diff --git a/.github/workflows/build-publish.yml b/.github/workflows/build-publish.yml index 952b1a9d92..2d2ab90df6 100644 --- a/.github/workflows/build-publish.yml +++ b/.github/workflows/build-publish.yml @@ -169,6 +169,7 @@ jobs: - 'packages/display-field/**' - 'packages/frontend-components/**' - 'packages/gfs-interaction-policy/**' + - 'scripts/qa-recorder/**' external-rest-api: - 'external-rest-api/**' - 'packages/jwt-key-policy/**' diff --git a/.github/workflows/ci-public.yml b/.github/workflows/ci-public.yml index b51ae48f7d..c781222925 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 @@ -178,6 +178,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 @@ -191,6 +196,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: Test Playwright MCP cluster probe (hermetic) if: matrix.service == 'host-context-controller' run: >- @@ -247,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 @@ -457,6 +474,24 @@ jobs: 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: ${{ steps.certification_inputs.outputs.observed_commit }} + path: r61-b1-producers/observed-dev + persist-credentials: false + + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + ref: ${{ steps.certification_inputs.outputs.latest_commit }} + path: r61-b1-producers/latest-dev + persist-credentials: false + - name: Install dependencies working-directory: control-api run: npm ci @@ -469,6 +504,40 @@ jobs: working-directory: packages/workflow-runtime-core 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 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 + 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 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 run: npm ci @@ -477,16 +546,28 @@ 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 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 - name: Run control-api real Postgres suites (migration + GFS) working-directory: control-api env: CONTROL_API_REAL_PG_ADMIN_URL: postgresql://postgres@127.0.0.1:15433/postgres CONTROL_API_REAL_PG_REQUIRED: '1' + 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 @@ -530,9 +611,8 @@ jobs: echo 'real-Postgres control-api lane reported skipped tests' >&2 exit 1 fi - # 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 \ @@ -575,6 +655,21 @@ jobs: oauth.legacyGrantLaneFence.realPostgres.integration.test.ts \ routes.mcpOauth.legacyGrantLaneFence.realPostgres.integration.test.ts \ oauth.dcrCredential.installIdentity.realPostgres.integration.test.ts \ + db.realPostgresMigration.integration.test.ts \ + directoryManagedWriterLockOrder.realPostgres.integration.test.ts \ + externalSessionSingleTouch.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 \ + userSessionUserLockMode.realPostgres.integration.test.ts \ + workflowRunReadAndApprovalV1Compatibility.realPostgres.integration.test.ts \ contracts.llmAttention.wire.realPostgres.integration.test.ts \ db.llmRetireSeededCatalog.realPostgres.integration.test.ts \ routes.adminLlmProviderCatalogPolicy.realPostgres.integration.test.ts \ @@ -583,8 +678,7 @@ jobs: services.llmCatalogAutoEnable.realPostgres.integration.test.ts \ services.llmCatalogSyncFakeDb.concurrency.realPostgres.integration.test.ts \ services.llmCatalogSyncFakeDb.differential.realPostgres.integration.test.ts \ - services.llmProviderCatalogSettings.realPostgres.integration.test.ts \ - db.realPostgresMigration.integration.test.ts + services.llmProviderCatalogSettings.realPostgres.integration.test.ts - name: Install gfs-controller dependencies working-directory: gfs-controller @@ -673,8 +767,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 @@ -869,6 +965,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 @@ -902,6 +1001,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 @@ -1034,6 +1139,9 @@ jobs: - name: Test the seed's LLM model ensure through the admin API run: bash scripts/tests/test-e2e-llm-model-ensure.sh + - name: Test the workflow-trigger E2E team helper adds the member + run: bash scripts/tests/test-e2e-workflow-triggers-team-helper.sh + - name: Test the E2E LLM model manifest covers every E2E model literal run: bash scripts/tests/test-e2e-llm-models-manifest.sh diff --git a/AGENTS.md b/AGENTS.md index 1c5caf50ff..27a0a16d99 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/Makefile b/Makefile index bfd976cb38..010f301046 100644 --- a/Makefile +++ b/Makefile @@ -491,6 +491,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 @@ -736,6 +744,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 @@ -1555,8 +1567,10 @@ 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 + @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 5066a8b047..718aad1a2f 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.940", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-control-api", - "version": "0.1.891", + "version": "0.1.940", "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..25477607cb 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.940", "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 ce39f529cc..06ac075f2d 100644 --- a/control-api/src/config.ts +++ b/control-api/src/config.ts @@ -1274,7 +1274,7 @@ export const config: Config = { // handler, and the hostWakeCoalesceWindowMs coalescer runs INSIDE the // handler (it coalesces annotation projections, not calls) — coalesced // calls therefore still consume rate-limit budget, so this budget must - // cover RAW calls (the arithmetic above counts raw calls). 30/min is >=3x + // cover RAW calls (the arithmetic above counts raw calls). 150/min is >=3x // the single-hold mechanism volume and covers one held wake plus prewarm // bursts from a few concurrent devices (4 x 3 = 12) plus a real message. // Derivation is regression-guarded by test/config.hostWakeRateLimit.test.ts. diff --git a/control-api/src/db.ts b/control-api/src/db.ts index 3e51524b09..52a701c9b3 100644 --- a/control-api/src/db.ts +++ b/control-api/src/db.ts @@ -6,7 +6,16 @@ import { } from './boundedPgPool.js' import { config } from './config.js' import { SEEDED_ALLOWED_MODELS_LISTED_BY_CATALOG } from './data/llmCatalogLegacySeeds.js' +import { migrationSessionBoundsSql } from './migrations/migrationExecutionPolicy.js' +import { + LAST_DEV_MIGRATION_VERSION, + applyPendingPr1Migrations, +} from './migrations/migrationRunner.js' import { rootLogger } from './observability/logger.js' +import { + applyUserAccessFoundationSchema, + backfillLegacyPasswordSecurityEpochs, +} from './services/access/userAccessFoundationSchema.js' import { applyAdminSubscriptionRateLimitNamespace } from './services/adminSubscriptionRateLimitMigration.js' import { applyPasswordAdmissionSchema, @@ -91,8 +100,31 @@ export type DbTransactionClient = DbClient & { readonly [dbTransactionClientBrand]: 'transaction' } +export type DbTransactionOptions = { + signal?: AbortSignal + onDatabaseFailure?: (error: unknown) => unknown + /** Called only after PostgreSQL acknowledges COMMIT. Must be synchronous and non-throwing. */ + onCommitAcknowledged?: () => void + /** Called when COMMIT was sent but PostgreSQL did not acknowledge its outcome. */ + onCommitOutcomeUnknown?: () => void +} + +const UNKNOWN_COMMIT_OUTCOME_SQLSTATES = new Set(['08007', '40003']) + +function isDefinitivePostgresCommitErrorResponse(error: unknown): boolean { + if (!error || typeof error !== 'object') return false + const response = error as { name?: unknown; code?: unknown; severity?: unknown } + return ( + response.name === 'error' && + typeof response.code === 'string' && + /^[0-9A-Z]{5}$/.test(response.code) && + response.severity === 'ERROR' && + !UNKNOWN_COMMIT_OUTCOME_SQLSTATES.has(response.code) + ) +} + type DbSessionClient = DbClient & { - release: () => void + release: (destroy?: boolean | Error) => void } type DbConnector = { @@ -100,6 +132,7 @@ type DbConnector = { } const INIT_DB_LOCK_KEY_SQL = "hashtext('control-api-init-db-v1')::bigint" +const migrationLogger = rootLogger.child({ module: 'database-migration' }) const PASSWORD_SUCCESS_ADMISSION_MIGRATION = '0130_password_login_success_admission' type DbMigration = { @@ -6381,6 +6414,14 @@ export const CONTROL_API_MIGRATIONS: DbMigration[] = [ `) }, }, + { + version: '0137_user_access_foundation', + apply: applyUserAccessFoundationSchema, + }, + { + version: '0138_legacy_password_security_epoch_backfill', + apply: backfillLegacyPasswordSecurityEpochs, + }, ] /** @@ -6742,11 +6783,14 @@ 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) { + // Everything through this bound runs in this single transaction; the versions + // after it belong to the PR1 runner (migrations/migrationRunner.ts). + if (migration.version > LAST_DEV_MIGRATION_VERSION) break if (appliedVersions.has(migration.version)) continue if (migration.legacyVersions?.some(version => appliedVersions.has(version))) { await recordMigration(db, migration.version) @@ -6757,6 +6801,7 @@ async function applyPendingMigrations(db: DbClient): Promise { await recordMigration(db, migration.version) appliedVersions.add(migration.version) } + return appliedVersions } async function preparePasswordAdmissionMigrationFence( @@ -6824,7 +6869,11 @@ async function preparePasswordAdmissionMigrationFence( ) } + // dev set only a 60 s lock timeout here and inherited the pool's 15 s + // statement timeout. The migration session sets its own 15 s statement + // bound, so this transaction raises both to 60 s for the fence wait. await db.query("SET LOCAL lock_timeout = '60s'") + await db.query("SET LOCAL statement_timeout = '60s'") await preparePasswordAdmissionRolloutFence(db) return true } @@ -6842,8 +6891,12 @@ 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 @@ -6868,8 +6921,7 @@ export async function initDb(db: DbConnector = pool): Promise { inTransaction = true await client.query('SET LOCAL synchronous_commit = on') await client.query("SET LOCAL app.password_admission_rollout_migration = 'v2'") - - await applyPendingMigrations(client) + const appliedVersions = await applyPendingLegacyMigrations(client) // The explicit readiness probe is meaningful after the migration row is // visible in PostgreSQL. Lightweight migration fakes do not persist rows; // startup still performs this probe through assertDbReady(). @@ -6880,15 +6932,22 @@ export async function initDb(db: DbConnector = pool): Promise { ) { await assertPasswordAdmissionRolloutReady(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) { - rootLogger.warn({ err: rollbackError }, 'Control API migration rollback failed') + migrationLogger.warn({ err: rollbackError }, 'Database migration rollback failed') } } throw error @@ -6897,10 +6956,11 @@ export async function initDb(db: DbConnector = pool): Promise { try { await client.query(`SELECT pg_advisory_unlock(${INIT_DB_LOCK_KEY_SQL})`) } catch (unlockError) { - rootLogger.warn({ err: unlockError }, 'Control API migration advisory unlock failed') + destroySession = true + migrationLogger.warn({ err: unlockError }, 'Database migration advisory unlock failed') } } - client.release() + client.release(destroySession) } } @@ -6921,28 +6981,6 @@ export async function assertDbReady(db: DbClient = pool): Promise { } } -export type DbTransactionOptions = { - signal?: AbortSignal - /** Called only after PostgreSQL acknowledges COMMIT. Must be synchronous and non-throwing. */ - onCommitAcknowledged?: () => void - /** Called when COMMIT was sent but PostgreSQL did not acknowledge its outcome. */ - onCommitOutcomeUnknown?: () => void -} - -const UNKNOWN_COMMIT_OUTCOME_SQLSTATES = new Set(['08007', '40003']) - -function isDefinitivePostgresCommitErrorResponse(error: unknown): boolean { - if (!error || typeof error !== 'object') return false - const response = error as { name?: unknown; code?: unknown; severity?: unknown } - return ( - response.name === 'error' && - typeof response.code === 'string' && - /^[0-9A-Z]{5}$/.test(response.code) && - response.severity === 'ERROR' && - !UNKNOWN_COMMIT_OUTCOME_SQLSTATES.has(response.code) - ) -} - export async function withTransaction( work: (db: DbTransactionClient) => Promise, // Injectable only so the carrier's client lifecycle can be unit-tested against @@ -6951,16 +6989,15 @@ export async function withTransaction( options: DbTransactionOptions = {} ): Promise { const signal = options.signal + const mapDatabaseFailure = (error: unknown): unknown => + options.onDatabaseFailure ? options.onDatabaseFailure(error) : error signal?.throwIfAborted() - // The pool's existing finite connection timeout owns a pending borrow. An - // abort cannot release its caller until acquisition has settled, including - // disposal of a client delivered after the signal fired. let client: PoolClient & Pick try { client = (await txPool.connect()) as typeof client } catch (error) { signal?.throwIfAborted() - throw error + throw mapDatabaseFailure(error) } let transactionStarted = false let commitSent = false @@ -6968,16 +7005,10 @@ export async function withTransaction( let originalIdleTimeout: string | undefined 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)) @@ -6989,14 +7020,8 @@ export async function withTransaction( releaseError ??= reason if (termination) return termination = client.end().catch(error => { - releaseError = error instanceof Error ? error : true + releaseError ??= error instanceof Error ? error : true }) - // pg Client.end() destroys an active query's socket, but an idle client - // waits for the peer's EOF. Explicit cancellation must also end a half-open - // idle checkout. Client.connection.stream is the pinned driver's typed - // transport; Duplex.destroy() is idempotent and its socket close drives the - // driver's connection end event awaited by Client.end(). Do not evict or - // finish the caller ahead of that event. client.connection.stream.destroy() } const onAbort = (): void => { @@ -7005,11 +7030,39 @@ export async function withTransaction( 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) { + if ( + text === 'COMMIT' && + commitSent && + !transactionFinished && + !isDefinitivePostgresCommitErrorResponse(error) + ) { + options.onCommitOutcomeUnknown?.() + } + 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 { signal?.throwIfAborted() if (signal) { - // Read normalized milliseconds from this backend, not from client startup - // options: a role/database default or a reused session can be different. const settings = await client.query<{ statement_timeout_ms: string | null idle_timeout_ms: string | null @@ -7017,14 +7070,10 @@ export async function withTransaction( (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 setting = settings.rows[0] - const statementTimeout = setting?.statement_timeout_ms - const idleTimeout = setting?.idle_timeout_ms + const statementTimeout = settings.rows[0]?.statement_timeout_ms + const idleTimeout = settings.rows[0]?.idle_timeout_ms const statementMs = Number(statementTimeout) const idleMs = Number(idleTimeout) - // These are the existing createBoundedPgPoolForConnection startup bounds. - // An unbounded or invalid effective server setting cannot supply a finite - // remote cleanup backstop, so this cancellable transaction must fail loud. if ( typeof statementTimeout !== 'string' || !/^\d+$/.test(statementTimeout) || @@ -7038,11 +7087,6 @@ export async function withTransaction( throw new Error('Invalid effective PostgreSQL cancellation timeout bounds') } if (idleMs === 0 || idleMs > statementMs) { - // This must be SESSION scope before BEGIN. AbortTransaction restores a - // SET LOCAL value before entering idle-in-transaction (aborted), which - // would remove the very backstop needed when a blackhole hides our FIN. - // Record the original before sending: a missing set_config reply is also - // uncertain, and that checkout must be ended rather than returned healthy. originalIdleTimeout = idleTimeout await client.query('SELECT set_config($1, $2, false)', [ 'idle_in_transaction_session_timeout', @@ -7051,39 +7095,23 @@ export async function withTransaction( signal.throwIfAborted() } } - await client.query('BEGIN') + await executeQuery('BEGIN', undefined, false) transactionStarted = true + signal?.throwIfAborted() if (signal) { signal.throwIfAborted() - // PostgreSQL 16 on the service's Linux runtime supports socket polling - // during queries. Its default (0) can leave a disconnected writer running - // until statement_timeout; this scoped setting detects our transport stop - // promptly. Unsupported servers fail this transaction instead of silently - // omitting cancellation. The existing statement_timeout stays in force. 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 transaction = signal - ? ({ - query: async (text: string, values?: unknown[]) => { - signal.throwIfAborted() - const result = await client.query(text, values) - signal.throwIfAborted() - return result - }, - } as unknown as DbTransactionClient) - : (client as unknown as DbTransactionClient) - const result = await work(transaction) + const result = await work(transactionClient as unknown as DbTransactionClient) signal?.throwIfAborted() commitSent = true - await client.query('COMMIT') + await executeQuery('COMMIT', undefined, false) transactionFinished = true options.onCommitAcknowledged?.() - // An acknowledged COMMIT is the outcome, even if the signal aborted - // meanwhile: the finally still evicts the session instead of restoring it. return result } catch (error) { if (commitSent || !transactionStarted) { @@ -7093,22 +7121,14 @@ export async function withTransaction( await client.query('ROLLBACK') transactionFinished = true } catch (rollbackError) { - releaseError = rollbackError instanceof Error ? rollbackError : true + if (!signal?.aborted) { + releaseError = rollbackError instanceof Error ? rollbackError : true + } } } - // Only an ERROR-severity PostgreSQL response proves COMMIT was rejected. - // FATAL/PANIC may terminate a backend after COMMIT succeeded while its - // acknowledgement is withheld, so those and SQLSTATE 08007/40003 stay unknown. - if (commitSent && !transactionFinished && !isDefinitivePostgresCommitErrorResponse(error)) { - options.onCommitOutcomeUnknown?.() - } signal?.throwIfAborted() throw error } finally { - // Work (including its own finally) and any rollback have already unwound. - // Only an acknowledged, healthy transaction may restore session state and - // return to the pool. Lost COMMIT/ROLLBACK replies leave the server backstop - // installed; local transport completion is not remote cleanup observation. if ( originalIdleTimeout !== undefined && transactionFinished && @@ -7122,8 +7142,6 @@ export async function withTransaction( originalIdleTimeout, ]) } catch (restoreError) { - // Do not replace the work outcome or relabel an acknowledged COMMIT as - // a rollback. A session that failed restoration is physically ended. releaseError = restoreError instanceof Error ? restoreError : true } } @@ -7136,7 +7154,6 @@ export async function withTransaction( : (releaseError ?? asyncClientError ?? true) ) } - // Keep both listeners and the checkout until physical termination completes. if (termination) await termination signal?.removeEventListener('abort', onAbort) client.removeListener('error', onClientError) @@ -7147,9 +7164,6 @@ export async function withTransaction( if (asyncClientError && !releaseError) { releaseError = asyncClientError } - if (signal?.aborted) { - releaseError = signal.reason instanceof Error ? signal.reason : true - } client.release(releaseError) } } diff --git a/control-api/src/gfs/externalAuthority.ts b/control-api/src/gfs/externalAuthority.ts index ac6563e22a..063060d470 100644 --- a/control-api/src/gfs/externalAuthority.ts +++ b/control-api/src/gfs/externalAuthority.ts @@ -1,5 +1,12 @@ 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 { type GfsDesktopOperatorLink, GfsDesktopOperatorLinkError, @@ -28,6 +35,156 @@ 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 for the session this request authenticated. The + * signing read compares that session's generation (V1 `authGeneration`, V2 + * `session_version`) with the current row and refuses on any difference; GFSC + * re-reads users.lifecycle_version on every request, so a change after signing + * fails closed there. + * + * The V1 read also checks external_v1_session_revocations for the + * authenticated token hash (same predicate as the V1 session validator), so a + * single-token V1 logout that commits between the session middleware and this + * read refuses the mint. The V2 read joins the authenticated session row and + * detects a per-session revocation the same way. + * + * 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 => { + // One unlocked statement per mint (M-30). V1 reads the token's + // revocation and V2 the authenticated session row in the same snapshot + // as the user row. + const result = + context.contract === 'v1' + ? await transaction.query( + `SELECT lifecycle_state, lifecycle_version, clock_timestamp() AS db_now, + EXISTS ( + SELECT 1 + FROM external_v1_session_revocations r + WHERE r.token_hash = $2 + AND r.user_id = $1 + AND r.expires_at > clock_timestamp() + ) AS token_revoked + FROM users + WHERE id = $1`, + [context.userId, context.tokenHash] + ) + : await transaction.query( + `SELECT u.lifecycle_state, u.lifecycle_version, clock_timestamp() AS db_now, + s.revoked_at, s.session_version, + s.absolute_expires_at, s.idle_expires_at + FROM users u + JOIN external_user_sessions s ON s.sid = $2 AND s.user_id = u.id + WHERE u.id = $1`, + [context.userId, context.sid] + ) + const row = result.rows[0] as + | { + lifecycle_state?: unknown + lifecycle_version?: unknown + db_now?: Date | string + token_revoked?: unknown + revoked_at?: unknown + session_version?: unknown + absolute_expires_at?: unknown + idle_expires_at?: unknown + } + | undefined + if (!row) return null + const generation = Number(row.lifecycle_version) + const dbNow = timestampOf(row.db_now) + if ( + row.lifecycle_state !== 'active' || + !Number.isSafeInteger(generation) || + generation < 1 || + !Number.isFinite(dbNow.getTime()) || + expiresAt * 1000 <= dbNow.getTime() + ) { + return null + } + if (context.contract === 'v1') { + // Only an explicit false from the EXISTS proves the token is not revoked. + if (row.token_revoked !== false || generation !== context.authGeneration) return null + } else { + // Same expiry rules as the V2 session validator + // (userSessionService.ts validateLoadedSession), judged against the + // one db_now read above. Mint stays read-only: it refuses and leaves + // the revocation write to the validator. + const absoluteExpiresAt = timestampOf(row.absolute_expires_at) + const idleExpiresAt = timestampOf(row.idle_expires_at) + if ( + row.revoked_at !== null || + Number(row.session_version) !== context.sessionVersion || + !Number.isFinite(absoluteExpiresAt.getTime()) || + !Number.isFinite(idleExpiresAt.getTime()) || + dbNow.getTime() >= absoluteExpiresAt.getTime() || + dbNow.getTime() >= idleExpiresAt.getTime() + ) { + return null + } + } + + return signGfsToken({ + subject: input.subject, + drive: input.drive, + scopes: input.scopes, + ...(input.pathBindings === undefined ? {} : { pathBindings: input.pathBindings }), + authGeneration: generation, + principalType: 'user', + }) + }, + undefined, + { onDatabaseFailure: externalSessionDatabaseFailure } + ) + } catch (error) { + if (isExternalSessionBackendUnavailableError(error)) { + throw new ExternalGfsAuthorityError(503, 'gfs_authority_unavailable', { cause: error }) + } + throw error + } +} + +function timestampOf(value: unknown): Date { + return value instanceof Date ? value : new Date(String(value ?? '')) +} + export class ExternalGfsAuthorityError extends Error { constructor( readonly status: 403 | 503, diff --git a/control-api/src/http/errorHandler.ts b/control-api/src/http/errorHandler.ts index a50612e90e..2812225961 100644 --- a/control-api/src/http/errorHandler.ts +++ b/control-api/src/http/errorHandler.ts @@ -3,11 +3,13 @@ import { ApiException } from '@kubernetes/client-node' import { STATUS_CODES } from 'node:http' import { clearRateLimitHeaders } from '../middleware/rateLimitMiddleware.js' import { rootLogger } from '../observability/logger.js' +import { ExternalSessionIssuanceUnavailableError } from '../services/auth/externalSessionIssuanceError.js' import { PasswordMutationInternalError, passwordMutationCauseKind, } from '../services/auth/passwordAdmissionError.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 @@ -180,6 +182,24 @@ export function clerumErrorHandler( return } + 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 new file mode 100644 index 0000000000..7e9820d34e --- /dev/null +++ b/control-api/src/http/publicApiError.ts @@ -0,0 +1,51 @@ +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' + | 'session_issuance_temporarily_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 +) { + return { + error: { + code, + message, + correlationId, + retryable, + }, + } +} + +export function sendPublicApiError( + req: Request, + res: Response, + status: number, + code: PublicApiErrorCode, + message: string, + retryable = false +): void { + res + .status(status) + .json( + publicApiErrorBody(req.correlationId ?? fallbackCorrelationId(), code, message, retryable) + ) +} diff --git a/control-api/src/main.ts b/control-api/src/main.ts index 0687b2fb52..5a6ccf3ac5 100644 --- a/control-api/src/main.ts +++ b/control-api/src/main.ts @@ -5,6 +5,7 @@ import { reconcileAllowedModelsConfigMapOnBoot } from './llmAllowedModelsBootRec import { rootLogger } from './observability/logger.js' import { logRegistryConnectionState } from './registryBootGuard.js' import { ControlApiServer } from './server.js' +import { resolveEffectiveUserAccessPolicy } from './services/access/userAccessRuntimePolicy.js' import { startAdminRevokedTokenCleanup, stopAdminRevokedTokenCleanup, @@ -149,6 +150,12 @@ async function main(): Promise { await assertDbReady() ensureStartupActive() logger.info({ event: 'control_api_database_ready' }, 'Database schema ready') + const userAccessPolicy = await resolveEffectiveUserAccessPolicy() + logger.info( + { event: 'user_access_policy_ready', policyRevision: userAccessPolicy.policyRevision }, + 'User-access policy ready' + ) + ensureStartupActive() startEntityChangeDispatcher() // Observability only (never fatal): report whether this self-hosted deployment diff --git a/control-api/src/middleware/externalSessionAuth.ts b/control-api/src/middleware/externalSessionAuth.ts index abf10ae32f..3ddf422ddf 100644 --- a/control-api/src/middleware/externalSessionAuth.ts +++ b/control-api/src/middleware/externalSessionAuth.ts @@ -1,79 +1,69 @@ 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 { verifyExternalSessionToken } from '../utils/auth/externalSessionAuthToken.js' - -/** Retry-After sent when the user row cannot be read to validate a session. */ +import { getLiveTeamMembership } from '../services/access/liveTeamAuthorization.js' +import { authenticateExternalUserSession } from '../services/auth/externalSessionAuthentication.js' +import type { + ExternalSessionAuthentication, + ExternalSessionClient, + ExternalSessionPurpose, +} from '../services/auth/externalSessionAuthentication.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 -// 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 -} - -export type ExternalAuthedRequest = Request & { - externalAuth?: AuthClaims + ;(req.log ?? 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, + }) } /** - * 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. + * Marks a handler-level 503 as a retryable outage before its body is sent: + * sets `Retry-After` and `Cache-Control: no-store` and logs the failure's + * cause under `event`. The caller still sends its own response body. */ -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 function markAuthorityUnavailable( + req: Request, + res: Response, + error: Error, + event: string +): void { + ;(req.log ?? rootLogger).warn({ event, err: error.cause }, 'external authority is unavailable') + res.setHeader('Retry-After', String(SESSION_BACKEND_RETRY_AFTER_SECONDS)) + res.setHeader('Cache-Control', 'no-store') } -export async function assertCurrentExternalSession(claims: AuthClaims): Promise { - if (!(await isCurrentExternalSession(claims))) { - throw new Error('external session is inactive or stale') +export type ExternalAuthedRequest = Request & { + externalAuth?: AuthClaims + externalSessionAuthentication?: Extract< + ExternalSessionAuthentication, + { status: 'authenticated' } + > + externalTeamAuth?: { + teamId: string + role: TeamRole } } @@ -81,60 +71,103 @@ function extractUserSessionToken(req: Request): string { return String(req.header('x-user-session-token') || '').trim() } -export function requireValidExternalSessionToken( - req: ExternalAuthedRequest, +export function extractRouteSessionToken(req: Request): string { + const body = (req.body ?? {}) as { token?: unknown; sessionToken?: unknown } + 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( + req: Request, res: Response, - next: NextFunction -): Promise { - return requireValidExternalSessionTokenAsync(req, res, next) + 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.') } -async function requireValidExternalSessionTokenAsync( +/** + * 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.externalSessionAuthentication = authentication + next() + } catch (error) { + handleExternalSessionBackendFailure(error, req, res, next) + } + } +} + +export async function requireValidExternalSessionToken( 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 - } + const token = extractUserSessionToken(req) + if (!token || token.length > 4096) { + 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, - }) + try { + const authentication = await authenticateExternalUserSession(token, { + purpose: 'protected', + client: { version: req.header('x-evenfire-client-version') || undefined }, + }) + if (authentication.status === 'upgrade_required') { + res.status(426).json({ error: 'upgrade_required' }) return } - if (!current) { + if (authentication.status !== 'authenticated') { res.status(401).json({ error: 'Unauthorized' }) return } - - req.externalAuth = claims + req.externalAuth = authentication.claims + req.externalSessionAuthentication = authentication next() } catch (error) { - next(error) + handleExternalSessionBackendFailure(error, req, res, next) } } @@ -151,14 +184,34 @@ 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, { + onDatabaseFailure: externalSessionDatabaseFailure, + }) + if (!membership) { + res.status(403).json({ error: 'Forbidden' }) + return + } + req.externalTeamAuth = membership + next() + } catch (error) { + // 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 + } + markAuthorityUnavailable(req, res, error, 'external_team_authority_unavailable') + res.status(503).json({ error: 'authority_unavailable' }) + } } } @@ -178,7 +231,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 +241,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/middleware/externalUserRateLimitPolicy.ts b/control-api/src/middleware/externalUserRateLimitPolicy.ts new file mode 100644 index 0000000000..75ee7789ac --- /dev/null +++ b/control-api/src/middleware/externalUserRateLimitPolicy.ts @@ -0,0 +1,17 @@ +import type { NextFunction, Request, Response } from 'express' +import { sendPublicApiError } from '../http/publicApiError.js' + +type AuthenticatedRequest = Request & { externalAuth?: { userId?: string } } + +/** Fail closed if authenticated route composition omitted the user context. */ +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() +} diff --git a/control-api/src/middleware/rateLimitMiddleware.ts b/control-api/src/middleware/rateLimitMiddleware.ts index 9a466a1d62..d3f47f3f25 100644 --- a/control-api/src/middleware/rateLimitMiddleware.ts +++ b/control-api/src/middleware/rateLimitMiddleware.ts @@ -195,14 +195,16 @@ export function createRateLimitEnforcer(opts: RateLimitEnforcerOptions): RateLim 'rate limit exceeded' ) } - if (opts.onLimited) opts.onLimited(req, res, retryAfterSec) - else + if (opts.onLimited) { + opts.onLimited(req, res, retryAfterSec) + } else { res.status(429).json({ error: 'Too Many Requests', code: 'rate_limited', message: `This request limit has been reached. Try again in ${retryAfterSec} seconds.`, retryAfterSeconds: retryAfterSec, }) + } } return async function enforce(req: Request, res: Response, rawKey: string): Promise { 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/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..1a143b0a94 --- /dev/null +++ b/control-api/src/migrations/migrationExecutionPolicy.ts @@ -0,0 +1,64 @@ +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'`, + // A Job pod killed during a long statement would otherwise leave its + // backend running, and holding the session advisory lock, until the + // statement ends. The server checks the client socket every second; that + // needs PostgreSQL 14+ on Linux or 15+ on macOS, illumos or BSD, and other + // servers reject the setting, so initDb fails there. + `${scope} client_connection_check_interval = '1s'`, + ] +} diff --git a/control-api/src/migrations/migrationRunner.ts b/control-api/src/migrations/migrationRunner.ts new file mode 100644 index 0000000000..7a566f703b --- /dev/null +++ b/control-api/src/migrations/migrationRunner.ts @@ -0,0 +1,214 @@ +import type { DbClient } from '../db.js' +import { migrationSessionBoundsSql } from './migrationExecutionPolicy.js' +import { preparePr1Migration } from './pr1OnlineIndexPlan.js' + +export const PR1_MIGRATION_VERSIONS = Object.freeze([ + '0137_user_access_foundation', + '0138_legacy_password_security_epoch_backfill', +] as const) + +// The one bound between initDb's single dev batch and this runner: every migration +// up to and including this version runs in the dev batch (db.ts imports it), and +// this runner owns only the PR1 versions that follow it. Until this change merges, +// a dev merge that brings new migrations moves this bound to dev's last version and +// renumbers PR1_MIGRATION_VERSIONS above it, and every dev merge moves the latestDev +// producer in tests/contracts/certification-inputs.json to dev's tip. After it merges +// the bound stays: a later migration adds its version to the list this runner +// applies, or initDb throws "Unclassified post-dev migrations". +export const LAST_DEV_MIGRATION_VERSION = '0136_llm_catalog_dismissed_backfill' + +// Migration names that earlier heads of this change recorded under other +// four-character prefixes (decimal such as `0126` or hexadecimal such as +// `010a`; some below 0130, and `user_access_foundation` under the prefix `0131`, +// in some heads with a larger body). The name after the prefix is the identity. +const EARLIER_PR1_MIGRATION_NAMES: readonly string[] = Object.freeze([ + 'user_access_foundation', + 'invitation_delivery_commands', + 'catalog_utf8_ordering', + 'composable_catalog_revisions', + 'gfs_catalog_revision_components', + 'legacy_password_security_epoch_backfill', + 'user_access_foundation_definer_temp_shadow_hardening', + 'authorization_revision_delete_compatibility', +]) + +// Tables that only earlier heads created. Their trigger functions are found by +// name prefix: a trigger cannot outlive its function. +const EARLIER_PR1_TABLES = Object.freeze([ + 'authorization_catalog_environment', + 'authorization_catalog_revision', + 'authorization_catalog_writer_components', + 'authorization_resource_revisions', + 'authorization_team_revisions', + 'authorization_user_revisions', + 'invitation_delivery_commands', + 'operational_catalog_source_state', + 'operational_relationships_staging', + 'operational_resource_index', + 'operational_resource_index_staging', + 'operational_resource_relationships', +]) + +const EARLIER_PR1_HEAD_CLEANUP_REFERENCE = + 'see docs/deploy/user-access-foundation-upgrade.md#databases-that-ran-an-earlier-head-of-this-change' + +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') + } catch (error) { + if (started) { + try { + await db.query('ROLLBACK') + } catch { + // The caller destroys the migration session after any failed unit. + } + } + throw error + } +} + +/** + * Rejects a database that ran an earlier head of this change. Such a database + * holds triggers whose writers no longer take the matching pre-locks, so it is + * cleaned up by an operator (the referenced SQL), never automatically. + * + * Retirement: earlier heads ran only on branch profiles and local databases + * (docs/deploy/user-access-foundation-upgrade.md). Remove this guard, the two + * EARLIER_PR1_* lists, the cleanup SQL, that doc section and their tests in one + * commit once both hold: this release's migration Job has completed on + * clerum-dev and production, and no branch registers an + * EARLIER_PR1_MIGRATION_NAMES migration other than PR1_MIGRATION_VERSIONS + * (today #594, until it is reconciled with this change). The same commit removes + * the upgrade tests from the pinned producers in + * test/migrationExecutionPolicy.realPostgres.integration.test.ts and the producers + * in tests/contracts/certification-inputs.json, unless a later change owns them: + * once this change is on dev, latestDev stays at the last dev commit before it. + * Remove it sooner, in + * the same commit, if a migration creates a function or table this probe + * matches: the guard runs on every initDb and would stop every later Job. + * If PR1_MIGRATION_VERSIONS are renumbered after a shared database recorded + * them, keep the old versions as legacyVersions, or this guard rejects it. + */ +async function assertNoEarlierPr1Head( + db: DbClient, + appliedVersions: ReadonlySet, + registeredVersions: ReadonlySet +): Promise { + const versions = [...appliedVersions] + .filter(version => { + const name = /^[0-9a-f]{4}_(.+)$/.exec(version)?.[1] + return ( + name !== undefined && + EARLIER_PR1_MIGRATION_NAMES.includes(name) && + !registeredVersions.has(version) + ) + }) + .sort() + const result = await db.query( + `/* pr1-earlier-head-guard */ + SELECT 'function' AS kind, fn.oid::regprocedure::text AS name + FROM pg_proc fn + JOIN pg_namespace fn_ns ON fn_ns.oid = fn.pronamespace + WHERE fn_ns.nspname = current_schema() + AND (fn.proname LIKE 'authorization\\_bump\\_%' ESCAPE '\\' + OR fn.proname LIKE 'authorization\\_prelock\\_%' ESCAPE '\\' + OR fn.proname = 'catalog_utf8_bytes') + UNION ALL + SELECT 'table' AS kind, rel.relname::text AS name + FROM pg_class rel + JOIN pg_namespace rel_ns ON rel_ns.oid = rel.relnamespace + WHERE rel_ns.nspname = current_schema() + AND rel.relname = ANY($1::text[]) + ORDER BY kind, name`, + [EARLIER_PR1_TABLES] + ) + const objects = result.rows as Array<{ kind: 'function' | 'table'; name: string }> + if (versions.length === 0 && objects.length === 0) return + const functions = objects.filter(row => row.kind === 'function').map(row => row.name) + const tables = objects.filter(row => row.kind === 'table').map(row => row.name) + throw new Error( + 'Database ran an earlier head of the user access foundation change: ' + + `recorded versions [${versions.join(', ')}]; ` + + `functions [${functions.join(', ')}]; ` + + `tables [${tables.join(', ')}]; ` + + EARLIER_PR1_HEAD_CLEANUP_REFERENCE + ) +} + +export async function applyPendingPr1Migrations({ + db, + migrations, + appliedVersions, + 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) { + 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 => migration.version > LAST_DEV_MIGRATION_VERSION && !expected.has(migration.version) + ) + if (unclassified.length > 0) { + throw new Error( + `Unclassified post-dev migrations: ${unclassified.map(item => item.version).join(', ')}` + ) + } + await assertNoEarlierPr1Head( + db, + appliedVersions, + new Set([...PR1_MIGRATION_VERSIONS, ...canonicalVersions, ...aliasOwners.keys()]) + ) + + 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 runBoundedTransaction(db, () => recordMigration(db, version)) + appliedVersions.add(version) + continue + } + + await preparePr1Migration(db, version) + await runBoundedTransaction(db, async () => { + 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..25772a955a --- /dev/null +++ b/control-api/src/migrations/pr1OnlineIndexPlan.ts @@ -0,0 +1,165 @@ +import type { DbClient } from '../db.js' +import { MIGRATION_EXECUTION_POLICY } from './migrationExecutionPolicy.js' + +export type OnlineIndexDefinition = Readonly<{ + migrationVersion: '0137_user_access_foundation' + name: string + table: string + unique?: boolean + keys: readonly string[] + include?: readonly string[] + createSql: string +}> + +type OnlineIndexStructure = Omit + +// Every planned index is a plain btree over bare columns. Quoted identifiers, +// expressions and predicates are unrepresentable, so PostgreSQL's deparsed +// definition can be compared byte for byte without a normalizer. +const PLAIN_IDENTIFIER = /^[a-z_][a-z0-9_]*$/ + +function assertPlainIdentifier(value: string, role: string, indexName: string): void { + if (!PLAIN_IDENTIFIER.test(value)) { + throw new Error(`Online index ${indexName} has a non-plain ${role}: ${JSON.stringify(value)}`) + } +} + +function indexColumnsSql(entry: OnlineIndexStructure): string { + const include = entry.include ? ` INCLUDE (${entry.include.join(', ')})` : '' + return `(${entry.keys.join(', ')})${include}` +} + +export function onlineIndex(structure: OnlineIndexStructure): OnlineIndexDefinition { + assertPlainIdentifier(structure.name, 'name', structure.name) + assertPlainIdentifier(structure.table, 'table', structure.name) + if (structure.keys.length === 0) throw new Error(`Online index ${structure.name} has no keys`) + for (const key of structure.keys) assertPlainIdentifier(key, 'key', structure.name) + if (structure.include && structure.include.length === 0) { + throw new Error(`Online index ${structure.name} has an empty INCLUDE list`) + } + for (const column of structure.include ?? []) { + assertPlainIdentifier(column, 'INCLUDE column', structure.name) + } + const unique = structure.unique ? 'UNIQUE ' : '' + return Object.freeze({ + ...structure, + keys: Object.freeze([...structure.keys]), + ...(structure.include ? { include: Object.freeze([...structure.include]) } : {}), + createSql: `CREATE ${unique}INDEX CONCURRENTLY ${structure.name} ON ${structure.table} ${indexColumnsSql(structure)}`, + }) +} + +/** The exact text PostgreSQL 16's pg_get_indexdef returns for a planned index. */ +export function expectedIndexDefinition(entry: OnlineIndexDefinition, schema: string): string { + assertPlainIdentifier(schema, 'schema', entry.name) + const unique = entry.unique ? 'UNIQUE ' : '' + return `CREATE ${unique}INDEX ${entry.name} ON ${schema}.${entry.table} USING btree ${indexColumnsSql(entry)}` +} + +export const PR1_ONLINE_INDEX_PLAN: readonly OnlineIndexDefinition[] = Object.freeze([ + onlineIndex({ + migrationVersion: '0137_user_access_foundation', + name: 'team_members_user_active_idx', + table: 'team_members', + keys: ['user_id', 'status', 'team_id'], + include: ['role', 'updated_at'], + }), + onlineIndex({ + migrationVersion: '0137_user_access_foundation', + name: 'user_contexts_context_user_idx', + table: 'user_contexts', + keys: ['context_id', 'user_id'], + }), + onlineIndex({ + migrationVersion: '0137_user_access_foundation', + name: 'team_contexts_context_team_idx', + table: 'team_contexts', + keys: ['context_id', 'team_id'], + }), + onlineIndex({ + migrationVersion: '0137_user_access_foundation', + name: 'user_agents_agent_user_idx', + table: 'user_agents', + keys: ['agent_name', 'user_id'], + }), + onlineIndex({ + migrationVersion: '0137_user_access_foundation', + name: 'team_agents_agent_team_idx', + table: 'team_agents', + keys: ['agent_name', 'team_id'], + }), +]) + +type IndexState = { + schema_name: string + table_name: string + indisunique: boolean + indisvalid: boolean + definition: string +} + +async function readIndexState(db: DbClient, name: string): Promise { + const result = await db.query( + `SELECT index_ns.nspname AS schema_name, + 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) || + state.definition !== expectedIndexDefinition(entry, state.schema_name) + ) { + 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}`) +} + +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 + for (const index of indexes) await ensureOnlineIndex(db, index) +} diff --git a/control-api/src/profileTypes.ts b/control-api/src/profileTypes.ts index ee30f86f8b..e2882d6eca 100644 --- a/control-api/src/profileTypes.ts +++ b/control-api/src/profileTypes.ts @@ -46,9 +46,17 @@ 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' + sid?: string + jti?: string + sv?: number + ver?: 2 + authTime?: number + amr?: string[] } export type RpcAccessClaims = { diff --git a/control-api/src/routes/admin/auth.ts b/control-api/src/routes/admin/auth.ts index afc904449c..56d4e86bdc 100644 --- a/control-api/src/routes/admin/auth.ts +++ b/control-api/src/routes/admin/auth.ts @@ -54,6 +54,10 @@ import { const ADMIN_PASSWORD_MIN_LENGTH = 8 const ADMIN_PASSWORD_MAX_LENGTH = 256 +// A replay proves another request already consumed the Desktop invitation and +// established its password. This completion therefore reports that it did not +// complete Desktop access instead of overwriting the password that request set. +const DESKTOP_ACCESS_ALREADY_ACCEPTED_REASON = 'desktop_invitation_already_accepted' const logger = rootLogger.child({ module: 'admin-auth' }) @@ -520,7 +524,11 @@ export function createAdminAuthRouter(): Router { ) return { status: 409, body: { error: acceptedDesktopAccess.error } } } - if (acceptedDesktopAccess?.data?.userId && desktopAccess) { + // An acceptance replay consumed no pending Desktop invitation: another + // request already accepted it, possibly setting its own password. The + // replay must not write a password over that request's credential. + const desktopAccessReplayed = acceptedDesktopAccess?.data?.replay === true + if (acceptedDesktopAccess?.data?.userId && desktopAccess && !desktopAccessReplayed) { const passwordResult = await setInvitationPasswordForUser( acceptedDesktopAccess.data.userId, validation.email, @@ -538,8 +546,13 @@ export function createAdminAuthRouter(): Router { body: { completed: true, desktopAccessCompleted: Boolean( - acceptedDesktopAccess && !('error' in acceptedDesktopAccess) + acceptedDesktopAccess && + !('error' in acceptedDesktopAccess) && + !desktopAccessReplayed ), + ...(desktopAccessReplayed + ? { desktopAccessReason: DESKTOP_ACCESS_ALREADY_ACCEPTED_REASON } + : {}), login: { username: result.email || result.username, }, diff --git a/control-api/src/routes/entityChangeStream.ts b/control-api/src/routes/entityChangeStream.ts index 67bae1b0be..a7da562a43 100644 --- a/control-api/src/routes/entityChangeStream.ts +++ b/control-api/src/routes/entityChangeStream.ts @@ -20,6 +20,20 @@ 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 +} + type EntityChangeStreamMessage = | { schemaVersion: 1 @@ -67,7 +81,7 @@ export function streamEntityChanges( req: Request, res: Response, initialCursor: string | null, - isAuthorized: () => Promise, + isAuthorized: () => Promise, principalKind: 'user' | 'operator', principalId: string = principalKind ): void { @@ -178,7 +192,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 +210,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 +230,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 +307,15 @@ export function streamEntityChanges( } try { - if (!(await isAuthorized())) { + const initialObservation = await isAuthorized() + const initialAuthorization = authorizationStatus(initialObservation) + if (initialAuthorization === 'unavailable') { + // 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() cleanup() return diff --git a/control-api/src/routes/external/auth.ts b/control-api/src/routes/external/auth.ts index 3bbceeaad3..774793ee53 100644 --- a/control-api/src/routes/external/auth.ts +++ b/control-api/src/routes/external/auth.ts @@ -1,27 +1,50 @@ -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 { + type ExternalAuthedRequest, + extractRouteSessionToken, + handleExternalSessionBackendFailure, + markAuthorityUnavailable, + requireExternalSessionRateLimitContext, +} from '../../middleware/externalSessionAuth.js' +import { requireAuthenticatedExternalUserRateLimitContext } from '../../middleware/externalUserRateLimitPolicy.js' import { passwordLoginSourceAdmission, sendPasswordAdmissionError, validatePasswordLogin, } from '../../middleware/passwordLoginAdmission.js' import { rateLimitMiddleware } from '../../middleware/rateLimitMiddleware.js' -import { AuthClaims, RpcScope, TEAM_ROLES } 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' +import { renewExternalUserSession } from '../../services/auth/externalSessionAuthentication.js' +import { + exchangeLegacyExternalUserSession, + issueExternalUserSession, + 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, + 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 { GoogleIdentityRejectedError, verifyGoogleIdToken } from '../../utils/auth/googleAuth.js' import { SANDBOX_UI_RPC_HOST_REF, classifyRpcTokenDenial, @@ -33,6 +56,116 @@ 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 } : {}), + } +} + +/** A non-string body field is an invalid value, never one to coerce with String(). */ +function trimmedBodyString(value: unknown): string { + return typeof value === 'string' ? value.trim() : '' +} + +function requireLegacySessionTokenPayload(req: Request, res: Response, next: NextFunction): void { + const body = (req.body ?? {}) as { + userId?: unknown + email?: unknown + teamId?: unknown + role?: unknown + } + const userId = trimmedBodyString(body.userId) + const email = trimmedBodyString(body.email) + const teamId = trimmedBodyString(body.teamId) + const role = trimmedBodyString(body.role) + const token = extractRouteSessionToken(req) + if ( + !userId || + !email || + !teamId || + !TEAM_ROLES.includes(role as AuthClaims['role']) || + !token || + token.length > 4096 + ) { + res.status(400).json({ error: 'invalid payload' }) + return + } + next() +} + +function sendExternalLoginError(res: Response, error: unknown): Response { + if (error === 'password_not_set') { + return res.status(409).json({ error: 'password_not_set' }) + } + 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 + } + markAuthorityUnavailable(req, res, error, 'external_auth_authority_unavailable') + sendPublicApiError(req, res, 503, 'authority_unavailable', message, true) +} + +async function requireLegacyRpcSessionRateLimitContext( + req: ExternalAuthedRequest, + res: Response, + next: NextFunction +): Promise { + 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 + } + 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.externalSessionAuthentication = authentication + next() + } catch (error) { + handleExternalSessionBackendFailure(error, req, res, next) + } +} + export function createExternalAuthRouter(gateway: K8sGateway): Router { const router = Router() @@ -40,25 +173,46 @@ 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 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' }) + } + throw error + } + const policy = await resolveEffectiveUserAccessPolicy() const login = await googleLoginData({ email: google.email, name: google.name, picture: google.picture, }) - if ('error' in login) { - return res.status(401).json({ error: 'Unauthorized' }) - } + 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, - }) + 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, + token: issued.token, + sessionContract: issued.contract, me: { id: login.user.id, email: google.email, @@ -89,19 +243,29 @@ export function createExternalAuthRouter(gateway: K8sGateway): Router { 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: 'invalid_credentials' }) + 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: 'invalid_credentials' }) + } + if ('error' in login) { + return sendExternalLoginError(res, login.error) + } 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, - }) + const issued = login.issued return res.status(200).json({ - token, + token: issued.token, + sessionContract: issued.contract, me: { id: login.user.id, email: login.user.email, @@ -152,127 +316,367 @@ export function createExternalAuthRouter(gateway: K8sGateway): Router { router.post('/external/auth/verify', async (req, res, next) => { try { - const token = String(req.body?.token || '').trim() + const token = typeof req.body?.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))) { + 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') { return res.status(401).json({ error: 'Unauthorized' }) } - return res.status(200).json({ claims }) + return res.status(200).json({ claims: authentication.claims }) } catch (error) { - return next(error) + handleExternalSessionBackendFailure(error, req, res, next) } }) - 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', + requireLegacySessionTokenPayload, + requireExternalSessionRateLimitContext({ purpose: 'switch', client: externalSessionClient }), + requireAuthenticatedExternalUserRateLimitContext, + 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 role = String(req.body?.role || '').trim() + const currentToken = extractRouteSessionToken(req) + 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! + 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, + role: role as AuthClaims['role'], + }, + { + policy: authentication.policy, + onDatabaseFailure: externalSessionDatabaseFailure, + } + ) + 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 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 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 || liveRole !== role) { + return res.status(403).json({ error: 'membership_not_found' }) + } + return res.status(200).json({ + token: currentToken, + sessionContract: 'v2', + deprecated: true, + }) + } catch (error) { + handleExternalAuthorityUnavailable( + error, + req, + res, + next, + 'Authorization is temporarily unavailable.' + ) } - 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', + requireExternalSessionRateLimitContext({ purpose: 'renew', client: externalSessionClient }), + requireAuthenticatedExternalUserRateLimitContext, + async (req: ExternalAuthedRequest, res, next) => { + try { + const token = extractRouteSessionToken(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, + onDatabaseFailure: externalSessionDatabaseFailure, + }) + 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 (error) { + handleExternalAuthorityUnavailable( + error, + req, + res, + next, + 'Session authority is temporarily unavailable.' + ) } + } + ) - 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', + requireExternalSessionRateLimitContext({ + purpose: 'revoke_cleanup', + client: externalSessionClient, + }), + requireAuthenticatedExternalUserRateLimitContext, + async (req: ExternalAuthedRequest, res, next) => { + try { + const token = extractRouteSessionToken(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', + undefined, + authentication.authorityContext, + { onDatabaseFailure: externalSessionDatabaseFailure } + ) + : await revokeLegacyUserSession(token, authentication.claims, 'logout', undefined, { + onDatabaseFailure: externalSessionDatabaseFailure, + }) + return res.status(200).json({ revoked }) + } catch (error) { + handleExternalAuthorityUnavailable( + error, + req, + res, + next, + 'Session authority is temporarily unavailable.' + ) } - if (includesSandboxUiHostRef && !requestsSandboxUiScope) { - return res.status(403).json({ error: 'sandbox_ui_scope_required' }) + } + ) + + router.post( + '/external/auth/sessions/revoke-all', + requireExternalSessionRateLimitContext({ purpose: 'protected', client: externalSessionClient }), + requireAuthenticatedExternalUserRateLimitContext, + async (req: ExternalAuthedRequest, res, next) => { + try { + const token = extractRouteSessionToken(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', + undefined, + authentication.authorityContext, + undefined, + { onDatabaseFailure: externalSessionDatabaseFailure } + ) + return res.status(200).json({ revoked }) + } catch (error) { + handleExternalAuthorityUnavailable( + error, + req, + res, + next, + 'Session authority is temporarily unavailable.' + ) } + } + ) - 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', + requireExternalSessionRateLimitContext({ + purpose: 'protected', + client: externalSessionClient, + requireV2: true, + }), + requireAuthenticatedExternalUserRateLimitContext, + async (req: ExternalAuthedRequest, res, next) => { + try { + const authentication = req.externalSessionAuthentication! + const revoked = await revokeUserSession( + authentication.claims.userId, + String(req.params.sid || '').trim(), + 'user_revoked', + undefined, + authentication.authorityContext, + { onDatabaseFailure: externalSessionDatabaseFailure } + ) + return res.status(200).json({ revoked }) + } catch (error) { + handleExternalAuthorityUnavailable( + error, + req, + res, + next, + 'Session authority is temporarily unavailable.' + ) } - 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', + requireLegacyRpcSessionRateLimitContext, + requireAuthenticatedExternalUserRateLimitContext, + 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, { + onDatabaseFailure: externalSessionDatabaseFailure, + }) + : null + } catch (error) { + handleExternalAuthorityUnavailable( + error, + req, + res, + next, + 'Authorization is temporarily unavailable.' + ) + 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/directory.ts b/control-api/src/routes/external/directory.ts index 1837bf81bf..cb4cd22813 100644 --- a/control-api/src/routes/external/directory.ts +++ b/control-api/src/routes/external/directory.ts @@ -2,10 +2,14 @@ import { Router } from 'express' import { config } from '../../config.js' import { createExternalClientRateLimiters } from '../../middleware/externalClientIdentity.js' import { + type ExternalAuthedRequest, requireExternalTeamParamMatch, requireValidExternalSessionToken, } from '../../middleware/externalSessionAuth.js' -import { searchDirectory } from '../../services/directory/index.js' +import { + DIRECTORY_SEARCH_MAX_QUERY_LENGTH, + searchDirectory, +} from '../../services/directory/index.js' export function createExternalDirectoryRouter(): Router { const router = Router() @@ -23,12 +27,15 @@ export function createExternalDirectoryRouter(): Router { router.get( '/external/directory/search', requireExternalTeamParamMatch(), - async (req, res, next) => { + 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) }) + if (q.length > DIRECTORY_SEARCH_MAX_QUERY_LENGTH) { + return res.status(400).json({ error: 'Invalid directory search request.' }) + } + return res.status(200).json(await searchDirectory(teamId, q)) } catch (error) { return next(error) } 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/gfs.ts b/control-api/src/routes/external/gfs.ts index dc3a04065d..f01f80356b 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' @@ -169,6 +172,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 } @@ -231,7 +253,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] @@ -545,15 +566,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) }) ) @@ -1050,25 +1070,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 @@ -1151,25 +1180,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(' ') @@ -1245,16 +1283,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/src/routes/external/invitationEmail.ts b/control-api/src/routes/external/invitationEmail.ts new file mode 100644 index 0000000000..01d5d29888 --- /dev/null +++ b/control-api/src/routes/external/invitationEmail.ts @@ -0,0 +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() + return EMAIL_PATTERN.test(email) ? email : null +} diff --git a/control-api/src/routes/external/invitations.ts b/control-api/src/routes/external/invitations.ts index 37b91e28c4..ccfe0cb534 100644 --- a/control-api/src/routes/external/invitations.ts +++ b/control-api/src/routes/external/invitations.ts @@ -1,6 +1,7 @@ import { type Request, Router } from 'express' import { randomBytes } from 'node:crypto' import { config } from '../../config.js' +import { withTransaction } from '../../db.js' import { createExternalClientRateLimiters, externalClientIpRateLimitKey, @@ -10,10 +11,16 @@ import { rejectBodyUserTeamMismatch, requireValidExternalSessionToken, } from '../../middleware/externalSessionAuth.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' import { - acceptInvitationForEmail, + issueExternalUserSession, + selectExternalSessionRepresentation, +} from '../../services/auth/externalSessionIssuance.js' +import { + acceptInvitationForEmailInTransaction, getInvitationByToken, listPendingInvitations, setInvitationPasswordForEmail, @@ -25,12 +32,27 @@ import { validateInvitationFlowToken, } from '../../services/invitationFlowRegistrationService.js' import { memberRegistrationErrorResponse } from '../../services/memberRegistrationErrors.js' -import { signExternalSessionToken } from '../../utils/auth/externalSessionAuthToken.js' function invitationAdmissionKey(req: Request, operation: 'preview' | 'password-token'): string { 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( @@ -38,6 +60,7 @@ export function createExternalInvitationsRouter(): Router { config.approvalRlExternalClientIpPerMin, config.approvalRlExternalEdgePerMin ) + router.get( '/external/invitations/token/:token', ...externalInvitationsEdgeRateLimits, @@ -103,10 +126,8 @@ export function createExternalInvitationsRouter(): Router { if (memberRegistrationErrorResponse(error)) throw error return res.status(400).json({ error: 'invalid_invitation' }) } - // The trusted Member Registration flow binds invitationUuid to the - // invitation token. The UI submits the distinct database row ID. - // Resolve the trusted token first, then bind the supplied row ID to - // that exact invitation before mutating credentials. + // Bind the trusted provider token to the submitted database row before + // the password service performs its independent lock-and-currentness check. const invitation = await getInvitationByToken(validation.invitationUuid) if (!invitation || invitation.id !== invitationId) { return res.status(403).json({ error: 'forbidden' }) @@ -147,22 +168,9 @@ export function createExternalInvitationsRouter(): Router { if (!userId) { return res.status(409).json({ error: 'invitation_not_ready' }) } - const session = result.data.sessionContext - if ( - !session || - session.userId !== userId || - !Number.isSafeInteger(session.authGeneration) || - session.authGeneration < 1 - ) { - return res.status(409).json({ error: 'invitation_not_ready' }) - } - - const sessionToken = signExternalSessionToken(session) - const { sessionContext: _sessionContext, ...response } = result.data - return res.status(200).json({ - ...response, - token: sessionToken, + ...withoutSessionResponseFields(result.data), + reauthenticationRequired: true, }) } catch (error) { if (sendPasswordAdmissionError(error, res)) return @@ -176,6 +184,7 @@ export function createExternalInvitationsRouter(): Router { ...externalInvitationsEdgeRateLimits, requireValidExternalSessionToken, rejectBodyUserTeamMismatch, + requireAuthenticatedExternalUserRateLimitContext, async (req, res, next) => { try { const userId = String(req.body?.userId || '').trim() @@ -188,7 +197,14 @@ export function createExternalInvitationsRouter(): Router { return res.status(400).json({ error: 'invalid payload' }) } - const result = await setInvitationPasswordForUser(userId, email, invitationId, password) + const result = await setInvitationPasswordForUser( + userId, + email, + invitationId, + password, + undefined, + { rejectIfPasswordAlreadySet: true } + ) if ('error' in result) { if (result.error === 'not_found') { return res.status(404).json({ error: 'not_found' }) @@ -196,6 +212,9 @@ export function createExternalInvitationsRouter(): Router { if (result.error === 'forbidden') { return res.status(403).json({ error: 'forbidden' }) } + if (result.error === 'password_already_set') { + return res.status(409).json({ error: 'password_already_set' }) + } if (result.error === 'not_accepted') { return res.status(409).json({ error: 'invitation_not_accepted' }) } @@ -210,7 +229,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) @@ -223,6 +242,8 @@ export function createExternalInvitationsRouter(): Router { ...externalInvitationsEdgeRateLimits, requireValidExternalSessionToken, rejectBodyUserTeamMismatch, + requireAuthenticatedExternalUserRateLimitContext, + async (req, res, next) => { try { const userId = String(req.body?.userId || '').trim() @@ -265,12 +286,13 @@ export function createExternalInvitationsRouter(): Router { '/external/invitations/pending', ...externalInvitationsEdgeRateLimits, requireValidExternalSessionToken, + requireAuthenticatedExternalUserRateLimitContext, 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) @@ -296,42 +318,70 @@ 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 } + // Only the transaction that consumed a pending invitation proves the + // 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 } + // 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, + 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, lockedUser: result.lockedUser } + ) + 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') { + if (accepted.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' }) + if ('replay' in accepted) { + return res.status(200).json(accepted.data) } - 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/external/members.ts b/control-api/src/routes/external/members.ts index e312426ad3..1244da2ec1 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, @@ -17,8 +18,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 @@ -85,14 +86,12 @@ export function createExternalMembersRouter(): Router { 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)) { + 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' }) } @@ -119,7 +118,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) } @@ -169,6 +170,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().toLowerCase() === req.params.userId.trim().toLowerCase()) { + return res.status(400).json({ error: 'invalid_target' }) + } const result = await updateManagedMemberRoleForUser( managerUserId, req.params.userId, @@ -178,9 +182,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) @@ -230,6 +231,9 @@ export function createExternalMembersRouter(): Router { try { const managerUserId = currentUserId(req) if (!managerUserId) return res.status(403).json({ error: 'Forbidden' }) + if (managerUserId.trim().toLowerCase() === req.params.userId.trim().toLowerCase()) { + return res.status(400).json({ error: 'invalid_target' }) + } const result = await deleteManagedMemberForUser( managerUserId, req.params.userId, @@ -237,9 +241,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/notifications.routes.ts b/control-api/src/routes/external/notifications.routes.ts index 0ab0bba9e1..00351833c2 100644 --- a/control-api/src/routes/external/notifications.routes.ts +++ b/control-api/src/routes/external/notifications.routes.ts @@ -4,6 +4,7 @@ import { asyncHandler } from '../../http/asyncHandler.js' import { createExternalClientRateLimiters } from '../../middleware/externalClientIdentity.js' import { type ExternalAuthedRequest, + handleExternalSessionBackendFailure, requireValidExternalSessionToken, } from '../../middleware/externalSessionAuth.js' import { mcpHostHttpMetrics } from '../../middleware/mcpHostHttpMetrics.js' @@ -16,6 +17,7 @@ import { notificationStreamEventsSentTotal, notificationStreamSnapshotSize, } from '../../observability/metrics.js' +import { observeExternalSessionCurrentness } from '../../services/auth/externalSessionCurrentnessObserver.js' import { acknowledgeDesktopNotificationDelivery } from '../../services/notificationAckService.js' import { listActiveApprovalNotificationsForUser, @@ -113,15 +115,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') { + res.status(401).json({ error: 'Unauthorized' }) + 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) { @@ -131,6 +144,15 @@ export function createExternalNotificationsRouter(): Router { let closed = false let flushInFlight = false + // A wake-up that arrives while a flush is reading, observing authority + // or writing is recorded here; the flush runs once more when it ends, + // so a notification committed after its read is not left for the next + // heartbeat. + let flushRequested = false + // Delivery opens only once the snapshot frame is written. Listener + // notifications that arrive during startup read and write nothing; the + // first flush after the snapshot catches up from lastCursor. + let deliveryOpen = false let lastCursor: string | null = rawCursor || null let listener: Awaited> | null = null let heartbeatTimer: NodeJS.Timeout | null = null @@ -159,6 +181,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' @@ -180,8 +242,13 @@ export function createExternalNotificationsRouter(): Router { ) const flushNewEvents = async () => { - if (closed || flushInFlight) return + if (closed || !deliveryOpen) return + if (flushInFlight) { + flushRequested = true + return + } flushInFlight = true + flushRequested = false try { const events = await listNotificationStreamEventsForUser(claims.userId, { limit: config.notificationStreamSnapshotLimit, @@ -191,6 +258,12 @@ export function createExternalNotificationsRouter(): Router { notificationStreamEventsFilteredTotal.inc({ reason: 'no_new_active_events' }, 1) return } + // One authority observation per non-empty batch, after the read and + // before the write. The writes below are synchronous, so this + // observation covers the whole batch. On a refusal the stream ends + // (with session_expired when denied) and lastCursor does not + // advance, so a reconnect re-reads the withheld events. + if (!(await closeForAuthority())) return for (const event of events) { if (closed) break if (event.eventType === 'approval.requested') { @@ -245,6 +318,12 @@ export function createExternalNotificationsRouter(): Router { cleanup() } finally { flushInFlight = false + // One more flush per recorded wake-up. An error path above has + // already called cleanup(), so `closed` stops it from running. + if (flushRequested && !closed) { + flushRequested = false + void flushNewEvents() + } } } @@ -278,10 +357,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, { @@ -291,16 +372,19 @@ export function createExternalNotificationsRouter(): Router { observedAt: new Date().toISOString(), }) notificationStreamEventsSentTotal.inc({ event_type: 'notification.snapshot' }, 1) + deliveryOpen = true 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 () => { + if (closed || !(await closeForAuthority())) return + writeStreamMessage(res, { + type: 'heartbeat', + observedAt: new Date().toISOString(), + }) + notificationStreamEventsSentTotal.inc({ event_type: 'heartbeat' }, 1) + void flushNewEvents() + })() }, config.notificationStreamHeartbeatMs) lifetimeTimer = setTimeout(() => { @@ -371,6 +455,7 @@ export function createExternalNotificationsRouter(): Router { ...externalNotificationsEdgeRateLimits, mcpHostHttpMetrics('external_notification_preferences_get'), requireValidExternalSessionToken, + asyncHandler(async (req: Request, res: Response) => { const extReq = req as ExternalAuthedRequest const claims = extReq.externalAuth @@ -387,6 +472,7 @@ export function createExternalNotificationsRouter(): Router { ...externalNotificationsEdgeRateLimits, mcpHostHttpMetrics('external_notification_preferences_put'), requireValidExternalSessionToken, + asyncHandler(async (req: Request, res: Response) => { const extReq = req as ExternalAuthedRequest const claims = extReq.externalAuth diff --git a/control-api/src/routes/external/sharedFilesystems.ts b/control-api/src/routes/external/sharedFilesystems.ts index 4cc14a25b4..e63afa2b5d 100644 --- a/control-api/src/routes/external/sharedFilesystems.ts +++ b/control-api/src/routes/external/sharedFilesystems.ts @@ -1,12 +1,19 @@ -import { Router } from 'express' +import { type Response, Router } from 'express' import { config } from '../../config.js' import { asyncHandler } from '../../http/asyncHandler.js' import type { K8sGateway } from '../../k8s.js' import { createExternalClientRateLimiters } from '../../middleware/externalClientIdentity.js' import { type ExternalAuthedRequest, + markAuthorityUnavailable, requireValidExternalSessionToken, } from '../../middleware/externalSessionAuth.js' +import { getLiveTeamMembership } from '../../services/access/liveTeamAuthorization.js' +import { + type ExternalSessionBackendUnavailableError, + externalSessionDatabaseFailure, + isExternalSessionBackendUnavailableError, +} from '../../services/auth/sessionDatabaseFailure.js' import { listActiveContextIds, partitionAccessValues, @@ -58,6 +65,14 @@ class ContextAccessReconciliationError extends Error { } } +class ContextAuthorizationUnavailableError extends Error { + constructor(cause: ExternalSessionBackendUnavailableError) { + super('Context authorization is unavailable') + this.name = 'ContextAuthorizationUnavailableError' + this.cause = cause + } +} + async function loadAccessibleContextIds( gateway: K8sGateway, userId: string, @@ -68,7 +83,7 @@ async function loadAccessibleContextIds( try { activeContextIds = await listActiveContextIds(gateway) } catch (err) { - console.warn('[external/shared-filesystems] context reconciliation failed:', err) + // Logged once, where the 503 is answered (resolveAccessibleContextIdsForRequest). throw new ContextAccessReconciliationError(err) } const userCtx = await getUserContexts(userId) @@ -77,10 +92,24 @@ 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, { + onDatabaseFailure: externalSessionDatabaseFailure, + }) + } catch (err) { + // Only the database-outage provenance is an outage. Anything else (a + // programmer or policy failure) keeps the global handler's sanitized 500. + if (!isExternalSessionBackendUnavailableError(err)) throw 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 @@ -107,16 +136,24 @@ function loadAccessibleContextIdsForRequest( async function resolveAccessibleContextIdsForRequest( gateway: K8sGateway, req: ExternalAuthedRequest, - res: { - locals: Record - status: (code: number) => { json: (body: unknown) => void } - } + res: Response ): Promise | null> { 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) { + markAuthorityUnavailable( + req, + res, + err, + 'external_shared_filesystems_context_reconciliation_failed' + ) + res.status(503).json({ error: 'context_reconciliation_unavailable' }) + return null + } + if (!(err instanceof ContextAuthorizationUnavailableError)) throw err + markAuthorityUnavailable(req, res, err, 'external_shared_filesystems_authority_unavailable') + 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 646876158c..da2959fbb7 100644 --- a/control-api/src/routes/external/teams.ts +++ b/control-api/src/routes/external/teams.ts @@ -17,19 +17,22 @@ import { listActiveContextIds, } from '../../services/directory/accessReconciliation.js' import { - createInvitation, + createManagedInvitationForUser, createTeamForUser, + deleteManagedMemberForUser, + externalManagedInvitationResponse, findMemberRole, getCurrentTeam, getTeamAgents, getTeamContexts, listMembers, renameTeam, - softDeleteMember, - updateMemberRole, + updateManagedMemberRoleForUser, } 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 @@ -79,6 +82,7 @@ export function createExternalTeamsRouter(gateway: K8sGateway): Router { router.put( '/external/teams/:teamId/name', requireExternalTeamParamMatch(), + requireExternalRole(['admin']), rejectBodyUserTeamMismatch, async (req, res, next) => { try { @@ -194,16 +198,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) } @@ -226,9 +232,18 @@ export function createExternalTeamsRouter(gateway: K8sGateway): Router { return res.status(400).json({ error: 'invalid invitation payload' }) } 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) } @@ -241,13 +256,22 @@ export function createExternalTeamsRouter(gateway: K8sGateway): Router { requireExternalRole(['admin']), async (req, res, next) => { try { - const deleted = await softDeleteMember( - req.params.teamId, + // 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, - (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' }) + 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 3be669b03c..b4a9a5ba1c 100644 --- a/control-api/src/routes/external/users.ts +++ b/control-api/src/routes/external/users.ts @@ -5,12 +5,13 @@ import { createExternalClientRateLimiters } from '../../middleware/externalClien import { type ExternalAuthedRequest, rejectBodyUserTeamMismatch, + requireExternalSessionRateLimitContext, requireExternalUserParamMatch, requireValidExternalSessionToken, } from '../../middleware/externalSessionAuth.js' import { sendPasswordAdmissionError } from '../../middleware/passwordLoginAdmission.js' import { rateLimitMiddleware } from '../../middleware/rateLimitMiddleware.js' -import { rootLogger } from '../../observability/logger.js' +import { type Logger, rootLogger } from '../../observability/logger.js' import { resolveMcpServersForAgents } from '../../services/access/mcpInvocable.js' import { filterAccessValues, @@ -47,6 +48,16 @@ type TeamDirectoryMember = { status: string } +type TeamDirectoryPartialError = { + teamId?: string + source: 'operational_resources' | 'members' | 'contexts' | 'agents' + 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 => { @@ -68,6 +79,33 @@ export function createExternalUsersRouter(gateway: K8sGateway): Router { config.approvalRlExternalClientIpPerMin, config.approvalRlExternalEdgePerMin ) + // `/me` is the session probe clients use to decide whether a stored session + // has ended, so it is registered before the generic chain below and answers + // a rejected session with the `invalid_session` envelope instead of the bare + // 401 that the service-credential guard also sends. Every other users route + // keeps the bare 401. The edge sends this GET with no body, so the body-token + // fallback of `requireExternalSessionRateLimitContext` never selects a token + // here; an outdated client receives the `upgrade_required` envelope (426). + router.get( + '/external/users/:userId/me', + ...externalUsersRateLimits, + requireExternalSessionRateLimitContext({ + purpose: 'protected', + client: req => ({ version: req.header('x-evenfire-client-version') || undefined }), + }), + requireExternalUserParamMatch(), + async (req, res, next) => { + try { + const teamId = String(req.query.teamId || '') + const data = await getMe(req.params.userId, teamId) + if (!data) return res.status(404).json({ error: 'not_found' }) + return res.status(200).json(data) + } catch (error) { + return next(error) + } + } + ) + router.use('/external/users', ...externalUsersRateLimits, requireValidExternalSessionToken) router.get( @@ -105,6 +143,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), @@ -113,43 +152,60 @@ export function createExternalUsersRouter(gateway: K8sGateway): Router { activeContextIds = new Set(contextIds) activeAgentNames = new Set(agentNames) } catch (err) { - ;(req.log ?? rootLogger).warn( - { err, module: 'external-users', userId: req.params.userId }, - 'access reconciliation failed for team directory' + requestLogger(req).warn( + { err, userId: req.params.userId, source: 'operational_resources' }, + 'external user team directory reconciliation failed' ) + 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 [members, contexts, agents] = await Promise.allSettled([ + listMembers(team.id), + 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') { + 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' }) } - } catch (err) { - ;(req.log ?? rootLogger).warn( - { err, module: 'external-users', teamId: team.id }, - 'team-directory fetch failed' - ) - return { + } + return { + item: { team, - members: [], - contextIds: [], - agentNames: [], - } + members: 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) @@ -171,21 +227,6 @@ export function createExternalUsersRouter(gateway: K8sGateway): Router { } ) - router.get( - '/external/users/:userId/me', - requireExternalUserParamMatch(), - async (req, res, next) => { - try { - const teamId = String(req.query.teamId || '') - const data = await getMe(req.params.userId, teamId) - if (!data) return res.status(404).json({ error: 'not_found' }) - return res.status(200).json(data) - } catch (error) { - return next(error) - } - } - ) - router.get( '/external/users/:userId/contexts', requireExternalUserParamMatch(), @@ -196,9 +237,9 @@ export function createExternalUsersRouter(gateway: K8sGateway): Router { try { active = new Set(await listActiveContextIds(gateway)) } catch (err) { - ;(req.log ?? rootLogger).warn( - { err, module: 'external-users', userId: req.params.userId }, - 'context reconciliation failed' + requestLogger(req).warn( + { err, userId: req.params.userId, source: 'contexts' }, + 'external user context reconciliation failed' ) } return res.status(200).json({ @@ -240,9 +281,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. - ;(req.log ?? rootLogger).warn( - { err, module: 'external-users', userId: req.params.userId }, - 'MCP server enrichment failed' + requestLogger(req).warn( + { err, userId: req.params.userId, source: 'agents' }, + 'external user MCP server enrichment failed' ) agents = [] } 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..0cba295859 100644 --- a/control-api/src/routes/external/workflow-approval-mediums.routes.ts +++ b/control-api/src/routes/external/workflow-approval-mediums.routes.ts @@ -83,6 +83,7 @@ export function createExternalWorkflowApprovalMediumsRouter(gateway: K8sGateway) ...externalWorkflowApprovalEdgeRateLimits, mcpHostHttpMetrics('external_workflow_approval_medium_challenge'), requireValidExternalSessionToken, + (req, res, next) => { void (async () => { try { @@ -171,6 +172,7 @@ export function createExternalWorkflowApprovalMediumsRouter(gateway: K8sGateway) ...externalWorkflowApprovalEdgeRateLimits, mcpHostHttpMetrics('external_workflow_approval_medium_link_session'), requireValidExternalSessionToken, + (req, res, next) => { void (async () => { try { @@ -261,6 +263,7 @@ export function createExternalWorkflowApprovalMediumsRouter(gateway: K8sGateway) ...externalWorkflowApprovalEdgeRateLimits, mcpHostHttpMetrics('external_workflow_approval_medium_confirm'), requireValidExternalSessionToken, + (req, res, next) => { void (async () => { try { @@ -307,6 +310,7 @@ export function createExternalWorkflowApprovalMediumsRouter(gateway: K8sGateway) ...externalWorkflowApprovalEdgeRateLimits, mcpHostHttpMetrics('external_workflow_approval_medium_list'), requireValidExternalSessionToken, + (req, res, next) => { void (async () => { try { @@ -341,6 +345,7 @@ export function createExternalWorkflowApprovalMediumsRouter(gateway: K8sGateway) ...externalWorkflowApprovalEdgeRateLimits, mcpHostHttpMetrics('external_workflow_approval_medium_targets'), requireValidExternalSessionToken, + (req, res, next) => { void (async () => { try { @@ -376,6 +381,7 @@ export function createExternalWorkflowApprovalMediumsRouter(gateway: K8sGateway) ...externalWorkflowApprovalEdgeRateLimits, mcpHostHttpMetrics('external_workflow_approval_medium_preference'), requireValidExternalSessionToken, + (req, res, next) => { void (async () => { try { @@ -401,6 +407,7 @@ export function createExternalWorkflowApprovalMediumsRouter(gateway: K8sGateway) ...externalWorkflowApprovalEdgeRateLimits, mcpHostHttpMetrics('external_workflow_approval_medium_display_name'), requireValidExternalSessionToken, + (req, res, next) => { void (async () => { try { @@ -439,6 +446,7 @@ export function createExternalWorkflowApprovalMediumsRouter(gateway: K8sGateway) ...externalWorkflowApprovalEdgeRateLimits, mcpHostHttpMetrics('external_workflow_approval_medium_delete'), requireValidExternalSessionToken, + (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 new file mode 100644 index 0000000000..d4ea99a400 --- /dev/null +++ b/control-api/src/routes/external/workflows/admission.ts @@ -0,0 +1,18 @@ +import { requireValidExternalSessionToken } 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([ + requireValidExternalSessionToken, + 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([ + workflowTriggerRateLimit(), + requireValidExternalSessionToken, + bindCompletedExternalWorkflowCaller, +]) diff --git a/control-api/src/routes/external/workflows/read.routes.ts b/control-api/src/routes/external/workflows/read.routes.ts index dbf5ac52ef..768c0448d9 100644 --- a/control-api/src/routes/external/workflows/read.routes.ts +++ b/control-api/src/routes/external/workflows/read.routes.ts @@ -10,7 +10,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' @@ -19,8 +20,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) @@ -30,8 +32,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 @@ -59,8 +62,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 2f72d68f72..ed2d80d978 100644 --- a/control-api/src/routes/external/workflows/runs.routes.ts +++ b/control-api/src/routes/external/workflows/runs.routes.ts @@ -12,8 +12,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' @@ -47,8 +48,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 @@ -69,8 +71,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 { @@ -90,8 +93,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 e93b17fd54..6fb190abc1 100644 --- a/control-api/src/routes/external/workflows/trigger.routes.ts +++ b/control-api/src/routes/external/workflows/trigger.routes.ts @@ -10,8 +10,8 @@ import { WorkflowTriggerHttpError, triggerWorkflow, } from '../../../services/workflows/workflowTriggerService.js' -import { requireExternalWorkflowCaller } 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' }) @@ -21,9 +21,9 @@ export function createExternalWorkflowTriggerRoutes(gateway: K8sGateway): Router router.post( `${BASE}/:ns/:name/trigger`, - workflowTriggerRateLimit(), + ...externalWorkflowTriggerAdmission, 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/gfs/grants.ts b/control-api/src/routes/gfs/grants.ts index 343d5d073d..bfb32e54b3 100644 --- a/control-api/src/routes/gfs/grants.ts +++ b/control-api/src/routes/gfs/grants.ts @@ -127,14 +127,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, @@ -839,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/routes/workflows/shared/auth.ts b/control-api/src/routes/workflows/shared/auth.ts index 0650a438f1..f8e8d68270 100644 --- a/control-api/src/routes/workflows/shared/auth.ts +++ b/control-api/src/routes/workflows/shared/auth.ts @@ -1,8 +1,7 @@ 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 type { WorkflowCaller } from '../../../services/workflows/types.js' -import { verifyExternalSessionToken } from '../../../utils/auth/externalSessionAuthToken.js' import { type McpHostControlScope, verifyMcpHostControlJwt, @@ -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 @@ -32,6 +27,10 @@ export type AdminWorkflowAuthedRequest = Request & { adminWorkflowCaller?: Extract } +export type ExternalWorkflowAuthedRequest = Request & { + externalWorkflowCaller?: Extract +} + export async function requireAdminWorkflowCaller( req: Request, res: Response @@ -81,19 +80,39 @@ 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) +export function externalWorkflowCaller( + req: Request +): ExternalWorkflowAuthedRequest['externalWorkflowCaller'] { + return (req as ExternalWorkflowAuthedRequest).externalWorkflowCaller +} - const claims = verifyExternalSessionToken(userSessionToken) - if (!claims) return unauthorized(res) - if (!(await isCurrentExternalSession(claims))) { - return unauthorized(res) +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 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/routes/workflows/shared/rateLimit.ts b/control-api/src/routes/workflows/shared/rateLimit.ts index e53ccc20e7..e28a7d6a7b 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,14 +442,15 @@ 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 } /** - * 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: 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 userSessionToken = String(req.header('x-user-session-token') || '').trim() diff --git a/control-api/src/services/access/liveTeamAuthorization.ts b/control-api/src/services/access/liveTeamAuthorization.ts new file mode 100644 index 0000000000..2dba829fb0 --- /dev/null +++ b/control-api/src/services/access/liveTeamAuthorization.ts @@ -0,0 +1,42 @@ +import { type DbClient, pool } from '../../db.js' +import type { TeamRole } from '../../profileTypes.js' + +export type LiveTeamMembership = { + teamId: string + role: TeamRole +} + +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 + +export async function getLiveTeamMembership( + userId: string, + teamId: string, + options: { + db?: Pick + onDatabaseFailure?: (error: unknown) => unknown + } = {} +): Promise { + // team_members.user_id and team_id are UUID columns: a value that is not a + // UUID cannot name a team or user row, so it has no membership. + if (!CANONICAL_UUID_RE.test(teamId) || !CANONICAL_UUID_RE.test(userId)) return null + 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` + const values = [userId, teamId] + let result + 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/access/userAccessFoundationSchema.ts b/control-api/src/services/access/userAccessFoundationSchema.ts new file mode 100644 index 0000000000..1ccce099fd --- /dev/null +++ b/control-api/src/services/access/userAccessFoundationSchema.ts @@ -0,0 +1,108 @@ +import type { DbClient } from '../../db.js' + +/** + * 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(` + 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); + `) + // 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 applyUserAccessRuntimePrivileges(db) +} + +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 + external_user_sessions, + external_user_session_security_epochs, + 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 TO control_api_runtime; + + -- The runtime never deletes an epoch. The user-deletion cascade runs as the table owner. + GRANT SELECT, INSERT, UPDATE ON TABLE 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; + `) +} diff --git a/control-api/src/services/access/userAccessPolicy.ts b/control-api/src/services/access/userAccessPolicy.ts new file mode 100644 index 0000000000..09f751269b --- /dev/null +++ b/control-api/src/services/access/userAccessPolicy.ts @@ -0,0 +1,216 @@ +import { createHash } from 'node:crypto' + +export const USER_ACCESS_POLICY_VERSION = '1' as const + +export type LegacyLifecycle = 'issue_and_accept' | 'accept_only' | 'disabled' +export type Readiness = 'ready' | 'unavailable' + +export type ConfiguredUserAccessIntent = Readonly<{ + legacyLifecycle: LegacyLifecycle + sessionV2Acceptance: boolean + sessionV2Issuance: 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 + 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 + minimumClientVersion: string | null + enforceMinimumClient: boolean +}> + +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 policyRevision( + readinessRevision: string, + effective: Omit +): string { + return createHash('sha256') + .update( + JSON.stringify({ + v: USER_ACCESS_POLICY_VERSION, + readinessRevision, + ...effective, + }) + ) + .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 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, + minimumClientVersion: intent.minimumClientEnforcement ? minimumVersion : null, + enforceMinimumClient: intent.minimumClientEnforcement, + }) + + 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), + 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-foundation-validated-v1', + snapshot: 'current', + legacySession: 'ready', + legacySwitchAuthorization: 'ready', + legacyDrainAccepted: false, + sessionV2Acceptance: 'ready', + sessionV2Issuance: 'unavailable', + minimumClientEnforcement: 'unavailable', +}) + +export const configuredUserAccessIntent = loadConfiguredUserAccessIntent(process.env) + +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 +} diff --git a/control-api/src/services/access/userAccessRuntimePolicy.ts b/control-api/src/services/access/userAccessRuntimePolicy.ts new file mode 100644 index 0000000000..c72c1c89d6 --- /dev/null +++ b/control-api/src/services/access/userAccessRuntimePolicy.ts @@ -0,0 +1,24 @@ +import { + type ConfiguredUserAccessIntent, + type EffectiveUserAccessPolicy, + compileUserAccessPolicy, + configuredUserAccessIntent, + reconstructionReadiness, +} from './userAccessPolicy.js' + +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 { + if (options.intent) return compileUserAccessPolicy(options.intent, reconstructionReadiness) + return (defaultPolicy ??= compileUserAccessPolicy( + configuredUserAccessIntent, + reconstructionReadiness + )) +} diff --git a/control-api/src/services/auth/externalSessionAuthentication.ts b/control-api/src/services/auth/externalSessionAuthentication.ts new file mode 100644 index 0000000000..3be7c77f4c --- /dev/null +++ b/control-api/src/services/auth/externalSessionAuthentication.ts @@ -0,0 +1,270 @@ +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, +} from '../access/userAccessPolicy.js' +import { resolveEffectiveUserAccessPolicy } from '../access/userAccessRuntimePolicy.js' +import { legacyExternalSessionAuthGeneration } from './legacyV1Generation.js' +import { externalSessionDatabaseFailure } from './sessionDatabaseFailure.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 ExternalSessionIdentityAuthentication = + | { + status: 'authenticated' + claims: AuthClaims + contract: 'v1' | 'v2' + authorityContext: ExternalSessionAuthorityContext + 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 + +type AuthenticationOptions = Readonly<{ + purpose: ExternalSessionPurpose + client?: ExternalSessionClient + policy?: EffectiveUserAccessPolicy + now?: Date +}> + +export type ExternalSessionAuthorityContext = Readonly< + | { + contract: 'v1' + userId: string + tokenHash: string + issuedAt: number + authGeneration: number + } + | { + contract: 'v2' + userId: string + sid: string + jti: string + sessionVersion: number + } +> + +/** + * 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. + */ +/** + * 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 verifyUserSessionV2Token(token)?.sub ?? verifyExternalSessionToken(token)?.userId ?? null +} + +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], + } +} + +/** + * 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. + */ +async function authenticateExternalUserSessionIdentity( + token: string, + options: Pick & { touch?: boolean } = {} +): Promise { + const v2Claims = verifyUserSessionV2Token(token) + if (v2Claims) { + const validation = await validateUserSessionClaims(v2Claims, { + touch: options.touch !== false, + onDatabaseFailure: externalSessionDatabaseFailure, + }) + if (validation.status !== 'valid') return validation + return { + status: 'authenticated', + contract: 'v2', + claims: normalizedV2Claims(v2Claims, validation.identity), + tokenClaims: v2Claims, + authorityContext: Object.freeze({ + contract: 'v2', + userId: v2Claims.sub, + sid: v2Claims.sid, + jti: v2Claims.jti, + sessionVersion: v2Claims.sv, + }), + } + } + + const v1Claims = verifyExternalSessionToken(token) + if (!v1Claims) return { status: 'invalid', reason: 'invalid_representation' } + const validation = await validateLegacyUserSession(token, v1Claims, { + onDatabaseFailure: externalSessionDatabaseFailure, + }) + 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, + }), + tokenClaims: v1Claims, + authorityContext: Object.freeze({ + contract: 'v1', + userId: v1Claims.userId, + tokenHash: validation.identity.jti, + issuedAt: v1Claims.iat!, + authGeneration, + }), + } +} + +/** + * 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()) + if ( + purposeRequiresMinimumClient(options.purpose) && + !clientMeetsMinimum(options.client, policy) + ) { + return { status: 'upgrade_required', reason: 'minimum_client_version' } + } + + // 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 ( + (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 & { + onDatabaseFailure?: (error: unknown) => unknown + } = {} +): Promise { + const policy = options.policy ?? (await resolveEffectiveUserAccessPolicy()) + 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, { + onDatabaseFailure: options.onDatabaseFailure, + }) + 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/externalSessionCurrentnessObserver.ts b/control-api/src/services/auth/externalSessionCurrentnessObserver.ts new file mode 100644 index 0000000000..2325d774f3 --- /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 { legacySecurityCutoffRejects } from './legacySecurityCutoff.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' } + if (legacySecurityCutoffRejects(context.issuedAt, row.valid_after)) { + 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/src/services/auth/externalSessionIssuance.ts b/control-api/src/services/auth/externalSessionIssuance.ts new file mode 100644 index 0000000000..56f54b26d9 --- /dev/null +++ b/control-api/src/services/auth/externalSessionIssuance.ts @@ -0,0 +1,242 @@ +import { type DbClient, pool, 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 { ExternalSessionIssuanceUnavailableError } from './externalSessionIssuanceError.js' +import { legacySecurityCutoffRejects } from './legacySecurityCutoff.js' +import { + createUserSession, + loadSessionDatabaseNow, + validateLegacyUserSession, +} from './userSessionService.js' + +export type ExternalSessionContract = 'v1' | 'v2' +export { ExternalSessionIssuanceUnavailableError } from './externalSessionIssuanceError.js' + +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 + +/** + * 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 +): 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 + // Legacy callers may still supply the generation they observed. V1 issuance + // 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: ExternalSessionIssuanceOptions +): Promise<{ token: string; contract: ExternalSessionContract }> { + const policy = options.policy + 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.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' } + } + + if (!policy.issueV1 || !policy.acceptV1) { + throw new Error('legacy user-session issuance is not effective') + } + + const lockedUser = options.lockedUser + const issueLegacy = async (db: Pick) => { + 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' || + !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) + 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 (legacySecurityCutoffRejects(issuedAt, validAfter)) { + throw new ExternalSessionIssuanceUnavailableError() + } + return signExternalSessionToken({ + userId: input.userId, + email: input.email, + teamId: input.teamId, + role: input.role, + authGeneration, + iat: issuedAt, + }) + } + + return { + token: options.db ? await issueLegacy(options.db) : await withTransaction(issueLegacy), + 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 + role: TeamRole + }, + options: { + policy: EffectiveUserAccessPolicy + transaction?: SessionTransaction + onDatabaseFailure?: (error: unknown) => unknown + } +): Promise { + if ( + input.claims.userId !== input.userId || + input.claims.email.toLowerCase() !== input.email.toLowerCase() + ) { + return { status: 'invalid_session' } + } + 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, + lockUser: true, + }) + if (validation.status !== 'valid') return { status: 'invalid_session' } + + const membership = await db.query( + `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 + 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_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', + userId: input.userId, + email: input.email, + teamId: input.teamId, + role: row.role, + authGeneration: Number(row.lifecycle_version), + authenticationMethods: [], + }, + { + 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/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/src/services/auth/legacySecurityCutoff.ts b/control-api/src/services/auth/legacySecurityCutoff.ts new file mode 100644 index 0000000000..e7a416f339 --- /dev/null +++ b/control-api/src/services/auth/legacySecurityCutoff.ts @@ -0,0 +1,26 @@ +/** + * The V1 security cutoff rule, defined once as the TypeScript comparator. + * + * A V1 session token is rejected when its whole-second `iat` is at or before + * external_user_session_security_epochs.valid_after, compared as an exact + * instant. Live security events store the second before the event; the + * lifecycle generation bumped in the same transaction rejects tokens of the + * previous generation issued in the event's own second. The historical + * password backfill stores password_set_at, which had no generation bump, so + * the whole second of that change is rejected. + * + * For a finite cutoff that a JavaScript Date can hold, pg parses timestamptz + * to a millisecond Date by truncation; for an integer `iat` that gives the same + * decision as the exact instant. PostgreSQL also stores infinity and dates + * beyond the Date range. No writer produces them, and this comparator throws + * on them instead of deciding. + */ +export function legacySecurityCutoffRejects( + issuedAtSeconds: number, + validAfter: Date | string | null | undefined +): boolean { + if (validAfter === null || validAfter === undefined) return false + const cutoffMs = (validAfter instanceof Date ? validAfter : new Date(validAfter)).getTime() + if (!Number.isFinite(cutoffMs)) throw new Error('invalid V1 security cutoff') + return issuedAtSeconds * 1000 <= cutoffMs +} diff --git a/control-api/src/services/auth/legacyV1Generation.ts b/control-api/src/services/auth/legacyV1Generation.ts new file mode 100644 index 0000000000..3e0f14b825 --- /dev/null +++ b/control-api/src/services/auth/legacyV1Generation.ts @@ -0,0 +1,14 @@ +import type { AuthClaims } from '../../profileTypes.js' + +/** + * A V1 session carries its lifecycle generation. The signer requires it + * (signExternalSessionToken, since 7fd634e6a), so a token without it is not + * a current session and maps to null like any other invalid generation. + */ +export function legacyExternalSessionAuthGeneration( + claims: Pick +): number | null { + return Number.isSafeInteger(claims.authGeneration) && Number(claims.authGeneration) >= 1 + ? Number(claims.authGeneration) + : null +} diff --git a/control-api/src/services/auth/passwordAdmissionError.ts b/control-api/src/services/auth/passwordAdmissionError.ts index 504655b170..8760b6c506 100644 --- a/control-api/src/services/auth/passwordAdmissionError.ts +++ b/control-api/src/services/auth/passwordAdmissionError.ts @@ -6,9 +6,9 @@ export class PasswordAdmissionError extends Error { readonly retryAfterSeconds: number, readonly reason: PasswordAdmissionDenialReason, readonly publicError: - 'rate_limited' | 'authority_unavailable' | 'recovery_outcome_unknown' = status === 429 - ? 'rate_limited' - : 'authority_unavailable' + | 'rate_limited' + | 'authority_unavailable' + | 'recovery_outcome_unknown' = status === 429 ? 'rate_limited' : 'authority_unavailable' ) { super(status === 429 ? 'rate_limited' : 'authority_unavailable') } @@ -27,7 +27,12 @@ export class PasswordMutationInternalError extends Error { } export type PasswordMutationCauseKind = - 'type_error' | 'reference_error' | 'range_error' | 'syntax_error' | 'error' | 'non_error' + | 'type_error' + | 'reference_error' + | 'range_error' + | 'syntax_error' + | 'error' + | 'non_error' export function passwordMutationCauseKind(cause: unknown): PasswordMutationCauseKind { try { diff --git a/control-api/src/services/auth/passwordSessionAuthentication.ts b/control-api/src/services/auth/passwordSessionAuthentication.ts new file mode 100644 index 0000000000..fb14a7bdc7 --- /dev/null +++ b/control-api/src/services/auth/passwordSessionAuthentication.ts @@ -0,0 +1,71 @@ +import { type DbClient, withTransaction } from '../../db.js' +import type { EffectiveUserAccessPolicy } from '../access/userAccessPolicy.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 + authGeneration: number +} + +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 + const authenticatedLogin = login as PasswordLoginSuccess + + return withTransaction(async (db: Pick) => { + const credential = await db.query( + `SELECT id, lifecycle_state, lifecycle_version + FROM users + WHERE id = $1 + AND email = $2 + AND password_hash = $3 + FOR NO KEY UPDATE`, + [authenticatedLogin.user.id, authenticatedLogin.user.email, authenticatedLogin.credentialHash] + ) + const lockedUser = credential.rows[0] as + | { lifecycle_state: unknown; lifecycle_version: unknown } + | undefined + if (!lockedUser) 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, + authGeneration: authenticatedLogin.authGeneration, + authenticationMethods: ['pwd'], + }, + { + db, + policy: input.policy, + lockedUser: { + lifecycleState: lockedUser.lifecycle_state, + lifecycleVersion: lockedUser.lifecycle_version, + }, + } + ) + return { ...authenticatedLogin, issued } + }) +} 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 new file mode 100644 index 0000000000..6ecef8dbe8 --- /dev/null +++ b/control-api/src/services/auth/userSessionService.ts @@ -0,0 +1,744 @@ +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, + USER_SESSION_V2_TYPE, + USER_SESSION_V2_VERSION, + signUserSessionV2Token, +} from '../../utils/auth/userSessionV2Token.js' +import type { ExternalSessionAuthorityContext } from './externalSessionAuthentication.js' +import { legacySecurityCutoffRejects } from './legacySecurityCutoff.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 +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 +} + +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 +} + +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) +} + +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 + 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, + 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 AND u.lifecycle_state = 'active' + 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 + }, + // 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) => { + 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) + const absoluteExpiresAt = plusSeconds(now, USER_SESSION_ABSOLUTE_LIFETIME_SECONDS) + + 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: { + db?: SessionDatabase + touch?: boolean + onDatabaseFailure?: (error: unknown) => unknown + } = {} +): Promise { + const work = async (db: SessionDatabase) => { + const row = await loadSessionForUpdate(db, claims.sid) + const now = await loadSessionDatabaseNow(db) + return validateLoadedSession(db, row, claims, now, options.touch !== false) + } + if (options.db) { + return work(sessionDatabaseWithFailureProvenance(options.db, options.onDatabaseFailure)) + } + return withTransaction(work, pool, { onDatabaseFailure: options.onDatabaseFailure }) +} + +export async function renewUserSession( + claims: UserSessionV2Claims, + options: { + db?: SessionDatabase + onDatabaseFailure?: (error: unknown) => unknown + } = {} +): Promise { + const work = async (db: SessionDatabase) => { + const row = await loadSessionForUpdate(db, claims.sid) + const now = await loadSessionDatabaseNow(db) + 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 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( + 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), + } + } + if (options.db) { + return work(sessionDatabaseWithFailureProvenance(options.db, options.onDatabaseFailure)) + } + return withTransaction(work, pool, { onDatabaseFailure: options.onDatabaseFailure }) +} + +export async function revokeUserSession( + userId: string, + sid: string, + reason: string, + db?: SessionDatabase, + actorContext?: ExternalSessionAuthorityContext, + options: { onDatabaseFailure?: (error: unknown) => unknown } = {} +): 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 + WHERE sid = $1 + AND user_id = $2 + FOR UPDATE`, + [sid, userId] + ) + if ((locked.rowCount ?? 0) === 0) return false + const now = await loadSessionDatabaseNow(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(sessionDatabaseWithFailureProvenance(db, options.onDatabaseFailure)) + : withTransaction(work, pool, { onDatabaseFailure: options.onDatabaseFailure }) +} + +export async function revokeAllUserSessions( + userId: string, + reason: string, + db?: SessionDatabase, + actorContext?: ExternalSessionAuthorityContext, + expectedLifecycleVersion?: string | number, + options: { onDatabaseFailure?: (error: unknown) => unknown } = {} +): Promise { + const work = async (transaction: SessionDatabase) => { + const user = await transaction.query( + `SELECT id, lifecycle_version + FROM users + WHERE id = $1 + FOR NO KEY UPDATE`, + [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) + + 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( + `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, compatibilityValidAfter, 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(sessionDatabaseWithFailureProvenance(db, options.onDatabaseFailure)) + : withTransaction(work, pool, { onDatabaseFailure: options.onDatabaseFailure }) +} + +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 } = {} +): Promise { + 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 NO KEY UPDATE`, + [context.userId] + ) + if ((principal.rowCount ?? 0) === 0) { + return { status: 'revoked', reason: 'user_unavailable' } + } + const row = await loadSessionForUpdate(db, context.sid) + const now = await loadSessionDatabaseNow(db) + 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, row, claims, now, false) + } + + const principal = await db.query( + `SELECT id, email, lifecycle_state, lifecycle_version + FROM users + WHERE id = $1 + FOR NO KEY 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 now = await loadSessionDatabaseNow(db) + 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 > $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, now] + ) + 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 (legacySecurityCutoffRejects(context.issuedAt, row.valid_after)) { + 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') +} + +export async function validateLegacyUserSession( + token: string, + claims: AuthClaims, + options: { + db?: SessionDatabase + lockUser?: boolean + onDatabaseFailure?: (error: unknown) => unknown + } = {} +): Promise { + const issuedAt = claims.iat + if (!issuedAt) return { status: 'invalid', reason: 'invalid_legacy_representation' } + const authGeneration = legacyExternalSessionAuthGeneration(claims) + if (authGeneration === null) { + 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 NO KEY UPDATE`, [ + claims.userId, + ]) + if ((locked.rowCount ?? 0) === 0) return { status: 'revoked', reason: 'user_unavailable' } + } + const result = await db.query( + `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 > 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)] + ) + const row = result.rows[0] as + | { + 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' || authGeneration !== Number(row.lifecycle_version)) { + return { status: 'revoked', reason: 'security_event' } + } + if (row.token_revoked) return { status: 'revoked', reason: 'logout' } + if (legacySecurityCutoffRejects(issuedAt, row.valid_after)) { + 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 + return work(sessionDatabaseWithFailureProvenance(db, options.onDatabaseFailure)) +} + +export async function revokeLegacyUserSession( + token: string, + claims: AuthClaims, + reason: string, + 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 NO KEY UPDATE`, [ + claims.userId, + ]) + if ((user.rowCount ?? 0) === 0) return false + 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 + ) + VALUES($1, $2, $3, $4, $5) + ON CONFLICT (token_hash) DO NOTHING + RETURNING token_hash`, + [legacySessionFingerprint(token), claims.userId, new Date(claims.exp * 1000), now, reason] + ) + return (result.rowCount ?? 0) > 0 + } + return db + ? work(sessionDatabaseWithFailureProvenance(db, options.onDatabaseFailure)) + : withTransaction(work, pool, { onDatabaseFailure: options.onDatabaseFailure }) +} diff --git a/control-api/src/services/directory/adminProvisioning.ts b/control-api/src/services/directory/adminProvisioning.ts index 4c6b52cfc7..286152cb2c 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, @@ -10,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 @@ -113,8 +138,14 @@ 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) { const inserted = await db.query( `INSERT INTO users(email, name) VALUES($1, $2) RETURNING id`, @@ -130,11 +161,15 @@ 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, undefined, lifecycleVersion) + } } await ensureDefaultTeamAndGrants(db, userId, teamLabel, input.agentNames, input.contextIds) @@ -206,7 +241,8 @@ export async function provisionMemberFromAdmin(input: { `SELECT id, email, name FROM users WHERE lower(email) = lower($1) - LIMIT 1`, + LIMIT 1 + FOR NO KEY UPDATE`, [admin.email] ) let created = false @@ -244,14 +280,15 @@ 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, undefined, lifecycleVersion) + } } if (assignments.size > 0) { diff --git a/control-api/src/services/directory/index.ts b/control-api/src/services/directory/index.ts index f65b79a1cf..b25d06940f 100644 --- a/control-api/src/services/directory/index.ts +++ b/control-api/src/services/directory/index.ts @@ -31,9 +31,9 @@ export { } from './teams.js' export { - acceptInvitation, acceptInvitationById, acceptInvitationForEmail, + acceptInvitationForEmailInTransaction, getInvitationByToken, addMemberToTeam, createPasswordSetupInvitationForUser, @@ -41,6 +41,7 @@ export { createInvitationForTeams, createSilentInvitationForTeams, createManagedInvitationForUser, + externalManagedInvitationResponse, deleteManagedMemberForUser, deleteManagedUserForUser, findMemberRole, @@ -59,6 +60,7 @@ export { listMembers, listPendingInvitations, searchDirectory, + DIRECTORY_SEARCH_MAX_QUERY_LENGTH, softDeleteMember, startDraftInvitationCleanup, stopDraftInvitationCleanup, diff --git a/control-api/src/services/directory/invitationStore.ts b/control-api/src/services/directory/invitationStore.ts index b7cc879329..61f60919d0 100644 --- a/control-api/src/services/directory/invitationStore.ts +++ b/control-api/src/services/directory/invitationStore.ts @@ -64,7 +64,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, @@ -73,7 +74,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 @@ -82,7 +84,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() @@ -96,10 +99,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] ) @@ -109,7 +114,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 @@ -122,7 +127,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 @@ -143,6 +148,41 @@ export async function lockPasswordResetUser( return (result.rows[0] as InvitationUserRow | undefined) ?? null } +/** + * Locks the authoritative user row inside the transaction that is about to + * write the password. A passwordless invited user is represented by + * `users.password_hash IS NULL`: the column is nullable and + * `ensureInvitationUser` inserts only email and name. + */ +export async function lockInvitationUserForPasswordSetup( + db: Pick, + userId: string +): Promise { + const result = await db.query( + `SELECT id, email, name, picture, password_hash, lifecycle_state, lifecycle_version + FROM users + WHERE id = $1 + LIMIT 1 + FOR NO KEY UPDATE`, + [userId] + ) + return (result.rows[0] as InvitationUserRow | undefined) ?? null +} + +/** + * Returns the invitation user for `email`, locked for the rest of the + * caller's transaction, and makes sure it has a profile row. + * + * The existing user is read `FOR NO KEY UPDATE`; a user this call inserts is + * already held by the inserting transaction. The profile insert therefore runs + * with the user lock held, in the same users -> profiles order as Google login + * (`login.ts`). The reverse order deadlocks: login holds the user row lock and + * its `INSERT INTO profiles ... ON CONFLICT (user_id)` waits on this + * transaction's uncommitted `profiles(user_id)` key, while this transaction + * waits on the user row lock that login holds. The profile insert's foreign-key + * KEY SHARE lock on the user is not the conflict: it does not conflict with + * the NO KEY UPDATE lock that login's `UPDATE users` takes. + */ export async function ensureInvitationUser( db: Pick, email: string, @@ -154,35 +194,34 @@ export async function ensureInvitationUser( `SELECT id, email, name, picture, password_hash, lifecycle_state, lifecycle_version FROM users WHERE email = $1 - LIMIT 1`, + LIMIT 1 + FOR NO KEY UPDATE`, [normalizedEmail] ) 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 + FOR NO KEY UPDATE`, + [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) @@ -190,15 +229,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/login.ts b/control-api/src/services/directory/login.ts index d38540a659..c12e025a61 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, @@ -223,26 +193,12 @@ export async function passwordLoginData(input: { email: string; password: string name: user.name, picture: user.picture, }, + credentialHash: user.password_hash, membership: await currentMemberMembership(db, user), } }) } -/** Resolve ordinary session claims in the same transaction as password recovery. */ -export async function getMemberSessionContext( - db: Pick, - user: { id: string; email: string; lifecycle_version?: number | string | null } -) { - const membership = await currentMemberMembership(db, user) - return { - userId: user.id, - email: user.email, - teamId: membership.team_id, - role: membership.role, - authGeneration: Number(user.lifecycle_version || 0), - } -} - export async function verifyUserPassword(input: { userId: string email: string diff --git a/control-api/src/services/directory/memberPassword.ts b/control-api/src/services/directory/memberPassword.ts index 9e88b6f000..f6a820fd3c 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, @@ -8,26 +9,33 @@ import { invitationResponse, invitationUserIsRetired, loadInvitationTeams, + lockInvitationUserForPasswordSetup, lockPasswordResetUser, } from './invitationStore.js' -import { getMemberSessionContext } from './login.js' 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( @@ -60,12 +68,22 @@ async function revokeSiblingPasswordResets( ) } +export type SetInvitationPasswordOptions = { + /** + * Only the external session password route is first-password-only. The admin + * finalization and the password-reset branch keep their existing overwrite + * semantics, so they never pass this option. + */ + rejectIfPasswordAlreadySet?: boolean +} + export async function setInvitationPasswordForUser( userId: string, email: string, invitationId: string, password: string, - passwordWorkLease?: MemberPasswordWorkLease + passwordWorkLease?: MemberPasswordWorkLease, + options?: SetInvitationPasswordOptions ) { const trimmedUserId = userId.trim() const normalizedEmail = email.trim().toLowerCase() @@ -92,8 +110,7 @@ export async function setInvitationPasswordForUser( passwordWorkLease ) if ('error' in result) return result - const { sessionContext: _sessionContext, ...data } = result.data - return { data } + return { data: result.data } } if (candidate.purpose !== 'member_invitation' && candidate.purpose !== 'admin_desktop_access') { return { error: 'not_found' as const } @@ -114,13 +131,30 @@ export async function setInvitationPasswordForUser( return { error: 'not_found' as const } } if (invitation.status !== 'accepted') return { error: 'not_accepted' as const } + if (options?.rejectIfPasswordAlreadySet) { + // The first-password decision must precede ensureInvitationUser, which + // 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. + const lockedUser = await lockInvitationUserForPasswordSetup(db, trimmedUserId) + if (!lockedUser) return { error: 'not_found' as const } + if (invitationUserIsRetired(lockedUser)) return { error: 'user_retired' as const } + if (lockedUser.email.toLowerCase() !== invitation.email.toLowerCase()) { + return { error: 'forbidden' as const } + } + if (lockedUser.password_hash) return { error: 'password_already_set' as const } + } const user = await ensureInvitationUser(db, invitation.email, invitation.invitee_name) 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, undefined, lifecycleVersion) if (!invitation.accepted_user_id) { await db.query(`UPDATE invitations SET accepted_user_id = $2 WHERE id = $1`, [ @@ -175,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 } } @@ -189,9 +223,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, undefined, lifecycleVersion) const redeemed = await db.query( `UPDATE invitations @@ -207,11 +243,9 @@ export async function setInvitationPasswordForEmail( const response = await loadInvitationPasswordResponse(db, invitation.id, user, passwordHash) if (!response) return { error: 'not_found' as const } - const sessionContext = await getMemberSessionContext(db, user) return { data: { passwordUpdated: true, - sessionContext, ...response, }, } @@ -264,12 +298,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, @@ -278,6 +313,18 @@ export async function updateUserPassword( ] ) if (update.rows.length !== 1) return { error: 'credential_changed' as const } + 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/src/services/directory/membership.ts b/control-api/src/services/directory/membership.ts index 22817c38c9..b1ef3e0098 100644 --- a/control-api/src/services/directory/membership.ts +++ b/control-api/src/services/directory/membership.ts @@ -2,8 +2,7 @@ import { type DbClient, pool, withTransaction } from '../../db.js' import { registerAndSendInvitation } from '../invitationFlowRegistrationService.js' import { appendControlApiPermissionEventsInTransaction } from '../tracing/controlApiPermissionEvents.js' import { - type GenericInvitationForTeamsInput, - type GenericInvitationPurpose, + type InvitationForTeamsInput, type InvitationPurpose, type InvitationRow, type InvitationTeamAssignment, @@ -19,7 +18,6 @@ import { insertInvitationForTeams, invitationForTeamsResponse, invitationResponse, - invitationUserIsRetired, loadInvitationTeams, normalizeInvitationPurpose, normalizeInvitationTeamAssignments, @@ -33,17 +31,20 @@ import { } from './types.js' import { DesktopUserRetirementError, retireDesktopUser } from './users.js' -export { INVITATION_TTL_HOURS } from './invitationStore.js' -export type { InvitationPurpose, InvitationTeamAssignment } from './invitationStore.js' export { - setInvitationPasswordForUser, setInvitationPasswordForEmail, + setInvitationPasswordForUser, updateUserPassword, } from './memberPassword.js' export { withPasswordWorkLease as withMemberPasswordWorkLease } from '../auth/passwordWorkOwnership.js' export type { MemberPasswordWorkLease } from '../auth/passwordWorkOwnership.js' +export { requestProfilePasswordReset } from './passwordResetInvitations.js' + +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 export function startDraftInvitationCleanup(intervalMs = DRAFT_INVITATION_CLEANUP_INTERVAL_MS) { @@ -63,7 +64,8 @@ export function stopDraftInvitationCleanup() { async function loadInvitationTeamsByInvitationIds( db: Pick, - invitationIds: readonly string[] + invitationIds: readonly string[], + managerUserId?: string ): Promise> { const byInvitationId = new Map() if (invitationIds.length === 0) return byInvitationId @@ -72,9 +74,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) || [] @@ -88,19 +96,38 @@ async function loadInvitationTeamsByInvitationIds( return byInvitationId } -async function invitationListResponse(rows: InvitationRow[]) { +async function invitationListResponse( + rows: Array, + managerUserId?: string +) { const teamsByInvitationId = await loadInvitationTeamsByInvitationIds( pool, - rows.map(row => row.id) + rows.map(row => row.id), + managerUserId ) - 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 + const managerSafeInvitation = managerUserId + ? (() => { + const { + team_id: _hiddenPrimaryTeamId, + team_name: _hiddenPrimaryTeamName, + role: _hiddenPrimaryRole, + assignment_count: _internalAssignmentCount, + ...safeManagerFields + } = safeInvitation + return safeManagerFields + })() + : safeInvitation + return { + ...managerSafeInvitation, + teams: (teamsByInvitationId.get(row.id) || []).map(team => ({ + id: team.team_id, + name: team.team_name, + role: team.role, + })), + } + }) } async function applyInvitationMemberships( @@ -123,6 +150,70 @@ async function applyInvitationMemberships( return teams } +/** + * The single-use transition of a pending invitation. Its predicates are the + * last guard against consuming a revoked, expired or password-reset row, so + * they hold even when a caller skipped its own refusals. + */ +export 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 +} + +/** + * Applies an invitation acceptance after the caller locked the invitation row. + * + * Pending invitations take the single-use transition. An already accepted + * invitation owned by the same user is replayed without any invitation or + * membership write, so a retried acceptance cannot create a second membership + * or move the acceptance timestamp. The replay is signalled to the caller + * because a replayed acceptance must not issue a user session: only the + * transaction that consumed a pending invitation proves the capability. The + * base's one-time backfill of a legacy accepted row whose accepted_user_id is + * still null stays conditional and is also a replay, not a new authentication. + * A different user never inherits an accepted invitation. + */ +async function applyInvitationAcceptance( + db: Pick, + invitation: InvitationRow, + userId: string +): Promise<{ error: 'forbidden' } | { error: 'not_pending' } | { replay: true } | null> { + if (invitation.status === 'accepted') { + if (invitation.accepted_user_id && invitation.accepted_user_id !== userId) { + return { error: 'forbidden' as const } + } + if (!invitation.accepted_user_id) { + await db.query( + `UPDATE invitations SET accepted_user_id = $2 + WHERE id = $1 AND accepted_user_id IS NULL`, + [invitation.id, userId] + ) + } + return { replay: true as const } + } + if (!(await acceptPendingMembershipInvitation(db, invitation, userId))) { + return { error: 'not_pending' as const } + } + return 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 @@ -339,24 +430,17 @@ export async function addMemberToTeam( }) } -async function createInvitationForTeamsRecord( - input: GenericInvitationForTeamsInput, - options: { sendEmail: boolean } -) { - cleanupStaleDraftInvitations().catch(() => undefined) - const purpose = normalizeInvitationPurpose(input.purpose) - const inserted = await withTransaction(async db => { - return insertInvitationForTeams(db, { - ...input, - purpose, - status: options.sendEmail ? 'draft' : 'pending', - }) - }) - - if (!options.sendEmail) { - return invitationForTeamsResponse(inserted.invitation, inserted.teams) - } +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, @@ -384,6 +468,27 @@ async function createInvitationForTeamsRecord( ) } +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, @@ -405,7 +510,7 @@ export async function createInvitationForTeams(input: { inviteeName: string email: string teamAssignments?: readonly InvitationTeamAssignment[] - purpose?: GenericInvitationPurpose + purpose?: InvitationPurpose fallbackRole?: InviteRole }) { return createInvitationForTeamsRecord(input, { sendEmail: true }) @@ -416,7 +521,7 @@ export async function createSilentInvitationForTeams(input: { email: string teamAssignments?: readonly InvitationTeamAssignment[] fallbackRole?: InviteRole - purpose?: GenericInvitationPurpose + purpose?: InvitationPurpose }) { return createInvitationForTeamsRecord(input, { sendEmail: false }) } @@ -425,7 +530,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 @@ -541,7 +646,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 @@ -558,7 +663,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 @@ -576,7 +681,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 @@ -660,7 +765,8 @@ export async function revokePendingInvitation( return (result.rows[0] as { id: string; email: string } | undefined) || null } -async function invitationResponseForActiveRecord(invitation: InvitationRow | null) { +export async function getInvitationByToken(token: string) { + const invitation = await getInvitationRecordByToken(pool, token.trim()) if (!invitation) return null if (invitation.status === 'draft' || invitation.status === 'revoked') return null if (invitation.status === 'pending' && invitation.expires_at.getTime() <= Date.now()) { @@ -689,197 +795,135 @@ async function invitationResponseForActiveRecord(invitation: InvitationRow | nul return invitationResponse(invitation, user, teams) } -export async function getInvitationByToken(token: string) { - return invitationResponseForActiveRecord(await getInvitationRecordByToken(pool, token.trim())) +type InvitationAcceptanceLookup = + | { by: 'token_or_id'; token: string; invitationId: string } + | { by: 'id'; invitationId: string } + +type InvitationAcceptanceRefusal = 'not_found' | 'forbidden' | 'not_pending' | 'expired' + +function readInvitationForAcceptance( + db: Pick, + lookup: InvitationAcceptanceLookup, + lock: boolean +): Promise { + return lookup.by === 'id' + ? getInvitationRecordById(db, lookup.invitationId, { lock }) + : getInvitationRecordByTokenOrId(db, lookup.token, lookup.invitationId, { lock }) } -export async function acceptInvitation( - userId: string, +function invitationAcceptanceRefusal( + invitation: InvitationRow, + email: string +): InvitationAcceptanceRefusal | null { + if (invitation.email.toLowerCase() !== email.toLowerCase()) return 'forbidden' + if (invitation.purpose === 'password_reset') return 'not_pending' + if (invitation.status === 'revoked' || invitation.status === 'draft') return 'not_pending' + if (invitation.status === 'pending' && invitation.expires_at.getTime() <= Date.now()) { + return 'expired' + } + return null +} + +/** + * Accepts an invitation for `email` inside the caller's transaction. + * + * Lock order: the user row, then the invitation, then that user's + * team_members. Every transaction that writes an invitation and a user of the + * same person takes the user first (user deletion with its ON DELETE SET NULL + * on invitations.accepted_user_id, Google login, password reset, first + * password setup, managed resend and revoke), so acceptance must not hold the + * invitation row while it waits for the user. The unlocked first read only + * spares the user insert on an obviously refused call: every refusal is + * decided again on the locked invitation row, and the lifecycle decision, the + * response and session issuance use the locked user row. + */ +async function acceptInvitationInTransaction( + db: Pick, email: string, - token: string, - invitationId: string + lookup: InvitationAcceptanceLookup ) { - return withTransaction(async db => { - const invitation = await getInvitationRecordByTokenOrId(db, token, invitationId) - 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') { - 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()) { - return { error: 'expired' as const } - } + const candidate = await readInvitationForAcceptance(db, lookup, false) + if (!candidate) return { error: 'not_found' as const } + const candidateRefusal = invitationAcceptanceRefusal(candidate, email) + if (candidateRefusal) return { error: candidateRefusal } + + // ensureInvitationUser locks the user before the profile insert (users -> + // profiles, the order Google login uses). This by-id read re-locks the row + // this transaction already holds and stays the authoritative lifecycle read. + const ensured = await ensureInvitationUser(db, candidate.email, candidate.invitee_name) + const locked = await db.query( + `SELECT id, email, name, picture, password_hash, lifecycle_state, lifecycle_version FROM users WHERE id = $1 FOR NO KEY UPDATE`, + [ensured.id] + ) + const user = locked.rows[0] as InvitationUserRow | undefined + if (!user) return { error: 'not_found' as const } + // A retirement that committed while this transaction waited on the lock wins + // before any invitation or membership write. + if (user.lifecycle_state !== 'active') return { error: 'user_retired' 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 invitation = await readInvitationForAcceptance(db, lookup, true) + if (!invitation) return { error: 'not_found' as const } + const refusal = invitationAcceptanceRefusal(invitation, email) + if (refusal) return { error: refusal } - if (invitation.status === 'pending') { - await applyInvitationMemberships(db, invitation, user.id) + const acceptance = await applyInvitationAcceptance(db, invitation, user.id) + if (acceptance && 'error' in acceptance) return acceptance + const replay = acceptance?.replay === true - 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, - ]) - } + 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) - 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, + ...(replay ? { replay: true as const } : {}), + }, + // Session issuance reuses this locked row instead of locking it again. + lockedUser: { + lifecycleState: user.lifecycle_state, + lifecycleVersion: user.lifecycle_version, + }, + } +} - return { - data: { - accepted: true as const, - ...payload, - }, - } - }) +export async function acceptInvitationForEmailInTransaction( + db: Pick, + email: string, + token: string, + invitationId = '' +) { + return acceptInvitationInTransaction(db, email, { by: 'token_or_id', token, invitationId }) } export async function acceptInvitationForEmail(email: string, token: string, invitationId = '') { - return withTransaction(async db => { - const invitation = await getInvitationRecordByTokenOrId(db, token, invitationId) - 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 (invitation.status === 'pending') { - 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, - ]) - } - - 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, - }, - } - }) + return withTransaction(db => + acceptInvitationForEmailInTransaction(db, email, token, invitationId) + ) } 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.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 (invitation.status === 'pending') { - 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, - ]) - } + return withTransaction(db => acceptInvitationInTransaction(db, email, { by: 'id', invitationId })) +} - 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) +export const DIRECTORY_SEARCH_MAX_QUERY_LENGTH = 100 - return { - data: { - accepted: true as const, - ...payload, - userId: user.id, - }, - } - }) +function escapeLikeLiteral(value: string): string { + return value.replace(/[\\%_]/g, character => `\\${character}`) } export async function searchDirectory(teamId: string, q: string) { const query = q.trim() - if (!query) return [] + if (!query) return { items: [] } + 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 FROM team_members tm JOIN users u ON u.id = tm.user_id LEFT JOIN profiles p ON p.user_id = u.id @@ -887,15 +931,16 @@ 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 '\\' ) - ORDER BY COALESCE(p.display_name, u.name, u.email) ASC + ORDER BY LOWER(COALESCE(NULLIF(p.display_name, ''), NULLIF(u.name, ''), u.email)) ASC, + u.id ASC LIMIT 25`, - [teamId, `%${query}%`] + [teamId, pattern] ) - return result.rows + return { items: result.rows } } export async function listManageableTeamsForUser(userId: string) { @@ -970,7 +1015,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' @@ -1027,9 +1072,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`, @@ -1065,19 +1110,34 @@ export async function listManagedMembersForUser(userId: string, targetUserId?: s })) } +async function lockManagerUser(db: Pick, managerUserId: string): Promise { + await db.query(`SELECT id FROM users WHERE id = $1 FOR NO KEY UPDATE`, [managerUserId.trim()]) +} + 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( + 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. A caller that already locked + // the manager re-issues a no-op lock here. + await lockManagerUser(db, managerUserId) + } + 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 tm.user_id = $1 AND tm.team_id = ANY($2::uuid[]) AND tm.status = 'active' - AND u.lifecycle_state = 'active'`, + AND u.lifecycle_state = 'active' + ORDER BY tm.team_id + ${lock ? 'FOR UPDATE OF tm' : ''}`, [managerUserId.trim(), teamIds] ) return new Map( @@ -1095,13 +1155,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)) } @@ -1118,8 +1182,11 @@ 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, + (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 @@ -1131,7 +1198,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 => @@ -1139,11 +1215,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, @@ -1166,65 +1243,33 @@ 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)) { - 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 inserted = await withTransaction(async db => { + 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({ + return insertInvitationForTeams(db, { inviteeName: normalizedInviteeName || fallbackName, email: normalizedEmail, purpose: 'member_invitation', teamAssignments: assignments, fallbackRole: 'member', - }), + status: 'draft', + }) + }) + if ('error' in inserted) return { error: inserted.error } + const delivered = await deliverAndActivateInvitation(inserted, 'member_invitation') + return { + invitation: externalManagedInvitationResponse(delivered), } } @@ -1234,24 +1279,82 @@ export async function updateManagedMemberRoleForUser( teamId: string, role: TeamRole ) { - if (managerUserId.trim() === targetUserId.trim()) { - return { error: 'invalid_target' as const } - } const normalizedRole = normalizeTeamRoleInput(role) 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), - } + // The lock map is keyed by the database's lower-case UUID text, so every id + // is normalised once and the same value is used for lookups and events. + const managerId = managerUserId.trim().toLowerCase() + const targetId = targetUserId.trim().toLowerCase() + const normalizedTeamId = teamId.trim().toLowerCase() + return withTransaction(async db => { + // A retired user keeps team_members.status = 'active'; as in dev's + // findMemberRole, only users whose lifecycle is active hold a role here. + // Global lock order for team_members rows: (team_id, user_id), shared + // with deleteManagedMemberForUser and the deleteManagedUserForUser + // authorization; one team, so ORDER BY user_id follows it. + const locked = await db.query( + `SELECT tm.user_id::text AS user_id, tm.role + FROM team_members tm + JOIN users u ON u.id = tm.user_id + WHERE tm.team_id = $1 + AND tm.user_id = ANY($2::uuid[]) + AND tm.status = 'active' + AND u.lifecycle_state = 'active' + ORDER BY tm.user_id + FOR UPDATE OF tm`, + [normalizedTeamId, [managerId, targetId]] + ) + const memberships = new Map( + (locked.rows as Array<{ user_id: string; role: TeamRole }>).map(row => [ + row.user_id, + row.role, + ]) + ) + if (!roleCanDeleteMembers(memberships.get(managerId))) { + return { error: 'forbidden' as const } + } + const previousRole = memberships.get(targetId) + 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`, + [normalizedTeamId, targetId, normalizedRole] + ) + const membership = result.rows[0] + if (!membership) return { error: 'not_found' as const } + if (previousRole !== normalizedRole) { + await appendControlApiPermissionEventsInTransaction(db, { + operatorSub: managerId, + changes: [ + { + action: 'revoke', + resourceClass: 'team_membership', + resourceRef: `team_membership:${normalizedTeamId}:role:${previousRole}`, + subject: { kind: 'user', id: targetId }, + teamId: normalizedTeamId, + status: 'role_replaced', + }, + { + action: 'grant', + resourceClass: 'team_membership', + resourceRef: `team_membership:${normalizedTeamId}:role:${normalizedRole}`, + subject: { kind: 'user', id: targetId }, + teamId: normalizedTeamId, + status: 'role_assigned', + }, + ], + }) + } + return { membership } + }) } export async function deleteManagedMemberForUser( @@ -1259,18 +1362,64 @@ export async function deleteManagedMemberForUser( targetUserId: string, teamId: string ) { - 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 } + // The lock map is keyed by the database's lower-case UUID text, so every id + // is normalised once and the same value is used for lookups and events. + const managerId = managerUserId.trim().toLowerCase() + const targetId = targetUserId.trim().toLowerCase() + const normalizedTeamId = teamId.trim().toLowerCase() + return withTransaction(async db => { + // Global lock order for team_members rows: (team_id, user_id), shared + // with updateManagedMemberRoleForUser and the deleteManagedUserForUser + // authorization; one team, so ORDER BY user_id follows it. + 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`, + [normalizedTeamId, [managerId, targetId]] + ) + const memberships = new Map( + (locked.rows as Array<{ user_id: string; role: TeamRole }>).map(row => [ + row.user_id, + row.role, + ]) + ) + if (!roleCanDeleteMembers(memberships.get(managerId))) { + return { error: 'forbidden' as const } + } + const targetRole = memberships.get(targetId) + 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`, + [normalizedTeamId, targetId] + ) + const deleted = result.rows[0] + if (!deleted) return { error: 'not_found' as const } + await appendControlApiPermissionEventsInTransaction(db, { + operatorSub: managerId, + changes: [ + { + action: 'revoke', + resourceClass: 'team_membership', + resourceRef: `team_membership:${normalizedTeamId}:role:${targetRole}`, + subject: { kind: 'user', id: targetId }, + teamId: normalizedTeamId, + status: 'membership_removed', + }, + ], + }) + return { deleted } + }) } export async function deleteManagedUserForUser( @@ -1283,38 +1432,94 @@ export async function deleteManagedUserForUser( if (!normalizedManagerUserId || !normalizedTargetUserId) { return { error: 'not_found' as const } } - if (normalizedManagerUserId === normalizedTargetUserId) { + // UUIDs compare case-insensitively in the database, so an upper-case copy of + // the caller's own id is still the caller. + if (normalizedManagerUserId.toLowerCase() === normalizedTargetUserId.toLowerCase()) { 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 => { + // 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 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] + ) + const outcome = await retireDesktopUser( + { kind: 'platform_user', desktopUserId: normalizedManagerUserId }, + normalizedTargetUserId, + retirement.reason, + retirement.idempotencyKey, + retirement.requestId, + { + db, + authorize: async retirementDb => { + // Global lock order for team_members rows: (team_id, user_id). + // The member writers lock one team's rows ORDER BY user_id, so + // the manager's and the target's rows across every team of the + // target are locked in one statement in that same order. Two + // statements (target rows, then manager rows) can hold a target + // row while waiting for a manager row that a member writer holds + // while it waits for that target row: a deadlock. The lifecycle + // filter keeps the manager filter dev applied; retireDesktopUser + // has already refused a target whose lifecycle is not active. + // The ids are compared with the database's lower-case UUID text. + const managerId = normalizedManagerUserId.toLowerCase() + const targetId = normalizedTargetUserId.toLowerCase() + const locked = await retirementDb.query( + `SELECT tm.team_id::text AS team_id, tm.user_id::text AS user_id, tm.role + FROM team_members tm + JOIN users u ON u.id = tm.user_id + WHERE tm.status = 'active' + AND u.lifecycle_state = 'active' + AND tm.user_id = ANY($1::uuid[]) + AND tm.team_id IN ( + SELECT team_id + FROM team_members + WHERE user_id = $2 + AND status = 'active' + ) + ORDER BY tm.team_id, tm.user_id + FOR UPDATE OF tm`, + [[managerId, targetId], targetId] + ) + const rows = locked.rows as Array<{ team_id: string; user_id: string; role: TeamRole }> + const teamIds = rows.filter(row => row.user_id === targetId).map(row => row.team_id) + if (teamIds.length === 0) { + throw new ManagedRetirementAuthorizationError('not_found') + } + const managerRoles = new Map( + rows.filter(row => row.user_id === managerId).map(row => [row.team_id, row.role]) + ) + 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 } @@ -1326,8 +1531,12 @@ 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, + const authorized = await withTransaction(async db => { + // Lock order: users before invitations. The manager may be the invitee, + // and an acceptance holds that user while it waits for the invitation. + await lockManagerUser(db, managerUserId) + 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 @@ -1336,15 +1545,21 @@ 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 } - } + 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 } + } + + return { invitation, teams } + }) + if ('error' in authorized) return authorized + const { invitation, teams } = authorized await registerAndSendInvitation( invitation.email, @@ -1361,8 +1576,11 @@ export async function resendManagedInvitationForUser(managerUserId: string, invi } 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, + return withTransaction(async db => { + // Lock order: users before invitations, as in resendManagedInvitationForUser. + await lockManagerUser(db, managerUserId) + 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 @@ -1371,18 +1589,21 @@ 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 { requestProfilePasswordReset } from './passwordResetInvitations.js' diff --git a/control-api/src/services/directory/users.ts b/control-api/src/services/directory/users.ts index 860bb03394..496a5bf62c 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( diff --git a/control-api/src/services/modelsDevCatalogUrl.ts b/control-api/src/services/modelsDevCatalogUrl.ts index a7039a2ad7..006736dd79 100644 --- a/control-api/src/services/modelsDevCatalogUrl.ts +++ b/control-api/src/services/modelsDevCatalogUrl.ts @@ -109,11 +109,16 @@ export function assertSafeCatalogUrl(rawUrl: string): void { try { parsed = new URL(rawUrl) } catch { - throw new Error(`models.dev catalog URL is not a valid URL: ${rawUrl}`) + // The raw value may carry credentials, so it is never echoed. + throw new Error('models.dev catalog URL is not a valid URL') } if (parsed.protocol !== 'https:') { throw new Error(`models.dev catalog URL must be https:// (got "${parsed.protocol}//")`) } + // fetch refuses a URL with userinfo and its error would log the whole URL. + if (parsed.username || parsed.password) { + throw new Error('models.dev catalog URL must not contain credentials') + } // `URL.hostname` keeps IPv6 literals wrapped in brackets ("[::1]"); strip them // so both the bracketed form and net.isIP() see the bare address. const host = parsed.hostname.toLowerCase().replace(/^\[|\]$/g, '') 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/src/services/workflows/workflowRunReadService.ts b/control-api/src/services/workflows/workflowRunReadService.ts index ab66925e7b..89f4851ddb 100644 --- a/control-api/src/services/workflows/workflowRunReadService.ts +++ b/control-api/src/services/workflows/workflowRunReadService.ts @@ -35,7 +35,34 @@ 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 { @@ -57,16 +84,14 @@ 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) + 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,26 +101,19 @@ 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) + return !userId && matchesTeam } if (actorType === 'scheduled' || actorType === 'autonomous') { - return teamId === scope.teamId || usageTeamId === scope.teamId + return matchesTeam } return false } -export function canCallerReadWorkflowRun( - caller: WorkflowRouteCaller, - row: WorkflowRunRow -): boolean { - const scope = userRunScope(caller) - if (!scope) return true - return runMatchesUserScope(row, scope) -} - export async function canCallerReadWorkflowRunWithApprovalTarget( caller: WorkflowRouteCaller, row: WorkflowRunRow, @@ -142,7 +160,15 @@ export async function canCallerReadWorkflowRunWithApprovalTarget( const scope = userRunScope(caller) if (!scope) return true - if (runMatchesUserScope(row, scope)) 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)) + ) { + return true + } if (!row.approval_request_id) return false const result = await pool.query( @@ -152,16 +178,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 } @@ -309,6 +347,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 @@ -318,11 +357,26 @@ export async function listAuditRunsByRecipe( ? `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[]) 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 +384,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 +547,34 @@ 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[]) 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..e0b6fa3c2e 100644 --- a/control-api/src/services/workflows/workflowTriggerService.ts +++ b/control-api/src/services/workflows/workflowTriggerService.ts @@ -81,13 +81,25 @@ function approvalConsumeResponse(err: ApprovalConsumeError): Record ): string { + 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}` } @@ -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' diff --git a/control-api/src/utils/auth/externalSessionAuthToken.ts b/control-api/src/utils/auth/externalSessionAuthToken.ts index bc0f87b269..2cf9e171e6 100644 --- a/control-api/src/utils/auth/externalSessionAuthToken.ts +++ b/control-api/src/utils/auth/externalSessionAuthToken.ts @@ -1,7 +1,8 @@ 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' const ALLOWED_ROLES = new Set(TEAM_ROLES) const sessionJwtPublicKey = createPublicKey(config.sessionJwtPrivateKey).export({ @@ -32,13 +33,35 @@ export function signExternalSessionToken( expiresIn: ttlSeconds, issuer: config.jwtIssuer, audience: config.jwtAudience, + jwtid: randomUUID(), } ) } +const BASE64URL_SEGMENT_PATTERN = /^[A-Za-z0-9_-]+$/ +const JWS_SEGMENT_COUNT = 3 + +/** + * V1 revocation is keyed by the SHA-256 of the exact token string, so an + * alternate base64url encoding of the same JWS segments would carry a + * different hash and escape its revocation record. A canonical segment is + * unpadded base64url over the base64url alphabet and re-encodes to exactly + * the presented text; verification accepts nothing else. + */ +function isCanonicalBase64UrlSegment(segment: string): boolean { + if (!BASE64URL_SEGMENT_PATTERN.test(segment)) return false + return Buffer.from(segment, 'base64url').toString('base64url') === segment +} + +function hasCanonicalJwsRepresentation(token: string): boolean { + const segments = token.split('.') + return segments.length === JWS_SEGMENT_COUNT && segments.every(isCanonicalBase64UrlSegment) +} + export function verifyExternalSessionToken(token: string): AuthClaims | null { try { if (token.length > 4096) return null + if (!hasCanonicalJwsRepresentation(token)) return null const payload = jwt.verify(token, sessionJwtPublicKey, { algorithms: ['RS256'], issuer: config.jwtIssuer, @@ -53,8 +76,7 @@ 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 + legacyExternalSessionAuthGeneration({ authGeneration }) === null ) { return null } @@ -69,6 +91,8 @@ 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 } : {}), + ...(typeof payload.jti === 'string' ? { jti: payload.jti } : {}), } } catch { return null diff --git a/control-api/src/utils/auth/googleAuth.ts b/control-api/src/utils/auth/googleAuth.ts index d13dbe462d..363239e023 100644 --- a/control-api/src/utils/auth/googleAuth.ts +++ b/control-api/src/utils/auth/googleAuth.ts @@ -1,26 +1,68 @@ -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 +} + +/** + * A Google identity that this verifier itself refuses to authenticate after + * the ID token verified. Callers answer it as a rejected credential (401). + */ +export class GoogleIdentityRejectedError extends Error { + constructor(message: string) { + super(message) + this.name = 'GoogleIdentityRejectedError' + } +} + +/** + * 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 payload = ticket.getPayload(); + const ticket = await verifyWithLibrary(idToken) + const payload = ticket.getPayload() if (!payload?.email) { - throw new Error("Google token has no email"); + throw new GoogleIdentityRejectedError('Google token has no email') + } + if (payload.email_verified !== true) { + throw new GoogleIdentityRejectedError('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/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/adminDesktopReplayPassword.realPostgres.integration.test.ts b/control-api/test/adminDesktopReplayPassword.realPostgres.integration.test.ts new file mode 100644 index 0000000000..b4e04e1d11 --- /dev/null +++ b/control-api/test/adminDesktopReplayPassword.realPostgres.integration.test.ts @@ -0,0 +1,279 @@ +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 { createAdminAuthRouter } from '../src/routes/admin/auth.js' +import { createControlAdminInvitation } from '../src/services/adminAuthService.js' +import { + acceptInvitationById, + createSilentInvitationForTeams, + setInvitationPasswordForUser, +} 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 registration = vi.hoisted(() => ({ + validateControlAdminInvitationToken: vi.fn(), +})) + +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), + } +}) + +vi.mock('../src/services/controlAdminInvitationRegistrationService.js', async importOriginal => { + const real = + await importOriginal< + typeof import('../src/services/controlAdminInvitationRegistrationService.js') + >() + return { + ...real, + validateControlAdminInvitationToken: registration.validateControlAdminInvitationToken, + } +}) + +const adminUrl = process.env.CONTROL_API_REAL_PG_ADMIN_URL +const describeRealPostgres = adminUrl ? describe : describe.skip + +type Deferred = { promise: Promise; resolve: () => void } + +function deferred(): Deferred { + let resolve!: () => void + const promise = new Promise(settle => { + resolve = settle + }) + return { promise, resolve } +} + +type InvitationPair = { + email: string + adminInvitationId: string + desktopInvitationId: string +} + +type StoredUser = { id: string; password_hash: string | null } + +function app(): express.Express { + const a = express() + a.set('trust proxy', 1) + a.use(express.json()) + a.use(createAdminAuthRouter()) + 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('control admin completion desktop password on real PostgreSQL', () => { + const database = `control_api_admin_desktop_replay_${randomBytes(6).toString('hex')}` + let adminPool: Pool + + async function seedAdmin(label: string): Promise { + const id = randomUUID() + const username = `${label}-${id.slice(0, 8)}` + const email = `${username}@example.test` + await holder.pool.query( + `INSERT INTO control_admin_users (id, username, email, password_hash, role, status, session_version) + VALUES ($1, $2, $3, 'real-pg-inviter-hash', 'admin', 'active', 1)`, + [id, username, email] + ) + return id + } + + async function createPair(label: string): Promise { + const email = `${label}-${randomUUID()}@example.test` + const inviterId = await seedAdmin(`${label}-inviter`) + const adminInvitation = await createControlAdminInvitation(email, inviterId) + if ('error' in adminInvitation) throw new Error(adminInvitation.error) + const desktopInvitation = await createSilentInvitationForTeams({ + inviteeName: email, + email, + teamAssignments: [], + fallbackRole: 'member', + purpose: 'admin_desktop_access', + }) + return { + email, + adminInvitationId: adminInvitation.id, + desktopInvitationId: desktopInvitation.id, + } + } + + async function storedUser(email: string): Promise { + const result = await holder.pool.query( + `SELECT id, password_hash FROM users WHERE email = $1`, + [email] + ) + const user = result.rows[0] + if (!user) throw new Error(`user ${email} was not created`) + return user + } + + function completeRequest(pair: InvitationPair, body: Record) { + return request(app()) + .post('/admin/auth/control-admin-invitations/complete') + .send({ + token: 'synthetic-control-admin-flow-token', + email: pair.email, + username: `invitee-${randomUUID().slice(0, 8)}`, + ...body, + }) + } + + 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) + }, 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('sets the Desktop password on the first acceptance', async () => { + const pair = await createPair('first-accept') + const adminPassword = 'Admin-Completion-1!' + const memberPassword = 'Desktop-Completion-1!' + registration.validateControlAdminInvitationToken.mockResolvedValueOnce({ + email: pair.email, + invitationUuid: pair.adminInvitationId, + }) + + const response = await completeRequest(pair, { + password: adminPassword, + useSameMemberPassword: false, + memberPassword, + }).expect(200) + + expect(response.body).toEqual({ + completed: true, + desktopAccessCompleted: true, + login: { username: pair.email }, + }) + + const user = await storedUser(pair.email) + const hash = user.password_hash + if (!hash) throw new Error('first acceptance did not store a Desktop password') + await expect(bcrypt.compare(memberPassword, hash)).resolves.toBe(true) + await expect(bcrypt.compare(adminPassword, hash)).resolves.toBe(false) + const desktop = await holder.pool.query<{ status: string; accepted_user_id: string | null }>( + `SELECT status, accepted_user_id FROM invitations WHERE id = $1`, + [pair.desktopInvitationId] + ) + expect(desktop.rows[0]).toEqual({ status: 'accepted', accepted_user_id: user.id }) + }) + + it('does not overwrite a concurrently established password on a Desktop replay', async () => { + const pair = await createPair('replay') + const adminPassword = 'Admin-Completion-2!' + const memberPassword = 'Desktop-Completion-2!' + const concurrentPassword = 'Concurrent-Device-2!' + + const validationStarted = deferred() + const validationGate = deferred() + registration.validateControlAdminInvitationToken.mockImplementationOnce(async () => { + // The route has already read the pending Desktop invitation and now + // waits in the external validator. This is the exact window in which + // another device can accept it and set its own password. + validationStarted.resolve() + await validationGate.promise + return { email: pair.email, invitationUuid: pair.adminInvitationId } + }) + + // A supertest Test starts when it is awaited or chained; start it before + // waiting for the route to reach the validator gate. + const responsePromise = completeRequest(pair, { + password: adminPassword, + useSameMemberPassword: false, + memberPassword, + }).then(response => response) + await validationStarted.promise + + const concurrent = await acceptInvitationById(pair.email, pair.desktopInvitationId) + if ('error' in concurrent) throw new Error(concurrent.error) + const userId = concurrent.data.userId + await expect( + setInvitationPasswordForUser(userId, pair.email, pair.desktopInvitationId, concurrentPassword) + ).resolves.toMatchObject({ data: { passwordUpdated: true } }) + + const before = await storedUser(pair.email) + const beforeHash = before.password_hash + if (!beforeHash) throw new Error('concurrent acceptance did not store a password') + await expect(bcrypt.compare(concurrentPassword, beforeHash)).resolves.toBe(true) + + validationGate.resolve() + const response = await responsePromise + + expect(response.status).toBe(200) + expect(response.body).toEqual({ + completed: true, + desktopAccessCompleted: false, + desktopAccessReason: 'desktop_invitation_already_accepted', + login: { username: pair.email }, + }) + + // The control-admin completion still committed its own invitation. + const admin = await holder.pool.query<{ email: string }>( + `SELECT email FROM control_admin_users WHERE lower(email) = lower($1)`, + [pair.email] + ) + expect(admin.rows).toHaveLength(1) + const acceptedAdminInvitation = await holder.pool.query<{ status: string }>( + `SELECT status FROM control_admin_invitations WHERE id = $1`, + [pair.adminInvitationId] + ) + expect(acceptedAdminInvitation.rows[0]?.status).toBe('accepted') + + // The replay wrote no password: the concurrently established hash is + // byte-identical and still verifies the concurrent password, not either + // password from this completion. + const after = await storedUser(pair.email) + expect(after.id).toBe(userId) + expect(after.password_hash).toBe(beforeHash) + await expect(bcrypt.compare(concurrentPassword, after.password_hash!)).resolves.toBe(true) + await expect(bcrypt.compare(memberPassword, after.password_hash!)).resolves.toBe(false) + await expect(bcrypt.compare(adminPassword, after.password_hash!)).resolves.toBe(false) + + const desktop = await holder.pool.query<{ status: string; accepted_user_id: string | null }>( + `SELECT status, accepted_user_id FROM invitations WHERE id = $1`, + [pair.desktopInvitationId] + ) + expect(desktop.rows[0]).toEqual({ status: 'accepted', accepted_user_id: userId }) + }) +}) diff --git a/control-api/test/app.test.ts b/control-api/test/app.test.ts index 22fb8d2f93..255bc86404 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,17 @@ function signHccInternalControl(): string { } describe('app router wiring', () => { + beforeEach(() => { + rateLimiter.checkAndIncrement.mockResolvedValue({ + allowed: true, + backendAvailable: true, + remaining: 9, + resetMs: Date.now() + 60_000, + windowStartMs: Date.now(), + count: 1, + }) + }) + it('requires the real admin session guard for subscription capability discovery', async () => { const app = createApp(new MockGateway('mcp-server') as never) await request(app).get('/api/v1/admin/llm/providers/capabilities').expect(401) @@ -178,7 +193,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') @@ -187,18 +204,100 @@ 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_state: 'active', lifecycle_version: 1 }], + rowCount: 1, + } + } + throw new Error(`unexpected session exchange query: ${sql}`) }) + vi.spyOn(pool, 'connect').mockResolvedValue({ + query: transactionQuery, + on: vi.fn(), + removeListener: vi.fn(), + 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(response => { + 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.stringMatching(/SELECT id FROM users[\s\S]*FOR NO KEY UPDATE/), + expect.stringContaining('external_user_session_security_epochs'), + expect.stringMatching(/u\.lifecycle_state, u\.lifecycle_version[\s\S]*JOIN team_members/), + expect.stringContaining('clock_timestamp()'), + expect.stringContaining('SELECT valid_after'), + 'COMMIT', + ]) }) it('enforces UI auth for control-ui routes', async () => { diff --git a/control-api/test/config.modelsDevApiUrl.test.ts b/control-api/test/config.modelsDevApiUrl.test.ts index c0e7f4b5cc..dfeea395b5 100644 --- a/control-api/test/config.modelsDevApiUrl.test.ts +++ b/control-api/test/config.modelsDevApiUrl.test.ts @@ -37,6 +37,26 @@ describe('MODELS_DEV_API_URL at boot', () => { await expect(bootWith(url)).rejects.toThrow(message) }) + it.each([ + 'https://user:s3cret@stub.example.com/api.json', + 'https://s3cret@stub.example.com/api.json', + 'https://:s3cret@stub.example.com/api.json', + ])('refuses credentials in the URL without echoing them (%s)', async url => { + const boot = bootWith(url) + await expect(boot).rejects.toThrow('models.dev catalog URL must not contain credentials') + await expect(boot).rejects.not.toThrow('s3cret') + }) + + it('refuses a malformed URL without echoing the secret it carries', async () => { + const boot = bootWith('https://user:s3cret@ bad host/api.json') + await expect(boot).rejects.toThrow('models.dev catalog URL is not a valid URL') + await expect(boot).rejects.not.toThrow('s3cret') + }) + + it('boots with a public URL that has no credentials', async () => { + await expect(bootWith('https://stub.example.com/api.json')).resolves.toBeDefined() + }) + it('boots with the emergency brake, and the loader then falls back to the vendored snapshot', async () => { await expect(bootWith(BRAKE)).resolves.toBeDefined() const { loadModelsDevCatalog } = await import('../src/services/modelsDevClient.js') 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.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 6a6e3ae092..b74722fb36 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' import { passwordAdmissionMigrationFixtureResult } from './helpers/passwordAdmissionMigrationFixture.js' const clientQuery = vi.fn() @@ -20,21 +21,27 @@ 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, 4)).toEqual([ + "SET lock_timeout = '10000ms'", + "SET statement_timeout = '15000ms'", + "SET idle_in_transaction_session_timeout = '15000ms'", + "SET client_connection_check_interval = '1s'", + ]) + expect(sqls[4]).toContain('SELECT pg_advisory_lock') + expect(sqls[5]).toBe('BEGIN') expect(sqls).toContainEqual( expect.stringContaining('CREATE TABLE IF NOT EXISTS schema_migrations') ) @@ -60,9 +67,35 @@ 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 committed password-admission rollout fence, one dev batch through + // 0136_llm_catalog_dismissed_backfill (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) + + // The first and last dev migrations after 0106 are recorded inside the + // second transaction (the dev batch), before its COMMIT. + const receiptIndex = (version: string) => + clientQuery.mock.calls.findIndex( + ([sql, values]) => + String(sql).includes('INSERT INTO schema_migrations(version)') && + Array.isArray(values) && + values[0] === version + ) + const devBatchBegin = sqls.indexOf('BEGIN', sqls.indexOf('BEGIN') + 1) + const devBatchCommit = sqls.indexOf('COMMIT', devBatchBegin) + for (const version of [ + '0107_llm_provider_attempts_sdk_link', + '0130_password_login_success_admission', + '0136_llm_catalog_dismissed_backfill', + ]) { + const index = receiptIndex(version) + expect(index, version).toBeGreaterThan(devBatchBegin) + expect(index, version).toBeLessThan(devBatchCommit) + } + expect(receiptIndex('0137_user_access_foundation')).toBeGreaterThan(devBatchCommit) }) it('applies the triggered_at audit index migration on top of an already-migrated baseline', async () => { @@ -963,8 +996,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[4]).toContain('SELECT pg_advisory_lock') + expect(sqls[5]).toBe('BEGIN') expect(sqls.filter(sql => sql === 'COMMIT')).toHaveLength(1) expect(sqls.filter(sql => sql === 'BEGIN')).toHaveLength(2) expect(sqls).toContainEqual( diff --git a/control-api/test/db.llmAllowedModelsMigration.test.ts b/control-api/test/db.llmAllowedModelsMigration.test.ts index e95228203c..b9376ed41d 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 c54fe0b76c..da70b207b7 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.migrationsOrder.test.ts b/control-api/test/db.migrationsOrder.test.ts index d1882b84f8..d77ed9ef22 100644 --- a/control-api/test/db.migrationsOrder.test.ts +++ b/control-api/test/db.migrationsOrder.test.ts @@ -56,6 +56,32 @@ 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 incomingDev = '0129_workflow_schedules_concurrency_policy' + const passwordAdmission = '0130_password_login_success_admission' + const lastDev = '0136_llm_catalog_dismissed_backfill' + const task106 = ['0137_user_access_foundation', '0138_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[versions.indexOf(incomingDev) + 1]).toBe(passwordAdmission) + expect(versions[versions.indexOf(lastDev) + 1]).toBe(task106[0]) + expect( + 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)) + }) + 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)) @@ -219,11 +245,31 @@ describe('CONTROL_API_MIGRATIONS ordering invariant', () => { sql.includes("SET LOCAL app.password_admission_rollout_migration = 'v2'") ) + const fenceLockWait = statements.indexOf("SET LOCAL lock_timeout = '60s'") + const fenceStatementWait = statements.indexOf("SET LOCAL statement_timeout = '60s'") + const passwordReceipt = query.mock.calls.findIndex( + ([sql, values]: unknown[]) => + String(sql).includes('INSERT INTO schema_migrations(version)') && + Array.isArray(values) && + values[0] === migration.version + ) + const schemaCommit = statements.findIndex( + (sql, index) => index > schemaBegin && sql === 'COMMIT' + ) + expect(fenceIndex).toBeGreaterThan(-1) + // Dev's 60-second fence wait is not cut short by the migration session's + // ordinary statement bound. + expect(fenceLockWait).toBeGreaterThan(-1) + expect(fenceStatementWait).toBeGreaterThan(fenceLockWait) + expect(fenceIndex).toBeGreaterThan(fenceStatementWait) expect(firstCommit).toBeGreaterThan(fenceIndex) expect(drainRead).toBeGreaterThan(firstCommit) expect(schemaBegin).toBeGreaterThan(drainRead) expect(migrationGuc).toBeGreaterThan(schemaBegin) + // 0130 is applied in the dev batch, under the rollout GUC, before its COMMIT. + expect(passwordReceipt).toBeGreaterThan(migrationGuc) + expect(schemaCommit).toBeGreaterThan(passwordReceipt) expect(release).toHaveBeenCalledOnce() }) @@ -282,6 +328,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/db.oauthGrantsOwnerGeneralization.test.ts b/control-api/test/db.oauthGrantsOwnerGeneralization.test.ts index c2c02200c7..3af7926390 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 }) }) @@ -59,8 +63,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('0124_entity_change_definer_search_path')).toBeLessThan( + versions.indexOf('0137_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 () => { 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/db.pluginWorkloadSdkTraceValidatorMigration.test.ts b/control-api/test/db.pluginWorkloadSdkTraceValidatorMigration.test.ts index 873738ba21..c09a4c5640 100644 --- a/control-api/test/db.pluginWorkloadSdkTraceValidatorMigration.test.ts +++ b/control-api/test/db.pluginWorkloadSdkTraceValidatorMigration.test.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' +import { onlineIndexAwareQuery } from './helpers/onlineIndexCatalogMock.js' import { passwordAdmissionMigrationFixtureResult } from './helpers/passwordAdmissionMigrationFixture.js' const clientQuery = vi.fn() @@ -14,7 +15,10 @@ describe('Plugin Workload SDK runtime-contract reconciliation 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.realPostgresMigration.integration.test.ts b/control-api/test/db.realPostgresMigration.integration.test.ts index 199c15c0bd..6f3a330d6a 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' @@ -40,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 function databaseUrl(baseUrl: string, database: string): string { const url = new URL(baseUrl) @@ -149,6 +146,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' }, @@ -303,22 +420,389 @@ 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']) } 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 }]) + // RP-12: the session-revocation ledger is append-only for the runtime. + expectPrivileges([...privileges.external_v1_session_revocations], ['SELECT', 'INSERT']) + // The runtime never deletes an epoch: the user-deletion cascade runs as the owner. + expectPrivileges( + [...privileges.external_user_session_security_epochs], + ['SELECT', 'INSERT', 'UPDATE'] + ) + expectPrivileges( + [...privileges.external_user_sessions], + ['SELECT', 'INSERT', 'UPDATE', 'DELETE'] + ) try { await dbPool.query('GRANT UPDATE ON password_verification_work TO control_api_runtime') expect(await violations(relationQuery)).toBe(1) @@ -340,6 +824,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')}` @@ -1180,6 +1711,12 @@ 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 firstVersions = await dbPool.query<{ version: string }>( 'SELECT version FROM schema_migrations ORDER BY version' ) @@ -3216,7 +3753,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 endPoolAndWaitForClients(attackerPool) 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..762ae21f5a 100644 --- a/control-api/test/db.runtimeAccessMigrations.test.ts +++ b/control-api/test/db.runtimeAccessMigrations.test.ts @@ -1,4 +1,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + PR1_ONLINE_INDEX_PLAN, + expectedIndexDefinition, +} from '../src/migrations/pr1OnlineIndexPlan.js' const clientQuery = vi.fn() const clientRelease = vi.fn() @@ -14,7 +18,36 @@ describe('runtime access migrations', () => { vi.resetModules() vi.clearAllMocks() mockConnect.mockResolvedValue({ query: clientQuery, release: clientRelease }) - clientQuery.mockResolvedValue({ rows: [], rowCount: 0 }) + const indexStates = new Map< + string, + { + schema_name: string + table_name: string + indisunique: boolean + indisvalid: boolean + definition: string + } + >() + clientQuery.mockImplementation(async (sql: string, values?: unknown[]) => { + if (sql.includes('/* pr1-earlier-head-guard */')) return { rows: [], rowCount: 0 } + 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, { + schema_name: 'public', + table_name: entry.table, + indisunique: Boolean(entry.unique), + indisvalid: true, + definition: expectedIndexDefinition(entry, 'public'), + }) + } + } + 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.userAccessFoundationMigration.test.ts b/control-api/test/db.userAccessFoundationMigration.test.ts new file mode 100644 index 0000000000..d627714260 --- /dev/null +++ b/control-api/test/db.userAccessFoundationMigration.test.ts @@ -0,0 +1,100 @@ +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 === '0137_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') +} + +async function legacyEpochBackfillSql(): Promise { + const { CONTROL_API_MIGRATIONS } = await import('../src/db.js') + const migration = CONTROL_API_MIGRATIONS.find( + candidate => candidate.version === '0138_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 foundation 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).not.toMatch(/DROP\s+(?:TABLE|COLUMN)/i) + }) + + it('builds keyset and reverse indexes online, outside the 0137 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 === '0137_user_access_foundation' + ) + const expectedIndexes = [ + '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', + ] + 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 external_user_sessions_user_live_idx') + for (const expectedIndex of expectedIndexes) expect(sql).not.toContain(expectedIndex) + }) + + 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([ + ['external_user_session_security_epochs', 'upsert'], + ['external_user_sessions', 'legacy_dml'], + ['external_v1_session_revocations', 'append'], + ]) + + for (const [relation, profile] of expected) { + expect(entries.get(relation), relation).toBe(profile) + } + }) + + it('backfills 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) + }) +}) 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 7dc8fac564..cf403be68c 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' import { passwordAdmissionMigrationFixtureResult } from './helpers/passwordAdmissionMigrationFixture.js' const clientQuery = vi.fn() @@ -75,7 +76,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 9d329c086e..a1b0bacfff 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' import { passwordAdmissionMigrationFixtureResult } from './helpers/passwordAdmissionMigrationFixture.js' const clientQuery = vi.fn() @@ -20,7 +21,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/db.workflowScheduleConcurrencyPolicyMigration.test.ts b/control-api/test/db.workflowScheduleConcurrencyPolicyMigration.test.ts index 1cfd270f17..ecb596e3d4 100644 --- a/control-api/test/db.workflowScheduleConcurrencyPolicyMigration.test.ts +++ b/control-api/test/db.workflowScheduleConcurrencyPolicyMigration.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it, vi } from 'vitest' +import { onlineIndexAwareQuery } from './helpers/onlineIndexCatalogMock.js' import { passwordAdmissionMigrationFixtureResult } from './helpers/passwordAdmissionMigrationFixture.js' // Mock pg exactly like the other db.*.test.ts specs so importing ../src/db.js @@ -89,7 +90,9 @@ describe('0129_workflow_schedules_concurrency_policy migration', () => { const query = recordingQuery(earlier) const release = vi.fn() - await initDb({ connect: vi.fn().mockResolvedValue({ query, release }) } as never) + await initDb({ + connect: vi.fn().mockResolvedValue({ query: onlineIndexAwareQuery(query), release }), + } as never) const sqls = query.mock.calls.map(([sql]) => String(sql)) expect(sqls.filter(sql => sql.includes('concurrency_policy'))).toHaveLength(1) diff --git a/control-api/test/directoryAuthorizationPrivacy.test.ts b/control-api/test/directoryAuthorizationPrivacy.test.ts new file mode 100644 index 0000000000..c9e20b31e6 --- /dev/null +++ b/control-api/test/directoryAuthorizationPrivacy.test.ts @@ -0,0 +1,1662 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import bcrypt from 'bcryptjs' +import { readFileSync } from 'node:fs' +import type { TeamRole } from '../src/profileTypes.js' +import { + acceptInvitationById, + acceptInvitationForEmail, + createInvitationForTeams, + createManagedInvitationForUser, + deleteManagedMemberForUser, + deleteManagedUserForUser, + 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' + +// The acceptance's user lock. Its text differs from the multi-line password +// setup lock, so a match cannot hit that statement. +const ACCEPTANCE_USER_LOCK = + 'SELECT id, email, name, picture, password_hash, lifecycle_state, lifecycle_version FROM users WHERE id = $1 FOR NO KEY UPDATE' +const MANAGER_USER_LOCK = 'SELECT id FROM users WHERE id = $1 FOR NO KEY UPDATE' + +type AcceptanceInvitationFixture = { + id: string + team_id: string | null + invitee_name: string | null + email: string + role: string + token: string + status: string + purpose: string + created_at: Date + expires_at: Date + accepted_at: Date | null + accepted_user_id: string | null + team_name: string | null +} + +type AcceptanceUserFixture = { + lifecycle_state: string + lifecycle_version: number + password_hash?: string | null +} + +// A dispatcher for one acceptance: the unlocked user read of +// ensureInvitationUser and the locked user row can disagree, as when a +// lifecycle change commits while the acceptance waits on the user lock. +function acceptanceQueries(options: { + invitation: AcceptanceInvitationFixture + unlockedUser: AcceptanceUserFixture + lockedUser: AcceptanceUserFixture +}) { + const state = { + status: options.invitation.status, + membershipWrites: 0, + acceptanceWrites: 0, + } + const userRow = (fixture: AcceptanceUserFixture) => ({ + id: TARGET, + email: options.invitation.email, + name: 'Target', + picture: null, + password_hash: fixture.password_hash ?? null, + lifecycle_state: fixture.lifecycle_state, + lifecycle_version: fixture.lifecycle_version, + }) + const query = async (sql: string) => { + if (sql.includes('FROM invitations i')) { + return { + rows: [ + { + ...options.invitation, + status: state.status, + accepted_user_id: state.status === 'accepted' ? TARGET : null, + }, + ], + rowCount: 1, + } + } + if (sql.includes(ACCEPTANCE_USER_LOCK)) { + return { rows: [userRow(options.lockedUser)], rowCount: 1 } + } + if (sql.includes('FROM users') && sql.includes('WHERE email = $1')) { + return { rows: [userRow(options.unlockedUser)], rowCount: 1 } + } + if (sql.includes('INSERT INTO profiles')) return { rows: [], rowCount: 0 } + if (sql.includes("SET status = 'accepted'")) { + if (state.status !== 'pending') return { rows: [], rowCount: 0 } + state.status = 'accepted' + state.acceptanceWrites += 1 + return { rows: [{ id: options.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')) { + state.membershipWrites += 1 + return { rows: [], rowCount: 1 } + } + throw new Error(`unexpected query: ${sql.slice(0, 40)}`) + } + return { state, query } +} + +// Acceptance lock order: an unlocked invitation read, then the user lock with +// the target id, then the locked invitation read, then the membership write. +function expectUsersBeforeInvitations( + calls: ReadonlyArray>, + options: { membershipWrite: boolean } +) { + const statements = calls.map(call => String(call[0])) + const unlockedRead = statements.findIndex( + statement => statement.includes('FROM invitations i') && !statement.includes('FOR UPDATE') + ) + const userLock = statements.findIndex(statement => statement.includes(ACCEPTANCE_USER_LOCK)) + const invitationLock = statements.findIndex(statement => statement.includes('FOR UPDATE OF i')) + const membershipWrite = statements.findIndex(statement => + statement.includes('INSERT INTO team_members') + ) + expect(unlockedRead).toBe(0) + expect(userLock).toBeGreaterThan(unlockedRead) + expect(calls[userLock]?.[1]).toEqual([TARGET]) + expect(invitationLock).toBeGreaterThan(userLock) + if (options.membershipWrite) { + expect(membershipWrite).toBeGreaterThan(invitationLock) + } else { + expect(membershipWrite).toBe(-1) + } +} + +// Managed resend and revoke: the manager's user row, then the invitation row, +// then the invitation teams, then the (no-op) manager re-lock and the +// manager's team_members rows. +function expectManagerBeforeInvitation(calls: ReadonlyArray>) { + const statements = calls.map(call => String(call[0])) + expect(statements).toHaveLength(5) + expect(statements[0]).toContain(MANAGER_USER_LOCK) + expect(calls[0]?.[1]).toEqual([MANAGER]) + expect(statements[1]).toContain('FOR UPDATE OF i') + expect(statements[2]).toContain('FROM invitation_teams it') + expect(statements[3]).toContain(MANAGER_USER_LOCK) + expect(statements[4]).toContain('FOR UPDATE OF tm') +} + +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) +} + +async function queryWithPasswordWorkLease(sql: string, values?: unknown[]) { + if (sql.includes('SET LOCAL synchronous_commit = on')) { + return { rows: [], rowCount: 0 } + } + if (sql.includes("set_config('app.password_admission_protocol', 'v2', true)")) { + return { rows: [{ set_config: 'v2' }], rowCount: 1 } + } + 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), + expires_at: new Date(Date.now() + 60_000), + } +} + +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, _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 () => { + 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: 'synthetic-must-never-serialize', + status: 'pending', + purpose: 'member_invitation', + ...activeInvitationWindow(), + 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 tm.team_id, tm.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(result[0]).not.toHaveProperty('assignment_count') + expect(JSON.stringify(result)).not.toContain('synthetic-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('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('does not re-lock the caller before the membership locks', 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(String(mocks.query.mock.calls[0]?.[0])).toContain('FROM team_members') + expect(String(mocks.query.mock.calls[0]?.[0])).toContain('FOR UPDATE') + 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 () => { + mocks.query + .mockResolvedValueOnce({ rows: [{ id: MANAGER }], rowCount: 1 }) + .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) + // 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() + }) + + 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: queryWithDatabaseClock }) + } 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: 'synthetic-must-never-leave-control-api', + status: 'draft', + purpose: 'member_invitation', + ...activeInvitationWindow(), + 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('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 } + } + 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('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'")) { + 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 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('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('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: 'synthetic-invitation-token', + status: 'pending', + purpose: 'member_invitation', + ...activeInvitationWindow(), + 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(ACCEPTANCE_USER_LOCK)) { + return { + rows: [ + { + id: TARGET, + email: invitation.email, + name: 'Original user name', + picture: null, + password_hash: null, + lifecycle_state: 'active', + lifecycle_version: 1, + }, + ], + 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 } }) + // Lock order: the user row before the invitation row and the membership + // write; the caller issues from that locked row. + expect(result).toMatchObject({ lockedUser: { lifecycleState: 'active', lifecycleVersion: 1 } }) + expectUsersBeforeInvitations(mocks.query.mock.calls, { membershipWrite: true }) + 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('replays a general invitation acceptance for the same user 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: 'synthetic-single-use-token', + status: 'pending', + purpose: 'member_invitation', + ...activeInvitationWindow(), + accepted_at: null, + accepted_user_id: null, + team_name: 'Team A', + } + let status = invitation.status + let membershipWrites = 0 + let acceptanceWrites = 0 + mocks.query.mockImplementation(async (sql: string) => { + if (sql.includes("$2 = '' AND i.token = $1")) { + return { + rows: [ + { + ...invitation, + status, + accepted_user_id: status === 'accepted' ? TARGET : null, + }, + ], + rowCount: 1, + } + } + if (sql.includes(ACCEPTANCE_USER_LOCK)) { + return { + rows: [ + { + id: TARGET, + email: invitation.email, + name: 'Target', + picture: null, + password_hash: null, + lifecycle_state: 'active', + lifecycle_version: 1, + }, + ], + 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('UPDATE profiles')) return { rows: [], rowCount: 1 } + if (sql.includes("SET status = 'accepted'")) { + if (status !== 'pending') return { rows: [], rowCount: 0 } + status = 'accepted' + acceptanceWrites += 1 + 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)}`) + }) + + const first = await acceptInvitationForEmail(invitation.email, invitation.token) + expect(first).toMatchObject({ data: { accepted: true, userId: TARGET } }) + mocks.query.mockClear() + const replay = await acceptInvitationForEmail(invitation.email, invitation.token) + expect(replay).toMatchObject({ + data: { accepted: true, userId: TARGET, replay: true }, + }) + // The replay takes the user lock before the accepted invitation row too. + expectUsersBeforeInvitations(mocks.query.mock.calls, { membershipWrite: false }) + // The replay repeats the retained payload and only adds the replay signal. + if (!('data' in first) || !('data' in replay)) { + throw new Error('acceptance replay did not return data') + } + expect(replay.data).toEqual({ ...first.data, replay: true }) + expect(membershipWrites).toBe(1) + expect(acceptanceWrites).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('locks the user before the invitation when accepting by id', async () => { + const invitation = { + id: '00000000-0000-4000-8000-000000000201', + team_id: TEAM_A, + invitee_name: 'Target', + email: 'target@example.com', + role: 'member', + token: 'synthetic-desktop-access-token', + status: 'pending', + purpose: 'admin_desktop_access', + ...activeInvitationWindow(), + accepted_at: null, + accepted_user_id: null, + team_name: 'Team A', + } + const dispatcher = acceptanceQueries({ + invitation, + unlockedUser: { lifecycle_state: 'active', lifecycle_version: 1 }, + lockedUser: { lifecycle_state: 'active', lifecycle_version: 1 }, + }) + mocks.query.mockImplementation(dispatcher.query) + + await expect(acceptInvitationById(invitation.email, invitation.id)).resolves.toMatchObject({ + data: { accepted: true, userId: TARGET, status: 'accepted' }, + }) + expectUsersBeforeInvitations(mocks.query.mock.calls, { membershipWrite: true }) + const statements = mocks.query.mock.calls.map(call => String(call[0])) + expect(statements[0]).toContain('i.id::text = $1') + expect(dispatcher.state.acceptanceWrites).toBe(1) + expect(dispatcher.state.membershipWrites).toBe(1) + }) + + it.each([ + ['token', () => acceptInvitationForEmail('target@example.com', 'synthetic-retired-token')], + [ + 'id', + () => acceptInvitationById('target@example.com', '00000000-0000-4000-8000-000000000202'), + ], + ] as const)( + 'refuses acceptance by %s when the locked user row is retired', + async (_lookup, accept) => { + const invitation = { + id: '00000000-0000-4000-8000-000000000202', + team_id: TEAM_A, + invitee_name: 'Target', + email: 'target@example.com', + role: 'member', + token: 'synthetic-retired-token', + status: 'pending', + purpose: 'member_invitation', + ...activeInvitationWindow(), + accepted_at: null, + accepted_user_id: null, + team_name: 'Team A', + } + // The unlocked read predates a retirement that committed while the + // acceptance waited on the user lock. + const dispatcher = acceptanceQueries({ + invitation, + unlockedUser: { lifecycle_state: 'active', lifecycle_version: 1 }, + lockedUser: { lifecycle_state: 'retired', lifecycle_version: 2 }, + }) + mocks.query.mockImplementation(dispatcher.query) + + await expect(accept()).resolves.toEqual({ error: 'user_retired' }) + const statements = mocks.query.mock.calls.map(call => String(call[0])) + // Witness: the decision followed the user lock taken for the target. + const userLock = statements.findIndex(statement => statement.includes(ACCEPTANCE_USER_LOCK)) + expect(userLock).toBeGreaterThan(0) + expect(mocks.query.mock.calls[userLock]?.[1]).toEqual([TARGET]) + expect(statements.some(statement => statement.includes('INSERT INTO team_members'))).toBe( + false + ) + expect(statements.some(statement => statement.includes("SET status = 'accepted'"))).toBe( + false + ) + expect(dispatcher.state.status).toBe('pending') + } + ) + + it('returns the lifecycle generation of the locked user row', async () => { + const invitation = { + id: '00000000-0000-4000-8000-000000000203', + team_id: TEAM_A, + invitee_name: 'Target', + email: 'target@example.com', + role: 'member', + token: 'synthetic-generation-token', + status: 'pending', + purpose: 'member_invitation', + ...activeInvitationWindow(), + accepted_at: null, + accepted_user_id: null, + team_name: 'Team A', + } + // A password change committed between the unlocked read and the lock. + const dispatcher = acceptanceQueries({ + invitation, + unlockedUser: { lifecycle_state: 'active', lifecycle_version: 1, password_hash: null }, + lockedUser: { + lifecycle_state: 'active', + lifecycle_version: 2, + password_hash: 'synthetic-stored-hash', + }, + }) + mocks.query.mockImplementation(dispatcher.query) + + const result = await acceptInvitationForEmail(invitation.email, invitation.token) + + expect(result).toMatchObject({ + data: { accepted: true, authGeneration: 2, lifecycleState: 'active', passwordPending: false }, + lockedUser: { lifecycleState: 'active', lifecycleVersion: 2 }, + }) + expect(dispatcher.state.membershipWrites).toBe(1) + }) + + 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: 'synthetic-invitation-token', + status: 'pending', + purpose: 'member_invitation', + ...activeInvitationWindow(), + 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 + let acceptanceUserReads = 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(MANAGER_USER_LOCK)) { + return { rows: [{ id: MANAGER }], rowCount: 1 } + } + // The acceptance resolves and locks its user before it queues on the + // invitation row the revocation holds. + if (sql.includes(ACCEPTANCE_USER_LOCK) || sql.includes('WHERE email = $1')) { + acceptanceUserReads += 1 + return { + rows: [ + { + id: TARGET, + email: invitation.email, + name: 'Invitee', + picture: null, + password_hash: null, + lifecycle_state: 'active', + lifecycle_version: 1, + }, + ], + rowCount: 1, + } + } + if (sql.includes('INSERT INTO profiles')) return { rows: [], rowCount: 0 } + 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') + // Witness: the acceptance passed its unlocked read and locked its user, so + // the refusal came from the locked invitation row after the revocation. + expect(acceptanceUserReads).toBe(2) + 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: 'synthetic-password-reset-token', + status: 'pending', + purpose: 'password_reset', + ...activeInvitationWindow(), + accepted_at: null, + accepted_user_id: null, + team_name: null, + } + + for (const operation of [ + () => + 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) => { + if ( + sql.includes('FROM invitations i') && + (sql.includes('i.id::text = $1') || sql.includes('i.token = $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 { + 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).not.toContain('SET password_hash') + } + }) + + it('accepts a member invitation before 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: 'synthetic-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 + let lifecycleVersion = 1 + let lifecycleAdvances = 0 + mocks.query.mockImplementation(async (sql: string) => { + if ( + sql.includes('FROM invitations i') && + (sql.includes('i.token = $1') || sql.includes('i.id::text = $1')) + ) { + return { + rows: [ + { ...invitation, status, accepted_user_id: status === 'accepted' ? TARGET : null }, + ], + rowCount: 1, + } + } + if (sql.includes(ACCEPTANCE_USER_LOCK)) { + return { + rows: [ + { + id: TARGET, + email: invitation.email, + name: 'New user', + picture: null, + password_hash: passwordHash, + lifecycle_state: 'active', + lifecycle_version: lifecycleVersion, + }, + ], + 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, + 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' + 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' + lifecycleVersion += 1 + lifecycleAdvances += 1 + return { rows: [{ lifecycle_version: lifecycleVersion }], 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 } + throw new Error(`unexpected query: ${sql.slice(0, 40)}`) + }) + + await expect( + acceptInvitationForEmail(invitation.email, invitation.token, invitation.id) + ).resolves.toMatchObject({ data: { accepted: true, userId: TARGET, status: 'accepted' } }) + expectUsersBeforeInvitations(mocks.query.mock.calls, { membershipWrite: true }) + await expect( + setInvitationPasswordForUser( + TARGET, + invitation.email, + invitation.id, + 'synthetic-valid-password' + ) + ).resolves.toMatchObject({ data: { passwordUpdated: true, status: 'accepted' } }) + expect(status).toBe('accepted') + expect(passwordHash).not.toBeNull() + expect(membershipWrites).toBe(1) + expect(lifecycleVersion).toBe(2) + expect(lifecycleAdvances).toBe(1) + + expect(membershipWrites).toBe(1) + }) + + it('rejects email-only password setup for a member invitation', async () => { + const invitation = { + id: '00000000-0000-4000-8000-000000000200', + team_id: TEAM_A, + invitee_name: 'Existing user', + email: 'existing@example.com', + role: 'member', + token: 'synthetic-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('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.id, 'attacker-password') + ).resolves.toEqual({ error: 'not_found' }) + const sql = mocks.query.mock.calls.map(call => String(call[0])).join('\n') + expect(sql).not.toContain('SET password_hash') + expect(sql).not.toContain('external_user_session_security_epochs') + }) + + it('sends a managed resend from one locked transaction', async () => { + const invitation = { + id: '00000000-0000-4000-8000-000000000200', + team_id: TEAM_A, + invitee_name: 'Invitee', + email: 'invitee@example.com', + role: 'member', + token: 'synthetic-invitation-token', + status: 'pending', + purpose: 'member_invitation', + ...activeInvitationWindow(), + 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: queryWithDatabaseClock }) + } finally { + transactionDepth -= 1 + } + }) + mocks.registerInvitation.mockImplementation(async () => { + expect(transactionDepth).toBe(0) + }) + mocks.query + .mockResolvedValueOnce({ rows: [{ id: MANAGER }], rowCount: 1 }) + .mockResolvedValueOnce({ rows: [invitation], rowCount: 1 }) + .mockResolvedValueOnce({ + 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({ + resent: true, + id: invitation.id, + email: invitation.email, + }) + expectManagerBeforeInvitation(mocks.query.mock.calls) + expect(mocks.registerInvitation).toHaveBeenCalledOnce() + }) + + 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', + ...activeInvitationWindow(), + accepted_at: null, + accepted_user_id: null, + team_name: 'Team A', + } + mocks.query + .mockResolvedValueOnce({ rows: [{ id: MANAGER }], rowCount: 1 }) + .mockResolvedValueOnce({ rows: [invitation], rowCount: 1 }) + .mockResolvedValueOnce({ + 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 = + operation === 'resend' + ? await resendManagedInvitationForUser(MANAGER, invitation.id) + : await revokeManagedInvitationForUser(MANAGER, invitation.id) + + expect(result).toEqual({ error: 'forbidden' }) + expectManagerBeforeInvitation(mocks.query.mock.calls) + 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('does not re-lock the caller before the member-removal membership locks', 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: 'member', status: 'deleted' }], + rowCount: 1, + }) + + const result = await deleteManagedMemberForUser(MANAGER, TARGET, TEAM_A) + + 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 team_members') + expect(String(mocks.query.mock.calls[0]?.[0])).toContain('FOR UPDATE') + 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) + }) + + // Emulates the role-change lock: the target is retired but keeps an active + // membership, so its row comes back only when the query ignores the user + // lifecycle. + function managedRoleDatabase(options: { targetRetired: boolean }) { + const query = async (sql: string) => { + if (sql.includes('FROM team_members') && sql.includes('FOR UPDATE')) { + const rows = [{ user_id: MANAGER, role: 'admin' }] + if (!options.targetRetired || !sql.includes("u.lifecycle_state = 'active'")) { + rows.push({ user_id: TARGET, role: 'member' }) + } + return { rows, rowCount: rows.length } + } + if (sql.includes('UPDATE team_members')) { + return { + rows: [{ team_id: TEAM_A, user_id: TARGET, role: 'inviter', status: 'active' }], + rowCount: 1, + } + } + throw new Error(`unexpected query: ${sql.slice(0, 40)}`) + } + return query + } + + it('refuses a role change on a retired target as dev did', async () => { + mocks.query.mockImplementation(managedRoleDatabase({ targetRetired: true })) + + const refused = await updateManagedMemberRoleForUser(MANAGER, TARGET, TEAM_A, 'inviter') + + expect(refused).toEqual({ error: 'not_found' }) + // Witness: the lock query ran once and locked only the membership rows. + expect(mocks.query).toHaveBeenCalledTimes(1) + expect(String(mocks.query.mock.calls[0]?.[0])).toContain('FOR UPDATE OF tm') + expect( + mocks.query.mock.calls.some(call => String(call[0]).includes('UPDATE team_members')) + ).toBe(false) + expect(mocks.appendEvents).not.toHaveBeenCalled() + + // Sibling: an active target passes the same lock and is updated. + mocks.query.mockReset() + mocks.query.mockImplementation(managedRoleDatabase({ targetRetired: false })) + + const changed = await updateManagedMemberRoleForUser(MANAGER, TARGET, TEAM_A, 'inviter') + + expect(changed).toEqual({ + membership: { team_id: TEAM_A, user_id: TARGET, role: 'inviter', status: 'active' }, + }) + expect(String(mocks.query.mock.calls[1]?.[0])).toContain('UPDATE team_members') + expect(mocks.appendEvents).toHaveBeenCalledTimes(1) + }) + + it('matches upper-case manager, target and team ids against the lower-case lock rows', async () => { + const manager = 'aaaaaaaa-0000-4000-8000-000000000001' + const target = 'bbbbbbbb-0000-4000-8000-000000000002' + const team = 'cccccccc-0000-4000-8000-000000000010' + mocks.query.mockImplementation(async (sql: string) => { + if (sql.includes('FROM team_members') && sql.includes('FOR UPDATE')) { + // PostgreSQL renders uuid::text in lower case. + return { + rows: [ + { user_id: manager, role: 'admin' }, + { user_id: target, role: 'member' }, + ], + rowCount: 2, + } + } + if (sql.includes("SET status = 'deleted'")) { + return { + rows: [{ team_id: team, user_id: target, role: 'member', status: 'deleted' }], + rowCount: 1, + } + } + if (sql.includes('UPDATE team_members')) { + return { + rows: [{ team_id: team, user_id: target, role: 'inviter', status: 'active' }], + rowCount: 1, + } + } + throw new Error(`unexpected query: ${sql.slice(0, 40)}`) + }) + + const changed = await updateManagedMemberRoleForUser( + manager.toUpperCase(), + target.toUpperCase(), + ` ${team.toUpperCase()} `, + 'inviter' + ) + const removed = await deleteManagedMemberForUser( + manager.toUpperCase(), + target.toUpperCase(), + team.toUpperCase() + ) + + expect(changed).toMatchObject({ membership: { role: 'inviter' } }) + expect(removed).toMatchObject({ deleted: { status: 'deleted' } }) + const statements = mocks.query.mock.calls as Array<[string, unknown[]]> + const locks = statements.filter(([sql]) => sql.includes('FOR UPDATE')) + const updates = statements.filter(([sql]) => sql.includes('UPDATE team_members')) + expect(locks.map(([, values]) => values)).toEqual([ + [team, [manager, target]], + [team, [manager, target]], + ]) + // Witness: both mutations wrote with the lower-case ids. + expect(updates.map(([, values]) => values.slice(0, 2))).toEqual([ + [team, target], + [team, target], + ]) + const events = mocks.appendEvents.mock.calls.map( + call => + call[1] as { + operatorSub: string + changes: Array<{ resourceRef: string; subject: { id: string }; teamId: string }> + } + ) + expect(events).toHaveLength(2) + for (const event of events) { + expect(event.operatorSub).toBe(manager) + for (const change of event.changes) { + expect(change.subject.id).toBe(target) + expect(change.teamId).toBe(team) + expect(change.resourceRef.startsWith(`team_membership:${team}:role:`)).toBe(true) + } + } + }) + + it('refuses a managed retirement of an upper-case copy of the caller', async () => { + const manager = 'aaaaaaaa-0000-4000-8000-000000000001' + const target = 'bbbbbbbb-0000-4000-8000-000000000002' + const retirement = { + reason: 'synthetic self retirement', + idempotencyKey: 'synthetic-self-retirement', + requestId: null, + } + mocks.withTransaction.mockRejectedValue(new Error('retirement transaction reached')) + + await expect( + deleteManagedUserForUser(manager, manager.toUpperCase(), retirement) + ).resolves.toEqual({ error: 'invalid_target' }) + expect(mocks.withTransaction).not.toHaveBeenCalled() + + // Witness: a different target passes the guard and opens the retirement + // transaction. + await expect(deleteManagedUserForUser(manager, target, retirement)).rejects.toThrow( + 'retirement transaction reached' + ) + expect(mocks.withTransaction).toHaveBeenCalledTimes(1) + }) + + // Cheap pin of the retirement lock statement; the lock order itself is + // proven on real PostgreSQL by + // directoryManagedWriterLockOrder.realPostgres.integration.test.ts. + it('locks the managed retirement authority in one (team_id, user_id) ordered statement', async () => { + const manager = 'aaaaaaaa-0000-4000-8000-000000000001' + const target = 'bbbbbbbb-0000-4000-8000-000000000002' + const teamOne = 'cccccccc-0000-4000-8000-000000000010' + const teamTwo = 'dddddddd-0000-4000-8000-000000000020' + const operationId = 'eeeeeeee-0000-4000-8000-000000000030' + let managerRoleOnTeamTwo: TeamRole = 'member' + mocks.query.mockImplementation(async (sql: string) => { + if (sql.includes('FROM users') && sql.includes('ANY($1::uuid[])')) { + return { rows: [{ id: manager }, { id: target }], rowCount: 2 } + } + if (sql.includes('INSERT INTO desktop_user_retirement_operations')) { + return { rows: [{ id: operationId }], rowCount: 1 } + } + if (sql.includes('FROM users') && sql.includes('WHERE id = $1::uuid')) { + return { + rows: [{ id: target, lifecycle_state: 'active', lifecycle_version: 1 }], + rowCount: 1, + } + } + // PostgreSQL returns uuid::text in lower case, in (team_id, user_id) order. + const memberships = [ + { team_id: teamOne, user_id: manager, role: 'admin' }, + { team_id: teamOne, user_id: target, role: 'member' }, + { team_id: teamTwo, user_id: manager, role: managerRoleOnTeamTwo }, + { team_id: teamTwo, user_id: target, role: 'member' }, + ] + if (sql.includes('FROM team_members tm') && sql.includes('ANY($1::uuid[])')) { + return { rows: memberships, rowCount: memberships.length } + } + // Answers for a two-step order (target rows, then manager rows), so that + // reverting to it fails at the single-statement pin below rather than here. + if (sql.includes(MANAGER_USER_LOCK)) return { rows: [], rowCount: 0 } + if (sql.includes('FROM team_members')) { + const userId = sql.includes('FROM team_members tm') ? manager : target + const rows = memberships.filter(row => row.user_id === userId) + return { rows, rowCount: rows.length } + } + if (sql.includes('gfs_desktop_operator_links')) { + throw new Error('retirement passed authorization') + } + throw new Error(`unexpected query: ${sql.slice(0, 60)}`) + }) + const retirement = { + reason: 'synthetic lock order retirement', + idempotencyKey: 'synthetic-lock-order-retirement', + requestId: null, + } + + await expect( + deleteManagedUserForUser(manager.toUpperCase(), target.toUpperCase(), retirement) + ).resolves.toEqual({ error: 'forbidden_uncontrolled_teams' }) + + const statements = (mocks.query.mock.calls as Array<[string, unknown[]]>).map( + ([sql, values]) => ({ sql: sql.replace(/\s+/g, ' '), values }) + ) + const membershipLocks = statements.filter( + ({ sql }) => sql.includes('team_members') && sql.includes('FOR UPDATE') + ) + expect(membershipLocks).toHaveLength(1) + expect(membershipLocks[0]!.sql).toContain('ORDER BY tm.team_id, tm.user_id FOR UPDATE OF tm') + expect(membershipLocks[0]!.sql).toContain("AND u.lifecycle_state = 'active'") + expect(membershipLocks[0]!.values).toEqual([[manager, target], target]) + // The principal pair lock already holds the manager row; no second lock. + expect(statements.filter(({ sql }) => sql.includes(MANAGER_USER_LOCK))).toHaveLength(0) + // Witness: the principal pair lock ran before the one membership lock. + const pairLock = statements.findIndex(({ sql }) => sql.includes('ANY($1::uuid[]) ORDER BY id')) + expect(pairLock).toBeGreaterThanOrEqual(0) + expect(pairLock).toBeLessThan(statements.indexOf(membershipLocks[0]!)) + + // Witness for the forbidden decision: with control of both teams, the + // upper-case manager id matches the lower-case rows and retirement + // proceeds past authorization. + managerRoleOnTeamTwo = 'admin' + await expect( + deleteManagedUserForUser(manager.toUpperCase(), target.toUpperCase(), retirement) + ).rejects.toThrow('retirement passed authorization') + }) + + it('changes a password and revokes every session in one conditional transaction', async () => { + 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 }) + .mockResolvedValueOnce({ rows: [{ id: MANAGER, lifecycle_version: 2 }], rowCount: 1 }) + .mockResolvedValueOnce({ rows: [], rowCount: 1 }) + .mockResolvedValueOnce({ rows: [], rowCount: 2 }) + + await expect( + updateUserPassword( + MANAGER, + 'manager@example.com', + 'synthetic-current-password', + 'next-password' + ) + ).resolves.toEqual({ updated: true }) + + 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(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( + 'INSERT INTO external_user_session_security_epochs' + ) + expect(String(mocks.query.mock.calls[4]?.[0])).toContain('UPDATE external_user_sessions') + expect( + mocks.query.mock.calls.filter(([sql]) => + String(sql).includes('lifecycle_version = lifecycle_version + 1') + ) + ).toHaveLength(1) + }) + + function directoryRows(count: number) { + return Array.from({ length: count }, (_, index) => ({ + id: `00000000-0000-4000-8000-${String(index + 1).padStart(12, '0')}`, + email: `person-${index}@example.com`, + name: `Person ${index}`, + display_name: index % 2 === 0 ? `Person ${index}` : null, + })) + } + + function selectList(sql: string): string { + const match = /^\s*SELECT\s+([\s\S]*?)\s+FROM team_members tm/.exec(sql) + if (!match) throw new Error('directory search SQL has no SELECT list') + return match[1]!.replace(/\s+/g, ' ') + } + + it('treats wildcard characters literally, excludes channels, and returns one bounded page without a cursor', async () => { + mocks.query.mockResolvedValue({ rows: directoryRows(25), 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(values).toEqual([TEAM_A, '%\\%\\_%']) + // channels never leave the database: the SELECT list does not read them. + expect(selectList(sql)).not.toContain('channels') + expect(sql).not.toContain('p.channels') + expect(sql).toMatch( + /ORDER BY LOWER\(COALESCE\(NULLIF\(p\.display_name, ''\), NULLIF\(u\.name, ''\), u\.email\)\) ASC,\s+u\.id ASC\s+LIMIT 25$/ + ) + expect(result.items).toHaveLength(25) + expect(result).not.toHaveProperty('nextCursor') + }) + + it("returns dev's raw row shape without channels or a sort key", async () => { + const rows = directoryRows(3) + mocks.query.mockResolvedValue({ rows, rowCount: rows.length }) + + const result = await searchDirectory(TEAM_A, 'person') + + // Witness: the search ran once with the plain pattern. + expect(mocks.query).toHaveBeenCalledTimes(1) + const [sql, values] = mocks.query.mock.calls[0] as [string, unknown[]] + expect(values).toEqual([TEAM_A, '%person%']) + expect(selectList(sql)).toBe('u.id, u.email, u.name, p.display_name') + expect(sql).not.toContain('sort_key') + expect(result).toEqual({ items: rows }) + for (const item of result.items as Array>) { + expect(Object.keys(item).sort()).toEqual(['display_name', 'email', 'id', 'name']) + } + + // A blank query answers an empty page without another search. + await expect(searchDirectory(TEAM_A, ' ')).resolves.toEqual({ items: [] }) + expect(mocks.query).toHaveBeenCalledTimes(1) + }) + + 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/directoryManagedWriterLockOrder.realPostgres.integration.test.ts b/control-api/test/directoryManagedWriterLockOrder.realPostgres.integration.test.ts new file mode 100644 index 0000000000..624c912d3d --- /dev/null +++ b/control-api/test/directoryManagedWriterLockOrder.realPostgres.integration.test.ts @@ -0,0 +1,377 @@ +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' +import { randomBytes, randomUUID } from 'node:crypto' +import { Pool, type PoolClient } from 'pg' +import { type DbTransactionClient, type DbTransactionOptions, initDb } from '../src/db.js' +import { + deleteManagedMemberForUser, + deleteManagedUserForUser, + updateManagedMemberRoleForUser, +} from '../src/services/directory/membership.js' +import { endPoolAndWaitForClients } from './helpers/realPostgresTeardown.js' + +// Managed member removal and role change lock one team's team_members rows +// ORDER BY user_id. Managed retirement locks the manager's and the target's +// rows across every team of the target. Both must follow one global order, +// (team_id, user_id), or a retirement of B by A and a removal (or demotion) +// of A by B on a shared team deadlock. Each case orders the two writers with a +// gate transaction so that the race is forced, not hoped for. + +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 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 = ( + work: (db: DbTransactionClient) => Promise, + _txPool?: Pool, + options: DbTransactionOptions = {} + ): Promise => { + return actual.withTransaction(work, producerDatabase.pool!, options) + } + return { ...actual, pool, withTransaction } +}) + +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, '""')}"` +} + +/** Two random UUIDs in PostgreSQL uuid order (byte order = lower-case text order). */ +function orderedUuids(): [string, string] { + const [first, second] = [randomUUID(), randomUUID()].sort() + return [first!, second!] +} + +function sqlState(reason: unknown): unknown { + return reason !== null && typeof reason === 'object' && 'code' in reason + ? (reason as { code?: unknown }).code + : undefined +} + +describeRealPostgres('managed directory writers share one team_members lock order', () => { + const database = `control_api_directory_lock_order_${randomBytes(6).toString('hex')}` + const connectionString = databaseUrl( + adminUrl ?? 'postgresql://postgres@127.0.0.1/postgres', + database + ) + 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 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 the member writer to block on the gate row lock') + } + + // The retirement is blocked behind the gate or behind the member writer + // queued first on the same row; either way it waits in a team_members lock. + async function waitForOtherBlockedTeamMembersLock(excludedPids: 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 activity.pid <> ALL($1::int[]) + AND cardinality(pg_blocking_pids(activity.pid)) > 0 + AND activity.query LIKE '%team_members%' + ORDER BY activity.pid + LIMIT 1`, + [excludedPids] + ) + if (result.rows[0]?.pid) return result.rows[0].pid + await new Promise(resolve => setTimeout(resolve, 10)) + } + throw new Error('expected the retirement to block in its team_members lock') + } + + async function holdMembershipRow(teamId: string, userId: string): Promise { + const gate = await databasePool.connect() + try { + await gate.query('BEGIN') + const locked = await gate.query( + `SELECT 1 + FROM team_members + WHERE team_id = $1 + AND user_id = $2 + FOR UPDATE`, + [teamId, userId] + ) + expect(locked.rowCount).toBe(1) + } catch (error) { + await gate.query('ROLLBACK') + gate.release() + throw error + } + return gate + } + + /** + * Admins A < B (uuid order) share every team in `teamIds` (sorted). + */ + async function seedAdmins(label: string, teamCount: number) { + const [userA, userB] = orderedUuids() + const teamIds = Array.from({ length: teamCount }, () => randomUUID()).sort() + await databasePool.query( + `INSERT INTO users(id, email, name, lifecycle_state, lifecycle_version) + VALUES ($1, $2, 'Admin A', 'active', 1), + ($3, $4, 'Admin B', 'active', 1)`, + [userA, `${label}-a-${userA}@example.test`, userB, `${label}-b-${userB}@example.test`] + ) + for (const teamId of teamIds) { + 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'), + ($1, $3, 'admin', 'active')`, + [teamId, userA, userB] + ) + } + return { userA, userB, teamIds } + } + + async function membershipRows(userIds: string[]) { + const result = await databasePool.query<{ + team_id: string + user_id: string + role: string + status: string + }>( + `SELECT team_id::text AS team_id, user_id::text AS user_id, role, status + FROM team_members + WHERE user_id = ANY($1::uuid[]) + ORDER BY team_id, user_id`, + [userIds] + ) + return result.rows + } + + /** + * A retires B while B changes A's membership on `contestedTeam`. The gate + * holds (contestedTeam, A), the first row the member writer locks. The member + * writer queues on it first, then the retirement queues behind it; the gate + * then commits and both proceed. + */ + async function raceRetirementAgainstMemberWriter(options: { + userA: string + userB: string + contestedTeam: string + memberWriter: () => Promise + }) { + const gate = await holdMembershipRow(options.contestedTeam, options.userA) + let gateOpen = true + try { + const gatePid = await backendPid(gate) + const memberWriter = options.memberWriter() + const memberWriterSettled = memberWriter.then( + value => ({ status: 'fulfilled' as const, value }), + (reason: unknown) => ({ status: 'rejected' as const, reason }) + ) + const memberWriterPid = await waitForBlockedBy(gatePid) + + const retirement = deleteManagedUserForUser(options.userA, options.userB, { + reason: 'synthetic lock order retirement', + idempotencyKey: `synthetic-lock-order-${randomBytes(8).toString('hex')}`, + requestId: null, + }) + const retirementSettled = retirement.then( + value => ({ status: 'fulfilled' as const, value }), + (reason: unknown) => ({ status: 'rejected' as const, reason }) + ) + const retirementPid = await waitForOtherBlockedTeamMembersLock([gatePid, memberWriterPid]) + + await gate.query('COMMIT') + gateOpen = false + const [memberWriterOutcome, retirementOutcome] = await Promise.all([ + memberWriterSettled, + retirementSettled, + ]) + return { memberWriterPid, retirementPid, memberWriterOutcome, retirementOutcome } + } finally { + if (gateOpen) await gate.query('ROLLBACK') + gate.release() + } + } + + function expectNoDeadlock(outcomes: Array<{ status: string; reason?: unknown }>): void { + const states = outcomes + .filter(outcome => outcome.status === 'rejected') + .map(outcome => sqlState(outcome.reason)) + expect(states).not.toContain('40P01') + // Witness: both writers settled without any rejection at all. + expect(states).toEqual([]) + expect(outcomes).toHaveLength(2) + } + + async function expectRetirementRolledBack(userB: string): Promise { + const user = await databasePool.query<{ lifecycle_state: string }>( + `SELECT lifecycle_state FROM users WHERE id = $1`, + [userB] + ) + expect(user.rows).toEqual([{ lifecycle_state: 'active' }]) + const operations = await databasePool.query<{ count: string }>( + `SELECT count(*)::text AS count + FROM desktop_user_retirement_operations + WHERE target_user_id = $1`, + [userB] + ) + expect(operations.rows).toEqual([{ count: '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 }) + producerDatabase.pool = databasePool + 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('lets a member removal and a retirement on a shared team both finish without a deadlock', async () => { + const { userA, userB, teamIds } = await seedAdmins('lock-order-remove', 1) + const [team] = teamIds as [string] + + const race = await raceRetirementAgainstMemberWriter({ + userA, + userB, + contestedTeam: team, + memberWriter: () => deleteManagedMemberForUser(userB, userA, team), + }) + + // Witness: both writers really waited, so the schedule was the racing one. + expect(race.memberWriterPid).toBeGreaterThan(0) + expect(race.retirementPid).toBeGreaterThan(0) + expect(race.retirementPid).not.toBe(race.memberWriterPid) + expectNoDeadlock([race.memberWriterOutcome, race.retirementOutcome]) + expect(race.memberWriterOutcome).toEqual({ + status: 'fulfilled', + value: { deleted: { team_id: team, user_id: userA, role: 'admin', status: 'deleted' } }, + }) + // The retirement re-reads A's row after the removal committed: A no longer + // manages the team, so the retirement is refused. + expect(race.retirementOutcome).toEqual({ + status: 'fulfilled', + value: { error: 'forbidden_uncontrolled_teams' }, + }) + await expect(membershipRows([userA, userB])).resolves.toEqual([ + { team_id: team, user_id: userA, role: 'admin', status: 'deleted' }, + { team_id: team, user_id: userB, role: 'admin', status: 'active' }, + ]) + await expectRetirementRolledBack(userB) + }) + + it('lets a role demotion and a retirement on a shared team both finish without a deadlock', async () => { + const { userA, userB, teamIds } = await seedAdmins('lock-order-demote', 1) + const [team] = teamIds as [string] + + const race = await raceRetirementAgainstMemberWriter({ + userA, + userB, + contestedTeam: team, + memberWriter: () => updateManagedMemberRoleForUser(userB, userA, team, 'member'), + }) + + expect(race.memberWriterPid).toBeGreaterThan(0) + expect(race.retirementPid).toBeGreaterThan(0) + expect(race.retirementPid).not.toBe(race.memberWriterPid) + expectNoDeadlock([race.memberWriterOutcome, race.retirementOutcome]) + expect(race.memberWriterOutcome).toEqual({ + status: 'fulfilled', + value: { membership: { team_id: team, user_id: userA, role: 'member', status: 'active' } }, + }) + // A is a member now, which cannot retire anyone. + expect(race.retirementOutcome).toEqual({ + status: 'fulfilled', + value: { error: 'forbidden_uncontrolled_teams' }, + }) + await expect(membershipRows([userA, userB])).resolves.toEqual([ + { team_id: team, user_id: userA, role: 'member', status: 'active' }, + { team_id: team, user_id: userB, role: 'admin', status: 'active' }, + ]) + await expectRetirementRolledBack(userB) + }) + + it('orders the retirement locks by team first when A and B share two teams', async () => { + const { userA, userB, teamIds } = await seedAdmins('lock-order-two-teams', 2) + const [firstTeam, secondTeam] = teamIds as [string, string] + + // The removal targets the second team: a retirement that locked every + // target row before the manager rows would already hold (second, B). + const race = await raceRetirementAgainstMemberWriter({ + userA, + userB, + contestedTeam: secondTeam, + memberWriter: () => deleteManagedMemberForUser(userB, userA, secondTeam), + }) + + expect(race.memberWriterPid).toBeGreaterThan(0) + expect(race.retirementPid).toBeGreaterThan(0) + expect(race.retirementPid).not.toBe(race.memberWriterPid) + expectNoDeadlock([race.memberWriterOutcome, race.retirementOutcome]) + expect(race.memberWriterOutcome).toEqual({ + status: 'fulfilled', + value: { + deleted: { team_id: secondTeam, user_id: userA, role: 'admin', status: 'deleted' }, + }, + }) + expect(race.retirementOutcome).toEqual({ + status: 'fulfilled', + value: { error: 'forbidden_uncontrolled_teams' }, + }) + await expect(membershipRows([userA, userB])).resolves.toEqual([ + { team_id: firstTeam, user_id: userA, role: 'admin', status: 'active' }, + { team_id: firstTeam, user_id: userB, role: 'admin', status: 'active' }, + { team_id: secondTeam, user_id: userA, role: 'admin', status: 'deleted' }, + { team_id: secondTeam, user_id: userB, role: 'admin', status: 'active' }, + ]) + await expectRetirementRolledBack(userB) + }) +}) diff --git a/control-api/test/externalInvitationAcceptSession.realPostgres.integration.test.ts b/control-api/test/externalInvitationAcceptSession.realPostgres.integration.test.ts new file mode 100644 index 0000000000..c478a1badf --- /dev/null +++ b/control-api/test/externalInvitationAcceptSession.realPostgres.integration.test.ts @@ -0,0 +1,239 @@ +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 { setInvitationPasswordForUser } from '../src/services/directory/membership.js' +import { endPoolAndWaitForClients } from './helpers/realPostgresTeardown.js' + +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() })) + +vi.mock('../src/services/invitationFlowRegistrationService.js', () => flow) +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 policy = { + policyVersion: '1', + policyRevision: 'invitation-accept-session-test', + acceptV1: false, + issueV1: false, + acceptV2: true, + issueV2: true, + renewV2: true, + switchCompatibility: false, + minimumClientVersion: null, + enforceMinimumClient: false, +} as EffectiveUserAccessPolicy + +type InvitationFixture = { + id: string + email: string + flowToken: string +} + +type InvitationStateRow = { + status: string + accepted_user_id: string | null + accepted_at: Date | null +} + +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 +} + +describeRealPostgres('external invitation acceptance session issuance on real PostgreSQL', () => { + const database = `control_api_invitation_session_${randomBytes(6).toString('hex')}` + const flowTokens = new Map() + let adminPool: Pool + let teamId: string + + async function createInvitation(label: string): Promise { + const email = `${label}-${randomUUID()}@example.test` + const capability = `db-token-${randomUUID()}` + const inserted = await holder.pool.query<{ id: string }>( + `INSERT INTO invitations(email, role, token, status, purpose, expires_at) + VALUES ($1, 'member', $2, 'pending', 'member_invitation', NOW() + INTERVAL '1 hour') + RETURNING id::text AS id`, + [email, capability] + ) + const id = inserted.rows[0]!.id + await holder.pool.query( + `INSERT INTO invitation_teams(invitation_id, team_id, role) + VALUES ($1, $2, 'member')`, + [id, teamId] + ) + const flowToken = `flow-${randomUUID()}` + flowTokens.set(flowToken, { capability, email }) + return { id, email, flowToken } + } + + async function invitationState(invitationId: string): Promise { + const result = await holder.pool.query( + `SELECT status, accepted_user_id, accepted_at + FROM invitations + WHERE id = $1`, + [invitationId] + ) + const row = result.rows[0] + if (!row) throw new Error(`invitation ${invitationId} was not found`) + return row + } + + async function activeSessionCount(userId: string): Promise { + const result = await holder.pool.query<{ count: number }>( + `SELECT count(*)::int AS count + FROM external_user_sessions + WHERE user_id = $1 AND revoked_at IS NULL`, + [userId] + ) + return result.rows[0]!.count + } + + async function accept(token: string, email: string) { + return request(app()) + .post('/external/invitations/accept') + .send({ email, token, sessionContract: 'v2' }) + } + + 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, 'Invitation Session Team')`, [ + teamId, + ]) + accessPolicy.resolveEffectiveUserAccessPolicy.mockResolvedValue(policy) + 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(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('issues a session on the first acceptance and none on a replay', async () => { + const invitation = await createInvitation('replay-session') + + const first = await accept(invitation.flowToken, invitation.email) + expect(first.status).toBe(200) + expect(first.body.token).toEqual(expect.any(String)) + expect(first.body.sessionContract).toBe('v2') + expect(first.body).not.toHaveProperty('replay') + const userId = String(first.body.userId) + expect(await activeSessionCount(userId)).toBe(1) + + const firstState = await invitationState(invitation.id) + expect(firstState.status).toBe('accepted') + expect(firstState.accepted_user_id).toBe(userId) + const firstAcceptedAt = firstState.accepted_at + if (!(firstAcceptedAt instanceof Date)) { + throw new Error('first acceptance did not record an accepted_at timestamp') + } + + const replay = await accept(invitation.flowToken, invitation.email) + expect(replay.status).toBe(200) + expect(replay.body).toMatchObject({ accepted: true, userId, replay: true }) + expect(replay.body).not.toHaveProperty('token') + expect(replay.body).not.toHaveProperty('sessionContract') + // The replay proves it read the same accepted row and issued nothing. + expect(replay.body.acceptedAt).toBe(firstAcceptedAt.toISOString()) + expect(await activeSessionCount(userId)).toBe(1) + + const replayState = await invitationState(invitation.id) + expect(replayState.accepted_user_id).toBe(userId) + const replayAcceptedAt = replayState.accepted_at + if (!(replayAcceptedAt instanceof Date)) { + throw new Error('replay cleared the accepted_at timestamp') + } + expect(replayAcceptedAt.toISOString()).toBe(firstAcceptedAt.toISOString()) + }) + + 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 = 'synthetic-Replay-After-Password-1!' + + const first = await accept(invitation.flowToken, invitation.email) + expect(first.status).toBe(200) + expect(first.body.token).toEqual(expect.any(String)) + const userId = String(first.body.userId) + expect(await activeSessionCount(userId)).toBe(1) + + await expect( + setInvitationPasswordForUser(userId, invitation.email, invitation.id, password) + ).resolves.toMatchObject({ data: { passwordUpdated: true } }) + const stored = await holder.pool.query<{ password_hash: string | null }>( + `SELECT password_hash FROM users WHERE id = $1`, + [userId] + ) + const passwordHash = stored.rows[0]?.password_hash + expect(passwordHash).toEqual(expect.any(String)) + await expect(bcrypt.compare(password, passwordHash!)).resolves.toBe(true) + // Liveness for the replay assertion: the password change revoked the + // session the first acceptance issued. + expect(await activeSessionCount(userId)).toBe(0) + + const replay = await accept(invitation.flowToken, invitation.email) + expect(replay.status).toBe(200) + expect(replay.body).toMatchObject({ accepted: true, userId, replay: true }) + expect(replay.body).not.toHaveProperty('token') + expect(await activeSessionCount(userId)).toBe(0) + }) +}) diff --git a/control-api/test/externalInvitationPasswordFirstSet.realPostgres.integration.test.ts b/control-api/test/externalInvitationPasswordFirstSet.realPostgres.integration.test.ts new file mode 100644 index 0000000000..6220f75d15 --- /dev/null +++ b/control-api/test/externalInvitationPasswordFirstSet.realPostgres.integration.test.ts @@ -0,0 +1,442 @@ +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 { createAdminAuthRouter } from '../src/routes/admin/auth.js' +import { createExternalInvitationsRouter } from '../src/routes/external/invitations.js' +import type { EffectiveUserAccessPolicy } from '../src/services/access/userAccessPolicy.js' +import { createControlAdminInvitation } from '../src/services/adminAuthService.js' +import { authenticateExternalUserSession } from '../src/services/auth/externalSessionAuthentication.js' +import { createUserSession } from '../src/services/auth/userSessionService.js' +import { + acceptInvitationById, + createSilentInvitationForTeams, + createSilentPasswordResetInvitation, + setInvitationPasswordForUser, +} 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() })) +const registration = vi.hoisted(() => ({ + validateControlAdminInvitationToken: vi.fn(), +})) + +vi.mock('../src/services/access/userAccessRuntimePolicy.js', () => accessPolicy) + +vi.mock('../src/services/controlAdminInvitationRegistrationService.js', async importOriginal => { + const real = + await importOriginal< + typeof import('../src/services/controlAdminInvitationRegistrationService.js') + >() + return { + ...real, + validateControlAdminInvitationToken: registration.validateControlAdminInvitationToken, + } +}) + +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 policy = { + policyVersion: '1', + policyRevision: 'invitation-password-first-set-test', + acceptV1: false, + issueV1: false, + acceptV2: true, + issueV2: true, + renewV2: true, + switchCompatibility: false, + minimumClientVersion: null, + enforceMinimumClient: false, +} as EffectiveUserAccessPolicy + +type StoredUser = { + id: string + password_hash: string | null + lifecycle_version: string | number + password_set_at: Date | null +} + +type InvitationState = { + status: string + accepted_user_id: string | null + accepted_at: Date | null +} + +type MemberFixture = { + email: string + invitationId: string + userId: string +} + +type ControlAdminPair = { + email: string + adminInvitationId: string + desktopInvitationId: string +} + +function app(): express.Express { + const a = express() + a.set('trust proxy', 1) + a.use(express.json()) + a.use(createExternalInvitationsRouter()) + a.use(createAdminAuthRouter()) + 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('external invitation password setup on real PostgreSQL', () => { + const database = `control_api_invitation_password_first_set_${randomBytes(6).toString('hex')}` + let adminPool: Pool + let teamId: string + + async function storedUser(email: string): Promise { + const result = await holder.pool.query( + `SELECT id, password_hash, lifecycle_version, password_set_at + FROM users + WHERE email = $1`, + [email] + ) + const user = result.rows[0] + if (!user) throw new Error(`user ${email} was not created`) + return user + } + + async function invitationState(invitationId: string): Promise { + const result = await holder.pool.query( + `SELECT status, accepted_user_id, accepted_at + FROM invitations + WHERE id = $1`, + [invitationId] + ) + const state = result.rows[0] + if (!state) throw new Error(`invitation ${invitationId} was not found`) + return state + } + + async function activeSessionCount(userId: string): Promise { + const result = await holder.pool.query<{ count: number }>( + `SELECT count(*)::int AS count + FROM external_user_sessions + WHERE user_id = $1 AND revoked_at IS NULL`, + [userId] + ) + return result.rows[0]!.count + } + + async function profileState(userId: string): Promise<{ + name: string | null + user_updated_at: Date + display_name: string | null + profile_updated_at: Date + }> { + const result = await holder.pool.query<{ + name: string | null + user_updated_at: Date + display_name: string | null + profile_updated_at: Date + }>( + `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 snapshot = result.rows[0] + if (!snapshot) throw new Error(`profile for user ${userId} was not found`) + return snapshot + } + + async function acceptedMember(label: string): Promise { + const email = `${label}-${randomUUID()}@example.test` + const invitation = await createSilentInvitationForTeams({ + inviteeName: email, + email, + teamAssignments: [{ teamId, role: 'member' }], + fallbackRole: 'member', + }) + const accepted = await acceptInvitationById(email, invitation.id) + if ('error' in accepted) throw new Error(accepted.error) + return { email, invitationId: invitation.id, userId: accepted.data.userId } + } + + async function seedControlAdmin(label: string): Promise { + const id = randomUUID() + const username = `${label}-${id.slice(0, 8)}` + await holder.pool.query( + `INSERT INTO control_admin_users (id, username, email, password_hash, role, status, session_version) + VALUES ($1, $2, $3, 'real-pg-inviter-hash', 'admin', 'active', 1)`, + [id, username, `${username}@example.test`] + ) + return id + } + + async function controlAdminPair(label: string): Promise { + const email = `${label}-${randomUUID()}@example.test` + const inviterId = await seedControlAdmin(`${label}-inviter`) + const adminInvitation = await createControlAdminInvitation(email, inviterId) + if ('error' in adminInvitation) throw new Error(adminInvitation.error) + const desktopInvitation = await createSilentInvitationForTeams({ + inviteeName: email, + email, + teamAssignments: [], + fallbackRole: 'member', + purpose: 'admin_desktop_access', + }) + return { + email, + adminInvitationId: adminInvitation.id, + desktopInvitationId: desktopInvitation.id, + } + } + + 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, 'Password Setup 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('sets the first password and refuses the second without any credential or profile write', async () => { + const member = await acceptedMember('first-password') + const firstPassword = 'First-Member-Password-1!' + const secondPassword = 'Second-Member-Password-2!' + + const firstSession = await createUserSession({ + userId: member.userId, + email: member.email, + authenticationMethods: ['password'], + }) + const first = await request(app()) + .post('/external/invitations/password') + .set('x-user-session-token', firstSession.token) + .send({ + userId: member.userId, + email: member.email, + invitationId: member.invitationId, + password: firstPassword, + }) + + expect(first.status).toBe(200) + expect(first.body).toMatchObject({ passwordUpdated: true }) + + // Liveness witness for the refusal below: this route can write a verifiable + // bcrypt hash for a passwordless invited user. + const afterFirst = await storedUser(member.email) + const firstHash = afterFirst.password_hash + if (!firstHash) throw new Error('first password setup did not store a hash') + await expect(bcrypt.compare(firstPassword, firstHash)).resolves.toBe(true) + + // The successful first set revokes the session it was called with. An + // existing session created afterwards is the credential the refusal must + // leave untouched. + const existingSession = await createUserSession({ + userId: member.userId, + email: member.email, + authenticationMethods: ['password'], + }) + // A refused call must not overwrite user-chosen identity fields either. + // 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', + updated_at = '2001-01-01T00:00:00Z' + WHERE id = $1`, + [member.userId] + ) + await holder.pool.query( + `UPDATE profiles + SET display_name = 'User selected profile', + updated_at = '2001-01-01T00:00:00Z' + WHERE user_id = $1`, + [member.userId] + ) + const profileBeforeRefusal = await profileState(member.userId) + expect(profileBeforeRefusal).toMatchObject({ + name: 'User selected name', + display_name: 'User selected profile', + }) + + const beforeRefusal = await storedUser(member.email) + const invitationBefore = await invitationState(member.invitationId) + const sessionsBefore = await activeSessionCount(member.userId) + expect(sessionsBefore).toBe(1) + await expect( + authenticateExternalUserSession(existingSession.token, { + purpose: 'protected', + policy, + }) + ).resolves.toMatchObject({ status: 'authenticated' }) + + const second = await request(app()) + .post('/external/invitations/password') + .set('x-user-session-token', existingSession.token) + .send({ + userId: member.userId, + email: member.email, + invitationId: member.invitationId, + password: secondPassword, + }) + + expect(second.status).toBe(409) + expect(second.body).toEqual({ error: 'password_already_set' }) + expect(second.headers['set-cookie']).toBeUndefined() + + // users.name, users.updated_at, profiles.display_name and + // profiles.updated_at all survive the refusal unchanged. + expect(await profileState(member.userId)).toEqual(profileBeforeRefusal) + + const afterRefusal = await storedUser(member.email) + expect(afterRefusal.id).toBe(beforeRefusal.id) + expect(afterRefusal.password_hash).toBe(beforeRefusal.password_hash) + expect(String(afterRefusal.lifecycle_version)).toBe(String(beforeRefusal.lifecycle_version)) + expect(afterRefusal.password_set_at?.toISOString()).toBe( + beforeRefusal.password_set_at?.toISOString() + ) + await expect(bcrypt.compare(firstPassword, afterRefusal.password_hash!)).resolves.toBe(true) + await expect(bcrypt.compare(secondPassword, afterRefusal.password_hash!)).resolves.toBe(false) + + // No invitation change and no session revocation on the refused call. + expect(await invitationState(member.invitationId)).toEqual(invitationBefore) + expect(await activeSessionCount(member.userId)).toBe(sessionsBefore) + const sessionRow = await holder.pool.query<{ revoked_at: Date | null }>( + `SELECT revoked_at FROM external_user_sessions WHERE sid = $1`, + [existingSession.identity.sid] + ) + expect(sessionRow.rows[0]?.revoked_at).toBeNull() + await expect( + authenticateExternalUserSession(existingSession.token, { + purpose: 'protected', + policy, + }) + ).resolves.toMatchObject({ + status: 'authenticated', + claims: { userId: member.userId }, + }) + }) + + it('sets the Desktop password on the admin finalization first acceptance', async () => { + const pair = await controlAdminPair('admin-first-accept') + const adminPassword = 'Admin-Completion-1!' + const memberPassword = 'Desktop-Completion-1!' + registration.validateControlAdminInvitationToken.mockResolvedValueOnce({ + email: pair.email, + invitationUuid: pair.adminInvitationId, + }) + + const response = await request(app()) + .post('/admin/auth/control-admin-invitations/complete') + .send({ + token: 'synthetic-control-admin-flow-token', + email: pair.email, + username: `invitee-${randomUUID().slice(0, 8)}`, + password: adminPassword, + useSameMemberPassword: false, + memberPassword, + }) + + expect(response.status).toBe(200) + expect(response.body).toEqual({ + completed: true, + desktopAccessCompleted: true, + login: { username: pair.email }, + }) + const user = await storedUser(pair.email) + const hash = user.password_hash + if (!hash) throw new Error('first acceptance did not store a Desktop password') + await expect(bcrypt.compare(memberPassword, hash)).resolves.toBe(true) + await expect(bcrypt.compare(adminPassword, hash)).resolves.toBe(false) + expect(await invitationState(pair.desktopInvitationId)).toMatchObject({ + status: 'accepted', + accepted_user_id: user.id, + }) + }) + + it('keeps the password-reset branch writable when the external option is set', async () => { + const email = `reset-branch-${randomUUID()}@example.test` + const previousPassword = 'Previous-Reset-Password-1!' + const nextPassword = 'Next-Reset-Password-2!' + const inserted = await holder.pool.query<{ id: string }>( + `INSERT INTO users(email, password_hash) VALUES ($1, $2) RETURNING id`, + [email, await bcrypt.hash(previousPassword, 12)] + ) + const userId = inserted.rows[0]!.id + const invitation = await createSilentPasswordResetInvitation({ email, inviteeName: email }) + + const result = await setInvitationPasswordForUser( + userId, + email, + invitation.id, + nextPassword, + undefined, + { rejectIfPasswordAlreadySet: true } + ) + + expect(result).toMatchObject({ data: { passwordUpdated: true } }) + const stored = await storedUser(email) + await expect(bcrypt.compare(nextPassword, stored.password_hash!)).resolves.toBe(true) + await expect(bcrypt.compare(previousPassword, stored.password_hash!)).resolves.toBe(false) + expect(await invitationState(invitation.id)).toMatchObject({ + status: 'accepted', + accepted_user_id: userId, + }) + }) +}) diff --git a/control-api/test/externalSessionAuth.nonStringToken.test.ts b/control-api/test/externalSessionAuth.nonStringToken.test.ts new file mode 100644 index 0000000000..96f37fe32d --- /dev/null +++ b/control-api/test/externalSessionAuth.nonStringToken.test.ts @@ -0,0 +1,236 @@ +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(), + 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: false, 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 +} + +/** + * A JSON value whose String() coercion throws: `toString` is not callable and + * `valueOf` returns the object itself, so String() raises a TypeError. + */ +const THROWING_JSON_VALUE = { toString: 1 } + +const SESSION_TOKEN_PAYLOAD = { + userId: claims.userId, + email: claims.email, + teamId: 'team-1', + role: 'member', +} + +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: THROWING_JSON_VALUE }) + + 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' }) + }) + + it('answers the session-token 400 for a non-string body token without coercing it', async () => { + const response = await request(authRouterApp()) + .post('/external/auth/session-token') + .send({ ...SESSION_TOKEN_PAYLOAD, token: THROWING_JSON_VALUE }) + + expect(response.status).toBe(400) + expect(response.body).toEqual({ error: 'invalid payload' }) + expect(sessions.authenticateExternalUserSession).not.toHaveBeenCalled() + }) + + it('answers the session-token 400 for a non-string payload field without coercing it', async () => { + const response = await request(authRouterApp()) + .post('/external/auth/session-token') + .send({ ...SESSION_TOKEN_PAYLOAD, userId: THROWING_JSON_VALUE, token: 'synthetic-id-token' }) + + expect(response.status).toBe(400) + expect(response.body).toEqual({ error: 'invalid payload' }) + expect(sessions.authenticateExternalUserSession).not.toHaveBeenCalled() + }) + + it('still authenticates a string body token at the session-token boundary', async () => { + issuance.exchangeLegacyExternalUserSession.mockResolvedValue({ + status: 'issued', + token: 'synthetic-replacement-token', + }) + + const response = await request(authRouterApp()) + .post('/external/auth/session-token') + .send({ ...SESSION_TOKEN_PAYLOAD, token: 'synthetic-id-token' }) + + expect(sessions.authenticateExternalUserSession).toHaveBeenCalledWith( + 'synthetic-id-token', + expect.objectContaining({ purpose: 'switch' }) + ) + expect(response.status).toBe(200) + expect(response.body).toEqual({ + token: 'synthetic-replacement-token', + sessionContract: 'v1', + deprecated: true, + }) + }) + + it('answers the verify 401 for a non-string body token without coercing it', async () => { + const response = await request(authRouterApp()) + .post('/external/auth/verify') + .send({ token: THROWING_JSON_VALUE }) + + expect(response.status).toBe(401) + expect(response.body).toEqual({ error: 'Unauthorized' }) + expect(sessions.authenticateExternalUserSession).not.toHaveBeenCalled() + }) + + it('still authenticates a string body token at the verify boundary', async () => { + const response = await request(authRouterApp()) + .post('/external/auth/verify') + .send({ token: 'synthetic-id-token' }) + + expect(sessions.authenticateExternalUserSession).toHaveBeenCalledWith( + 'synthetic-id-token', + expect.objectContaining({ purpose: 'verify' }) + ) + expect(response.status).toBe(200) + expect(response.body).toEqual({ claims }) + }) +}) 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/externalSessionAuthToken.canonical.test.ts b/control-api/test/externalSessionAuthToken.canonical.test.ts new file mode 100644 index 0000000000..9791096f33 --- /dev/null +++ b/control-api/test/externalSessionAuthToken.canonical.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from 'vitest' +import { randomUUID } from 'node:crypto' +import { + signExternalSessionToken, + verifyExternalSessionToken, +} from '../src/utils/auth/externalSessionAuthToken.js' +import { jwsAliasVariants } from './helpers/jwsBase64UrlAliases.js' + +const userId = randomUUID() + +function signedToken(): string { + return signExternalSessionToken({ + userId, + email: `${userId}@canonical-representation.test`, + teamId: null, + role: 'member', + authGeneration: 1, + }) +} + +describe('external session V1 token canonical representation', () => { + it('rejects the signature aliases whose unused bits the JWT library tolerates', () => { + const token = signedToken() + // Liveness witness: the canonical representation reaches the claims. + expect(verifyExternalSessionToken(token)).toMatchObject({ userId, authGeneration: 1 }) + + const signatureAliases = jwsAliasVariants(token).filter( + variant => variant.segment === 'signature' && variant.kind === 'unused-bits' + ) + expect(signatureAliases.length).toBeGreaterThan(0) + for (const variant of signatureAliases) { + expect(variant.token).not.toBe(token) + expect( + verifyExternalSessionToken(variant.token), + `${variant.segment}/${variant.kind} alias` + ).toBeNull() + } + }) + + it('accepts the canonical JWS and rejects every generated alias and malformed representation', () => { + const token = signedToken() + expect(verifyExternalSessionToken(token)).toMatchObject({ userId, authGeneration: 1 }) + + const variants = jwsAliasVariants(token) + expect(variants.length).toBeGreaterThan(0) + expect(new Set(variants.map(variant => variant.token)).size).toBe(variants.length) + expect(new Set(variants.map(variant => variant.segment))).toEqual( + new Set(['header', 'payload', 'signature']) + ) + + for (const variant of variants) { + expect(variant.token).not.toBe(token) + expect( + verifyExternalSessionToken(variant.token), + `${variant.segment}/${variant.kind} alias` + ).toBeNull() + } + + // Pre-existing malformed forms stay rejected. They do not distinguish the + // canonical check by themselves, so they live in this mutation-killing test + // instead of a separate test that could never go red. + const malformed = [ + `${token}.`, + `${token}.extra`, + `.${token}`, + token.replace('.', '..'), + `${token}\n`, + ` ${token}`, + ] + for (const candidate of malformed) { + expect(candidate).not.toBe(token) + expect(verifyExternalSessionToken(candidate)).toBeNull() + } + }) +}) diff --git a/control-api/test/externalSessionAuthentication.test.ts b/control-api/test/externalSessionAuthentication.test.ts new file mode 100644 index 0000000000..7fc71eccdb --- /dev/null +++ b/control-api/test/externalSessionAuthentication.test.ts @@ -0,0 +1,229 @@ +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, + externalSessionUserIdForRateLimit, + 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, + minimumClientVersion: null, + enforceMinimumClient: false, + ...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', + authGeneration: 1, + }) +} + +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, + 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', + claims: { authGeneration: 1 }, + authorityContext: { contract: 'v1', authGeneration: 1 }, + }) + await expect( + authenticateExternalUserSession(token, { + purpose: 'verify', + policy: policy({ acceptV1: false, issueV1: false }), + }) + ).resolves.toEqual({ status: 'invalid', reason: 'session_contract_not_accepted' }) + }) + + it('rejects a signature-valid v1 token without a lifecycle generation', async () => { + const identity = validIdentity({ sid: '', sessionVersion: 0 }) + sessionMocks.validateLegacyUserSession.mockResolvedValue({ status: 'valid', identity }) + + await expect( + authenticateExternalUserSession(preGenerationV1Token(), { + purpose: 'protected', + policy: policy(), + }) + ).resolves.toEqual({ status: 'invalid', reason: 'invalid_representation' }) + expect(sessionMocks.validateLegacyUserSession).not.toHaveBeenCalled() + + // Liveness witness: a token with generation one, under the same policy + // and mocks, reaches legacy validation and authenticates. + await expect( + authenticateExternalUserSession(v1Token(), { purpose: 'protected', policy: policy() }) + ).resolves.toMatchObject({ status: 'authenticated', contract: 'v1' }) + expect(sessionMocks.validateLegacyUserSession).toHaveBeenCalledTimes(1) + }) + + 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: 'synthetic-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: '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() + }) +}) 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..cc5e6dd216 --- /dev/null +++ b/control-api/test/externalSessionCurrentness.realPostgres.integration.test.ts @@ -0,0 +1,295 @@ +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, + 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, '""')}"` +} + +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/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/externalSessionIssuance.exchangeRoleMismatch.test.ts b/control-api/test/externalSessionIssuance.exchangeRoleMismatch.test.ts new file mode 100644 index 0000000000..a0389a0de7 --- /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_state, u.lifecycle_version')) { + membershipQueries.push(values) + return { + rows: [{ role: lockedRole, lifecycle_state: 'active', lifecycle_version: 1 }], + rowCount: 1, + } + } + 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')) { + 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: 'synthetic-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/externalSessionSingleTouch.realPostgres.integration.test.ts b/control-api/test/externalSessionSingleTouch.realPostgres.integration.test.ts new file mode 100644 index 0000000000..2872f240fd --- /dev/null +++ b/control-api/test/externalSessionSingleTouch.realPostgres.integration.test.ts @@ -0,0 +1,174 @@ +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, + renewExternalUserSession, +} 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 +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 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 () => { + 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('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 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: authentication.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 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-renew', + acceptV1: true, + issueV1: true, + acceptV2: true, + issueV2: true, + renewV2: true, + switchCompatibility: true, + minimumClientVersion: null, + enforceMinimumClient: false, + } + + // 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`, + [issued.identity.sid] + ) + expect(touches.rows).toEqual([{ count: '1' }]) + }) +}) diff --git a/control-api/test/externalSessionTeamAuthorization.test.ts b/control-api/test/externalSessionTeamAuthorization.test.ts new file mode 100644 index 0000000000..565c234f9c --- /dev/null +++ b/control-api/test/externalSessionTeamAuthorization.test.ts @@ -0,0 +1,132 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import express 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' + +const session = vi.hoisted(() => ({ authenticateExternalUserSession: vi.fn() })) +const db = vi.hoisted(() => ({ query: vi.fn() })) + +vi.mock('../src/services/auth/externalSessionAuthentication.js', () => session) +vi.mock('../src/db.js', () => ({ pool: db })) + +// team_members.user_id and team_id are UUID columns, so the ids are UUIDs. +const USER_ID = '1d2e3f4a-5b6c-4d7e-8f9a-0b1c2d3e4f5a' +const TEAM_ID = '7e6d5c4b-3a29-4f18-8e7d-6c5b4a392817' +const OTHER_TEAM_ID = 'c0ffee00-1234-4abc-9def-0123456789ab' + +/** 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() { + const value = express() + value.get( + '/teams/:teamId', + requireValidExternalSessionToken, + requireExternalTeamParamMatch(), + requireExternalRole(['admin']), + (_req, res) => res.status(200).json({ ok: true }) + ) + value.use(clerumErrorHandler) + return value +} + +describe('external team authorization', () => { + beforeEach(() => { + vi.clearAllMocks() + session.authenticateExternalUserSession.mockResolvedValue({ + status: 'authenticated', + contract: 'v1', + claims: { + userId: USER_ID, + email: 'user@example.com', + teamId: TEAM_ID, + 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_ID, role: 'member' }], + rowCount: 1, + }) + + await request(app()) + .get(`/teams/${TEAM_ID}`) + .set('x-user-session-token', 'stale-admin-token') + .expect(403) + + expect(db.query).toHaveBeenCalledWith(expect.stringContaining("tm.status = 'active'"), [ + USER_ID, + TEAM_ID, + ]) + }) + + it('allows a current admin even when the legacy token team and role are stale', async () => { + session.authenticateExternalUserSession.mockResolvedValue({ + status: 'authenticated', + contract: 'v1', + claims: { + userId: USER_ID, + email: 'user@example.com', + teamId: OTHER_TEAM_ID, + role: 'member', + exp: Math.floor(Date.now() / 1000) + 3600, + }, + }) + db.query.mockResolvedValue({ + rows: [{ team_id: TEAM_ID, role: 'admin' }], + rowCount: 1, + }) + + await request(app()) + .get(`/teams/${TEAM_ID}`) + .set('x-user-session-token', 'stale-member-token') + .expect(200) + }) + + it('classifies a bare pool error as an outage at the database boundary', async () => { + db.query.mockRejectedValue(new Error('database unavailable')) + + await request(app()) + .get(`/teams/${TEAM_ID}`) + .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_ID, + TEAM_ID, + ]) + }) + + 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_ID}`) + .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_ID, + TEAM_ID, + ]) + }) +}) diff --git a/control-api/test/externalSessionV1CanonicalRevocation.realPostgres.integration.test.ts b/control-api/test/externalSessionV1CanonicalRevocation.realPostgres.integration.test.ts new file mode 100644 index 0000000000..87348af0e6 --- /dev/null +++ b/control-api/test/externalSessionV1CanonicalRevocation.realPostgres.integration.test.ts @@ -0,0 +1,235 @@ +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' +import { createHash, randomBytes, randomUUID } from 'node:crypto' +import { Pool } from 'pg' +import { GFS_READ_SCOPE } from '../src/auth/gfsToken.js' +import { type DbClient, initDb } from '../src/db.js' +import { + type ExternalGfsUserTokenInput, + signCurrentExternalGfsUserToken, +} from '../src/gfs/externalAuthority.js' +import type { EffectiveUserAccessPolicy } from '../src/services/access/userAccessPolicy.js' +import { authenticateExternalUserSession } from '../src/services/auth/externalSessionAuthentication.js' +import { revokeLegacyUserSession } from '../src/services/auth/userSessionService.js' +import { + signExternalSessionToken, + verifyExternalSessionToken, +} from '../src/utils/auth/externalSessionAuthToken.js' +import { jwsAliasVariants } from './helpers/jwsBase64UrlAliases.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: 'canonical-v1-token-test', + acceptV1: true, + issueV1: true, + acceptV2: true, + issueV2: true, + renewV2: true, + 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 tokenHash(token: string): string { + return createHash('sha256').update(token, 'utf8').digest('hex') +} + +describeRealPostgres('V1 canonical external session token on real PostgreSQL', () => { + const database = `control_api_v1_canonical_${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): Promise<{ userId: string; email: string }> { + const userId = 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', 1)`, + [userId, email, label] + ) + return { userId, email } + } + + function signFor(principal: { userId: string; email: string }): string { + return signExternalSessionToken({ + userId: principal.userId, + email: principal.email, + teamId: null, + role: 'member', + authGeneration: 1, + }) + } + + async function authenticate(token: string) { + return authenticateExternalUserSession(token, { purpose: 'protected', policy }) + } + + 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('denies every base64url alias of a revoked token and keeps an unrevoked token valid', async () => { + const principal = await createPrincipal('v1-canonical') + const token = signFor(principal) + const claims = verifyExternalSessionToken(token) + if (!claims) throw new Error('the canonical token did not verify') + + const variants = jwsAliasVariants(token) + expect(variants.length).toBeGreaterThan(0) + const canonicalHash = tokenHash(token) + for (const variant of variants) { + expect(variant.token).not.toBe(token) + // Revocation is keyed by the exact string hash, which is why each alias + // could escape the record before canonical verification. + expect(tokenHash(variant.token)).not.toBe(canonicalHash) + } + + // Liveness witness: canonical authentication reaches the database. + await expect(authenticate(token)).resolves.toMatchObject({ + status: 'authenticated', + contract: 'v1', + }) + + for (const variant of variants) { + await expect( + authenticate(variant.token), + `${variant.segment}/${variant.kind} alias` + ).resolves.toEqual({ + status: 'invalid', + reason: 'invalid_representation', + }) + } + + await expect(revokeLegacyUserSession(token, claims, 'logout')).resolves.toBe(true) + await expect(authenticate(token)).resolves.toEqual({ status: 'revoked', reason: 'logout' }) + + for (const variant of variants) { + await expect( + authenticate(variant.token), + `${variant.segment}/${variant.kind} alias` + ).resolves.toEqual({ + status: 'invalid', + reason: 'invalid_representation', + }) + } + + // Witness: revocation stays per exact token; another canonical token is valid. + await expect(authenticate(signFor(principal))).resolves.toMatchObject({ + status: 'authenticated', + contract: 'v1', + }) + }) + + it('refuses a GFS mint for a V1 token logged out after the session middleware authenticated it', async () => { + const principal = await createPrincipal('v1-gfs-mint') + const token = signFor(principal) + const claims = verifyExternalSessionToken(token) + if (!claims) throw new Error('the canonical token did not verify') + + // The session middleware's own call, taken before the logout commits. The + // route path cannot cover this window: after the logout the middleware + // itself refuses the token, with or without the mint's revocation read. + const authentication = await authenticate(token) + if (authentication.status !== 'authenticated') { + throw new Error(`the V1 token did not authenticate: ${authentication.status}`) + } + const input: ExternalGfsUserTokenInput = { + subject: principal.userId, + drive: 'user', + scopes: [GFS_READ_SCOPE], + } + + // Liveness witness: the same authentication mints while the token is live. + await expect(signCurrentExternalGfsUserToken(authentication, input)).resolves.toMatchObject({ + token: expect.any(String), + expiresInSeconds: expect.any(Number), + }) + + await expect(revokeLegacyUserSession(token, claims, 'logout')).resolves.toBe(true) + const revocation = await databasePool.query( + `SELECT 1 + FROM external_v1_session_revocations + WHERE token_hash = $1 AND user_id = $2 AND expires_at > clock_timestamp()`, + [tokenHash(token), principal.userId] + ) + expect(revocation.rowCount).toBe(1) + + await expect(signCurrentExternalGfsUserToken(authentication, input)).resolves.toBeNull() + }) +}) diff --git a/control-api/test/externalUserRateLimitPolicy.test.ts b/control-api/test/externalUserRateLimitPolicy.test.ts new file mode 100644 index 0000000000..b51347a0a7 --- /dev/null +++ b/control-api/test/externalUserRateLimitPolicy.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it, vi } from 'vitest' +import express from 'express' +import request from 'supertest' +import { requireAuthenticatedExternalUserRateLimitContext } from '../src/middleware/externalUserRateLimitPolicy.js' + +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.get('/protected', requireAuthenticatedExternalUserRateLimitContext, handler) + + await request(app).get('/protected').expect(401) + expect(handler).not.toHaveBeenCalled() + }) + + 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) => { + ;(req as express.Request & { externalAuth?: { userId: string } }).externalAuth = { + userId: 'server-user', + } + next() + }) + app.get('/protected', requireAuthenticatedExternalUserRateLimitContext, handler) + + await request(app).get('/protected').expect(204) + expect(handler).toHaveBeenCalledOnce() + }) +}) 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/gfs.externalAuthority.test.ts b/control-api/test/gfs.externalAuthority.test.ts index 2702aff960..3c5b07ba8d 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,269 @@ 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, + minimumClientVersion: null, + enforceMinimumClient: false, + } + 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: ['gfs.read' as const] } + + function mockSingleRowQuery(rows: Record[]) { + const query = vi.fn().mockResolvedValue({ rows, rowCount: rows.length }) + dbMock.withTransaction.mockImplementation(async work => work({ query })) + gfsTokenMock.signGfsToken.mockReturnValue({ + token: 'synthetic-signed-gfs-token', + expiresInSeconds: 600, + }) + return query + } + + // Only token_revoked === false (the EXISTS found no live revocation of this + // token) lets a V1 mint proceed; any other value refuses. + function expectV1RevocationRead(query: ReturnType) { + expect(query).toHaveBeenCalledOnce() + const sql = String(query.mock.calls[0]?.[0]) + expect(sql).toContain('FROM users') + expect(sql).toContain('external_v1_session_revocations') + expect(sql).toContain('AS token_revoked') + expect(sql).not.toContain('FOR UPDATE') + expect(query.mock.calls[0]?.[1]).toEqual([DESKTOP_USER_ID, 'token-hash']) + } + + it('signs the authenticated V1 generation with a single unlocked users and revocation read', async () => { + const query = mockSingleRowQuery([ + { lifecycle_state: 'active', lifecycle_version: 1, db_now: new Date(), token_revoked: false }, + ]) + + await expect(signCurrentExternalGfsUserToken(authentication, input)).resolves.toEqual({ + token: 'synthetic-signed-gfs-token', + expiresInSeconds: 600, + }) + + expectV1RevocationRead(query) + expect( + query.mock.calls.some(call => + String(call[0]).includes('external_user_session_security_epochs') + ) + ).toBe(false) + // Witness: the authenticated session's generation reaches the signed claims. + expect(gfsTokenMock.signGfsToken).toHaveBeenCalledWith( + expect.objectContaining({ + subject: DESKTOP_USER_ID, + authGeneration: 1, + principalType: 'user', + }) + ) + }) + + it('refuses a V1 token revoked after authentication', async () => { + const query = mockSingleRowQuery([ + { lifecycle_state: 'active', lifecycle_version: 1, db_now: new Date(), token_revoked: true }, + ]) + + await expect(signCurrentExternalGfsUserToken(authentication, input)).resolves.toBeNull() + + // Witness: the one read carrying the revocation lookup ran before the refusal. + expectV1RevocationRead(query) + expect(gfsTokenMock.signGfsToken).not.toHaveBeenCalled() + }) + + it('refuses a V1 session whose generation was bumped after authentication', async () => { + const query = mockSingleRowQuery([ + { lifecycle_state: 'active', lifecycle_version: 3, db_now: new Date(), token_revoked: false }, + ]) + + await expect(signCurrentExternalGfsUserToken(authentication, input)).resolves.toBeNull() + + // Witness: the signing read ran before the refusal. + expect(query).toHaveBeenCalledOnce() + expect(String(query.mock.calls[0]?.[0])).toContain('FROM users') + expect(gfsTokenMock.signGfsToken).not.toHaveBeenCalled() + }) + + describe('V2 session', () => { + const SID = '44444444-4444-4444-8444-444444444444' + const v2TokenClaims = { + sub: DESKTOP_USER_ID, + sid: SID, + jti: '55555555-5555-4555-8555-555555555555', + sv: 2, + ver: 2 as const, + typ: 'user_session' as const, + auth_time: 1, + amr: ['pwd'], + iat: 1, + exp: Math.floor(Date.now() / 1000) + 600, + } + const v2Authentication = { + status: 'authenticated' as const, + contract: 'v2' as const, + claims: { + userId: DESKTOP_USER_ID, + email: 'user@example.test', + teamId: null, + role: 'member' as const, + exp: v2TokenClaims.exp, + }, + authorityContext: { + contract: 'v2' as const, + userId: DESKTOP_USER_ID, + sid: SID, + jti: v2TokenClaims.jti, + sessionVersion: 2, + }, + tokenClaims: v2TokenClaims, + policy: { ...policy, acceptV2: true, issueV2: true, renewV2: true }, + } + const DB_NOW = new Date() + const currentRow = { + lifecycle_state: 'active', + lifecycle_version: 5, + db_now: DB_NOW, + revoked_at: null, + session_version: 2, + absolute_expires_at: new Date(DB_NOW.getTime() + 60 * 60 * 1000), + idle_expires_at: new Date(DB_NOW.getTime() + 10 * 60 * 1000), + } + + function expectSessionSnapshotRead(query: ReturnType) { + expect(query).toHaveBeenCalledOnce() + const sql = String(query.mock.calls[0]?.[0]) + expect(sql).toContain('FROM users') + expect(sql).toContain('external_user_sessions') + expect(sql).not.toContain('FOR UPDATE') + expect(query.mock.calls[0]?.[1]).toEqual([DESKTOP_USER_ID, SID]) + } + + it('signs the current user generation for a live session', async () => { + const query = mockSingleRowQuery([currentRow]) + + await expect(signCurrentExternalGfsUserToken(v2Authentication, input)).resolves.toEqual({ + token: 'synthetic-signed-gfs-token', + expiresInSeconds: 600, + }) + + expectSessionSnapshotRead(query) + expect(gfsTokenMock.signGfsToken).toHaveBeenCalledWith( + expect.objectContaining({ + subject: DESKTOP_USER_ID, + authGeneration: 5, + principalType: 'user', + }) + ) + }) + + it('refuses a session revoked after authentication', async () => { + const query = mockSingleRowQuery([{ ...currentRow, revoked_at: new Date() }]) + + await expect(signCurrentExternalGfsUserToken(v2Authentication, input)).resolves.toBeNull() + + expectSessionSnapshotRead(query) + expect(gfsTokenMock.signGfsToken).not.toHaveBeenCalled() + }) + + it('refuses a session whose version moved after authentication', async () => { + const query = mockSingleRowQuery([{ ...currentRow, session_version: 3 }]) + + await expect(signCurrentExternalGfsUserToken(v2Authentication, input)).resolves.toBeNull() + + expectSessionSnapshotRead(query) + expect(gfsTokenMock.signGfsToken).not.toHaveBeenCalled() + }) + + function expectExpiryColumnsRead(query: ReturnType) { + const sql = String(query.mock.calls[0]?.[0]) + expect(sql).toContain('s.absolute_expires_at') + expect(sql).toContain('s.idle_expires_at') + } + + it('refuses a session whose absolute expiry is the database time', async () => { + const query = mockSingleRowQuery([ + { ...currentRow, absolute_expires_at: new Date(DB_NOW.getTime()) }, + ]) + + await expect(signCurrentExternalGfsUserToken(v2Authentication, input)).resolves.toBeNull() + + // Witness: the one unlocked snapshot read, carrying both expiry columns, + // ran before the refusal. + expectSessionSnapshotRead(query) + expectExpiryColumnsRead(query) + expect(gfsTokenMock.signGfsToken).not.toHaveBeenCalled() + }) + + it('refuses a session whose idle expiry passed before the database time', async () => { + const query = mockSingleRowQuery([ + { ...currentRow, idle_expires_at: new Date(DB_NOW.getTime() - 1000) }, + ]) + + await expect(signCurrentExternalGfsUserToken(v2Authentication, input)).resolves.toBeNull() + + expectSessionSnapshotRead(query) + expectExpiryColumnsRead(query) + expect(gfsTokenMock.signGfsToken).not.toHaveBeenCalled() + }) + + it('refuses when the session row is gone', async () => { + const query = mockSingleRowQuery([]) + + await expect(signCurrentExternalGfsUserToken(v2Authentication, input)).resolves.toBeNull() + + expectSessionSnapshotRead(query) + expect(gfsTokenMock.signGfsToken).not.toHaveBeenCalled() + }) + }) + + 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(), + token_revoked: false, + }, + ], + 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/gfsReadiness.realPostgres.integration.test.ts b/control-api/test/gfsReadiness.realPostgres.integration.test.ts index a230e7648d..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)!.version + // 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,12 +77,37 @@ 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 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]) - await expect(assertDbReady(client)).rejects.toThrow(new RegExp(latestMigration)) + const removed = await client.query('DELETE FROM schema_migrations WHERE version = $1', [ + aliasedMigration.version, + ]) + 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) + client.release() + } + }) + + 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') + const removed = await client.query( + `DELETE FROM schema_migrations WHERE version = ANY($1::text[])`, + [acceptedReceipts] + ) + expect(removed.rowCount).toBe(1) + await expect(assertDbReady(client)).rejects.toMatchObject({ + message: `Control API database is not ready: missing migrations ${aliasedMigration.version}`, + }) } finally { await client.query('ROLLBACK').catch(() => undefined) client.release() diff --git a/control-api/test/googleAuth.test.ts b/control-api/test/googleAuth.test.ts new file mode 100644 index 0000000000..dfcdb88e6e --- /dev/null +++ b/control-api/test/googleAuth.test.ts @@ -0,0 +1,90 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + GoogleIdentityRejectedError, + GoogleTokenVerificationError, + 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, + }), + }) + + 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('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/helpers/jwsBase64UrlAliases.ts b/control-api/test/helpers/jwsBase64UrlAliases.ts new file mode 100644 index 0000000000..43d9ee033f --- /dev/null +++ b/control-api/test/helpers/jwsBase64UrlAliases.ts @@ -0,0 +1,77 @@ +const BASE64URL_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_' + +export type CanonicalJwsSegmentName = 'header' | 'payload' | 'signature' + +export type JwsAliasKind = 'unused-bits' | 'padding' | 'standard-alphabet' | 'ignored-character' + +export type JwsAliasVariant = Readonly<{ + segment: CanonicalJwsSegmentName + kind: JwsAliasKind + alias: string + token: string +}> + +type SegmentAlias = Readonly<{ alias: string; kind: JwsAliasKind }> + +const SEGMENT_NAMES: readonly CanonicalJwsSegmentName[] = ['header', 'payload', 'signature'] + +function decodeBase64Url(value: string): Buffer { + return Buffer.from(value, 'base64url') +} + +/** + * Return the other textual encodings that Node's lenient base64url decoder + * maps to the same bytes by varying only the unused low bits of the final + * character. + */ +function tailBitAliases(segment: string): SegmentAlias[] { + const remainder = segment.length % 4 + if (remainder !== 2 && remainder !== 3) return [] + const lastIndex = BASE64URL_ALPHABET.indexOf(segment.slice(-1)) + if (lastIndex < 0) { + throw new Error('cannot derive a base64url alias from a non-alphabet segment') + } + // A final group of two characters carries two significant bits and four + // unused bits; a final group of three carries four significant bits and two + // unused bits. Varying only the unused bits keeps the decoded bytes equal. + const unusedBits = remainder === 2 ? 4 : 2 + const highBits = lastIndex & ~((1 << unusedBits) - 1) + return Array.from({ length: (1 << unusedBits) - 1 }, (_, offset) => ({ + alias: segment.slice(0, -1) + BASE64URL_ALPHABET[highBits | (offset + 1)], + kind: 'unused-bits' as const, + })) +} + +export function canonicalBase64UrlAliases(segment: string): SegmentAlias[] { + const remainder = segment.length % 4 + const found = new Map() + found.set(`${segment.slice(0, 1)}\n${segment.slice(1)}`, 'ignored-character') + for (const alias of tailBitAliases(segment)) found.set(alias.alias, alias.kind) + // Padding is only a textual alias when the final group is incomplete. + if (remainder === 2) found.set(`${segment}==`, 'padding') + if (remainder === 3) found.set(`${segment}=`, 'padding') + if (segment.includes('-')) found.set(segment.replace(/-/g, '+'), 'standard-alphabet') + if (segment.includes('_')) found.set(segment.replace(/_/g, '/'), 'standard-alphabet') + found.delete(segment) + const decoded = decodeBase64Url(segment) + for (const alias of found.keys()) { + if (!decodeBase64Url(alias).equals(decoded)) { + throw new Error('generated alias does not decode to the presented segment bytes') + } + } + return [...found].map(([alias, kind]) => ({ alias, kind })) +} + +export function jwsAliasVariants(token: string): JwsAliasVariant[] { + const segments = token.split('.') + if (segments.length !== SEGMENT_NAMES.length) { + throw new Error('expected a JWS with exactly three segments') + } + return SEGMENT_NAMES.flatMap((segment, index) => + canonicalBase64UrlAliases(segments[index]!).map(alias => { + const parts = [...segments] + parts[index] = alias.alias + return { segment, kind: alias.kind, alias: alias.alias, token: parts.join('.') } + }) + ) +} diff --git a/control-api/test/helpers/onlineIndexCatalogMock.ts b/control-api/test/helpers/onlineIndexCatalogMock.ts new file mode 100644 index 0000000000..6bf5629003 --- /dev/null +++ b/control-api/test/helpers/onlineIndexCatalogMock.ts @@ -0,0 +1,55 @@ +import type { Mock } from 'vitest' +import { + PR1_ONLINE_INDEX_PLAN, + expectedIndexDefinition, +} from '../../src/migrations/pr1OnlineIndexPlan.js' + +type QueryResult = { rows: unknown[]; rowCount: number | null } + +/** + * Adds the minimum PostgreSQL catalog behavior to legacy initDb mocks: the + * D34 index-state read and the PR1 earlier-head guard, which finds no + * earlier-head object. The wrapped mock still records and owns every + * query/result outside these catalog boundaries. + */ +export function onlineIndexAwareQuery( + query: Mock +): (sql: string, values?: unknown[]) => Promise { + const states = new Map< + string, + { + schema_name: 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('/* pr1-earlier-head-guard */')) { + return { rows: [], rowCount: 0 } + } + 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, { + schema_name: 'public', + table_name: entry.table, + indisunique: Boolean(entry.unique), + indisvalid: true, + definition: expectedIndexDefinition(entry, 'public'), + }) + } + } + return result + } +} 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/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/http.errorHandler.k8sStatus.test.ts b/control-api/test/http.errorHandler.k8sStatus.test.ts index 1ca0fe60bc..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' /** @@ -21,6 +22,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 +70,27 @@ describe('clerumErrorHandler — K8s status forwarding (UT-2a)', () => { expect(res.status).toBe(404) }) + 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.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') + }) + /** * R4-M3: the service-layer K8sNotFoundError/K8sConflictError expose their * status ONLY on `.httpStatus` (no `.status` / `.statusCode` / `.code`). The 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..9e0a505b7a --- /dev/null +++ b/control-api/test/invitationAcceptedOnboarding.realPostgres.integration.test.ts @@ -0,0 +1,261 @@ +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 DbTransactionClient, type DbTransactionOptions, initDb } from '../src/db.js' +import { + acceptInvitationForEmail, + createInvitationForTeams, + requestProfilePasswordReset, + 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 +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 = ( + work: (db: DbTransactionClient) => Promise, + _txPool?: Pool, + options: DbTransactionOptions = {} + ): Promise => { + return actual.withTransaction(work, producerDatabase.pool!, options) + } + 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, + } + } + + async function storedPasswordHash(userId: string): Promise { + const stored = await databasePool.query<{ password_hash: string | null }>( + `SELECT password_hash FROM users WHERE id = $1`, + [userId] + ) + const row = stored.rows[0] + if (!row) throw new Error(`password hash read did not return user ${userId}`) + return row.password_hash + } + + 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 () => { + 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 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.toMatchObject({ + data: { accepted: true, userId, status: 'accepted', replay: true }, + }) + + const passwordBeforeRejection = await storedPasswordHash(userId) + expect(passwordBeforeRejection).toEqual(expect.any(String)) + + // Same setter, real invitation id: a member invitation is not a + // password-reset capability, so the reset path must refuse before it + // writes anything for this user. + await expect( + setInvitationPasswordForEmail(invitation.email, invitation.id, 'Replay-Password-1!', userId) + ).resolves.toEqual({ error: 'not_found' }) + await expect(storedPasswordHash(userId)).resolves.toBe(passwordBeforeRejection) + + // Positive witness for the same setter: a real password-reset invitation + // for this user changes the hash, proving the refusal above is not an + // unreachable path that can never write. + await requestProfilePasswordReset(invitation.email) + const resetInvitation = 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`, + [invitation.email] + ) + const resetInvitationId = resetInvitation.rows[0]?.id + if (!resetInvitationId) throw new Error('password-reset witness invitation was not created') + await expect( + setInvitationPasswordForEmail( + invitation.email, + resetInvitationId, + 'Witness-Password-1!', + userId + ) + ).resolves.toMatchObject({ data: { passwordUpdated: true } }) + await expect(storedPasswordHash(userId)).resolves.not.toBe(passwordBeforeRejection) + } + ) + + 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' } }) + }) +}) 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..3271ff8468 --- /dev/null +++ b/control-api/test/invitationFirstPasswordIdentity.realPostgres.integration.test.ts @@ -0,0 +1,241 @@ +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, + minimumClientVersion: null, + enforceMinimumClient: false, +} 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/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[] 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..3aece0bdac --- /dev/null +++ b/control-api/test/legacyPasswordEpochBackfill.realPostgres.integration.test.ts @@ -0,0 +1,192 @@ +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' +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, '""')}"` +} + +async function removeLegacyEpochMigrationReceipts(databasePool: Pool): Promise { + const removed = await databasePool.query( + `DELETE FROM schema_migrations + WHERE version = '0138_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 }) { + 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 () => { + 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)}`) + } + } finally { + 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 removeLegacyEpochMigrationReceipts(databasePool) + 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 = '0138_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 removeLegacyEpochMigrationReceipts(databasePool) + await initDb({ connect: () => databasePool.connect() }) + await removeLegacyEpochMigrationReceipts(databasePool) + 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') + }) +}) diff --git a/control-api/test/legacySecurityCutoff.test.ts b/control-api/test/legacySecurityCutoff.test.ts new file mode 100644 index 0000000000..ae198cf93a --- /dev/null +++ b/control-api/test/legacySecurityCutoff.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'vitest' +import { legacySecurityCutoffRejects } from '../src/services/auth/legacySecurityCutoff.js' + +describe('V1 security cutoff rule', () => { + it('rejects an iat at or before the cutoff instant and accepts the next second', () => { + const cutoff = new Date('2030-03-17T17:46:40.250Z') + const second = Math.floor(cutoff.getTime() / 1000) + + expect(legacySecurityCutoffRejects(second - 1, cutoff)).toBe(true) + expect(legacySecurityCutoffRejects(second, cutoff)).toBe(true) + expect(legacySecurityCutoffRejects(second + 1, cutoff)).toBe(false) + }) + + it('reads a text timestamp the same way as a Date', () => { + expect(legacySecurityCutoffRejects(1_900_000_000, '2030-03-17T17:46:40.000Z')).toBe(true) + expect(legacySecurityCutoffRejects(1_900_000_001, '2030-03-17T17:46:40.000Z')).toBe(false) + }) + + it('applies no cutoff when the user has none', () => { + expect(legacySecurityCutoffRejects(1, null)).toBe(false) + expect(legacySecurityCutoffRejects(1, undefined)).toBe(false) + // Liveness witness: the same iat is rejected by a cutoff at that second. + expect(legacySecurityCutoffRejects(1, new Date(1000))).toBe(true) + }) + + it('fails loudly on a cutoff that is not a valid instant', () => { + expect(() => legacySecurityCutoffRejects(1, new Date(Number.NaN))).toThrow( + 'invalid V1 security cutoff' + ) + expect(() => legacySecurityCutoffRejects(1, 'not-a-timestamp')).toThrow( + 'invalid V1 security cutoff' + ) + }) +}) diff --git a/control-api/test/legacySecurityCutoffSites.realPostgres.integration.test.ts b/control-api/test/legacySecurityCutoffSites.realPostgres.integration.test.ts new file mode 100644 index 0000000000..cc556eb95e --- /dev/null +++ b/control-api/test/legacySecurityCutoffSites.realPostgres.integration.test.ts @@ -0,0 +1,293 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { createHash, randomBytes, randomUUID } from 'node:crypto' +import { Pool } from 'pg' +import { initDb } from '../src/db.js' +import type { AuthClaims } from '../src/profileTypes.js' +import type { EffectiveUserAccessPolicy } from '../src/services/access/userAccessPolicy.js' +import type { ExternalSessionAuthentication } from '../src/services/auth/externalSessionAuthentication.js' +import { observeExternalSessionCurrentness } from '../src/services/auth/externalSessionCurrentnessObserver.js' +import { issueExternalUserSession } from '../src/services/auth/externalSessionIssuance.js' +import { + validateExternalSessionAuthorityContext, + validateLegacyUserSession, +} from '../src/services/auth/userSessionService.js' +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 +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, '""')}"` +} + +// Every comparator case keeps the token's lifecycle generation current and the token +// unrevoked, so the security cutoff is the only reason any site can reject it. +describeRealPostgres('V1 security cutoff at every comparator on real PostgreSQL', () => { + const database = `control_api_v1_cutoff_sites_${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, lifecycle_state, lifecycle_version) + VALUES ($1, $2, $3, 'active', 1)`, + [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 databaseSecond(offsetSeconds: number): Promise { + const result = await databasePool.query( + `SELECT EXTRACT(EPOCH FROM date_trunc('second', clock_timestamp()))::bigint + $1::bigint + AS second`, + [offsetSeconds] + ) + return Number((result.rows[0] as { second: string }).second) + } + + async function setCutoff(userId: string, second: number): Promise { + 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)) + ON CONFLICT (user_id) DO UPDATE SET valid_after = EXCLUDED.valid_after`, + [userId, second] + ) + } + + function legacyToken(source: { userId: string; email: string }, iat: number) { + const token = signExternalSessionToken({ + userId: source.userId, + email: source.email, + teamId: null, + role: 'member', + authGeneration: 1, + iat, + }) + const claims = verifyExternalSessionToken(token) + if (!claims || claims.iat !== iat) throw new Error('test token does not carry the chosen iat') + return { token, claims } + } + + function v1Context(token: string, claims: AuthClaims) { + return { + contract: 'v1' as const, + userId: claims.userId, + tokenHash: createHash('sha256').update(token, 'utf8').digest('hex'), + issuedAt: claims.iat!, + authGeneration: 1, + } + } + + function authenticated( + token: string, + claims: AuthClaims + ): Extract { + return { + status: 'authenticated', + contract: 'v1', + claims, + tokenClaims: claims, + authorityContext: v1Context(token, claims), + policy: legacyPolicy, + } + } + + 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('rejects the cutoff second and accepts the next one in request validation and authority', async () => { + const user = await principal('cutoff-validators') + const cutoff = await databaseSecond(-60) + await setCutoff(user.userId, cutoff) + const atCutoff = legacyToken(user, cutoff) + const afterCutoff = legacyToken(user, cutoff + 1) + + await expect( + validateLegacyUserSession(atCutoff.token, atCutoff.claims, { db: databasePool }) + ).resolves.toEqual({ status: 'revoked', reason: 'security_event' }) + await expect( + validateLegacyUserSession(afterCutoff.token, afterCutoff.claims, { db: databasePool }) + ).resolves.toMatchObject({ status: 'valid' }) + + await expect( + validateExternalSessionAuthorityContext(v1Context(atCutoff.token, atCutoff.claims), { + db: databasePool, + }) + ).resolves.toEqual({ status: 'revoked', reason: 'security_event' }) + await expect( + validateExternalSessionAuthorityContext(v1Context(afterCutoff.token, afterCutoff.claims), { + db: databasePool, + }) + ).resolves.toMatchObject({ status: 'valid' }) + }) + + it('denies currentness for the cutoff second and keeps the next one current', async () => { + const user = await principal('cutoff-currentness') + const cutoff = await databaseSecond(-60) + await setCutoff(user.userId, cutoff) + const atCutoff = legacyToken(user, cutoff) + const afterCutoff = legacyToken(user, cutoff + 1) + + await expect( + observeExternalSessionCurrentness(authenticated(atCutoff.token, atCutoff.claims), { + db: databasePool, + }) + ).resolves.toEqual({ status: 'denied', reason: 'legacy_security_cutoff' }) + await expect( + observeExternalSessionCurrentness(authenticated(afterCutoff.token, afterCutoff.claims), { + db: databasePool, + }) + ).resolves.toEqual({ status: 'current' }) + }) + + it('refuses issuance while the database second is at or before the cutoff', async () => { + const user = await principal('cutoff-issuance') + const issue = () => + issueExternalUserSession( + { + contract: 'v1', + userId: user.userId, + email: user.email, + teamId: user.teamId, + role: 'member', + authenticationMethods: ['password'], + }, + { db: databasePool, policy: legacyPolicy } + ) + + // An hour ahead, so the cutoff cannot pass before issuance reads the clock. + await setCutoff(user.userId, await databaseSecond(3_600)) + await expect(issue()).rejects.toMatchObject({ + name: 'ExternalSessionIssuanceUnavailableError', + code: 'session_issuance_temporarily_unavailable', + status: 503, + }) + + const pastCutoff = await databaseSecond(-1) + await setCutoff(user.userId, pastCutoff) + const issued = await issue() + const claims = verifyExternalSessionToken(issued.token) + expect(claims?.userId).toBe(user.userId) + expect(claims!.iat!).toBeGreaterThan(pastCutoff) + }) + + // PostgreSQL stores timestamps that a JavaScript Date cannot hold. No writer + // produces them; the TypeScript comparators must still refuse rather than + // compare against NaN and accept. + it('fails closed on a stored cutoff outside the JavaScript Date range', async () => { + const user = await principal('cutoff-out-of-range') + const token = legacyToken(user, await databaseSecond(-60)) + const issue = () => + issueExternalUserSession( + { + contract: 'v1', + userId: user.userId, + email: user.email, + teamId: user.teamId, + role: 'member', + authenticationMethods: ['password'], + }, + { db: databasePool, policy: legacyPolicy } + ) + + for (const stored of ['infinity', '-infinity', '294000-01-01 00:00:00+00']) { + await databasePool.query( + `INSERT INTO external_user_session_security_epochs(user_id, valid_after, reason, updated_at) + VALUES ($1, $2::timestamptz, 'cutoff_site_test', now()) + ON CONFLICT (user_id) DO UPDATE SET valid_after = EXCLUDED.valid_after`, + [user.userId, stored] + ) + await expect( + validateLegacyUserSession(token.token, token.claims, { db: databasePool }), + stored + ).rejects.toThrow('invalid V1 security cutoff') + await expect( + validateExternalSessionAuthorityContext(v1Context(token.token, token.claims), { + db: databasePool, + }), + stored + ).rejects.toThrow('invalid V1 security cutoff') + await expect( + observeExternalSessionCurrentness(authenticated(token.token, token.claims), { + db: databasePool, + }), + stored + ).resolves.toMatchObject({ status: 'unavailable' }) + await expect(issue(), stored).rejects.toThrow('invalid V1 security cutoff') + } + + // Liveness witness: with a finite past cutoff the same token and user pass + // every comparator above. + await setCutoff(user.userId, await databaseSecond(-120)) + await expect( + validateLegacyUserSession(token.token, token.claims, { db: databasePool }) + ).resolves.toMatchObject({ status: 'valid' }) + await expect( + validateExternalSessionAuthorityContext(v1Context(token.token, token.claims), { + db: databasePool, + }) + ).resolves.toMatchObject({ status: 'valid' }) + await expect( + observeExternalSessionCurrentness(authenticated(token.token, token.claims), { + db: databasePool, + }) + ).resolves.toEqual({ status: 'current' }) + await expect(issue()).resolves.toMatchObject({ token: expect.any(String) }) + }) +}) 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..ab70542105 --- /dev/null +++ b/control-api/test/legacySessionExchangeRace.realPostgres.integration.test.ts @@ -0,0 +1,688 @@ +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' +import { type DbTransactionClient, type DbTransactionOptions, initDb } 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 { + createUserSession, + revokeAllUserSessions, + revokeLegacyUserSession, + revokeUserSession, + validateLegacyUserSession, + validateUserSessionClaims, +} from '../src/services/auth/userSessionService.js' +import { verifyUserPassword } from '../src/services/directory/login.js' +import { + setInvitationPasswordForUser, + updateUserPassword, +} from '../src/services/directory/membership.js' +import { + signExternalSessionToken, + 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.sequential : describe.skip +const runtimeRoles = [ + 'control_api_runtime', + 'trace_maintenance_runtime', + '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 = ( + work: (db: DbTransactionClient) => Promise, + _txPool?: Pool, + options: DbTransactionOptions = {} + ): Promise => { + return actual.withTransaction(work, producerDatabase.pool!, options) + } + return { ...actual, pool, withTransaction } +}) + +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 + const currentPassword = 'Legacy-Password-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 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, + role: TeamRole = 'admin' + ): Promise<{ + userId: string + teamId: string + email: string + token: string + claims: AuthClaims + }> { + 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, $4)`, + [userId, email, label, passwordHash] + ) + 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, $3, 'active')`, + [teamId, userId, role] + ) + const token = signExternalSessionToken({ + userId, + email, + teamId, + role, + 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 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 () => { + 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 + 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() + } + }) + + 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 => { + const source = await principal(`invalidation-first-${label.replaceAll(' ', '-')}`) + const producer = await passwordProducer(label, source) + const epochGate = await holdSecurityEpoch(source.userId) + const exchangeStarted = deferred() + try { + const epochGatePid = await backendPid(epochGate) + const producerWork = producer.run() + const producerPid = await waitForBlockedBy(epochGatePid) + + const exchange = exchangeLegacyExternalUserSession( + { + token: source.token, + claims: source.claims, + userId: source.userId, + email: source.email, + teamId: source.teamId, + role: 'admin', + }, + { + 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, 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, + role: 'admin', + }, + { policy } + ) + ).resolves.toEqual({ status: 'invalid_session' }) + await expect( + verifyUserPassword({ + userId: source.userId, + email: source.email, + password: producer.newPassword, + }) + ).resolves.toBe(true) + } finally { + await epochGate.query('ROLLBACK').catch(() => undefined) + epochGate.release() + } + } + ) + + 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( + { + token: source.token, + claims: source.claims, + userId: source.userId, + email: source.email, + teamId: source.teamId, + role: 'admin', + }, + { + 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 producerWork = producer.run() + await waitForBlockedBy(lockedExchange.pid) + releaseExchange.resolve() + const issued = await exchange + 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) + 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' }) + await expect( + exchangeLegacyExternalUserSession( + { + token: source.token, + claims: source.claims, + userId: source.userId, + email: source.email, + teamId: source.teamId, + role: 'admin', + }, + { policy } + ) + ).resolves.toEqual({ status: 'invalid_session' }) + await expect( + verifyUserPassword({ + userId: source.userId, + email: source.email, + password: producer.newPassword, + }) + ).resolves.toBe(true) + } finally { + releaseExchange.resolve() + } + } + ) + + 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, + role: 'admin', + }, + { + 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 revokeAll = revokeAllUserSessions(source.userId, 'user_revoked_all') + await waitForBlockedBy(lockedExchange.pid) + 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, + role: 'admin', + }, + { + 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) + 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, + role: 'admin', + }, + { policy } + ) + ).resolves.toEqual({ status: 'invalid_session' }) + } finally { + await gate.query('ROLLBACK').catch(() => undefined) + gate.release() + } + }) + + 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() + try { + await rollback.query('BEGIN') + await revokeAllUserSessions(source.userId, 'rollback', rollback) + 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) + 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 + FROM external_user_session_security_epochs + WHERE user_id = $1`, + [source.userId] + ) + expect(epoch.rows[0]!.valid_after.getTime()).toBeGreaterThan( + firstEpoch.rows[0]!.valid_after.getTime() + ) + 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/legacyV1Generation.realPostgres.integration.test.ts b/control-api/test/legacyV1Generation.realPostgres.integration.test.ts new file mode 100644 index 0000000000..d436236cf9 --- /dev/null +++ b/control-api/test/legacyV1Generation.realPostgres.integration.test.ts @@ -0,0 +1,394 @@ +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, 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 { + revokeAllUserSessions, + revokeLegacyUserSession, + 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('./fixtures/legacyV1Base/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 + +// 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 pinned deployed-base 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}` + 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 + )`) +} + +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( + 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 | string + 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)) + expect(successorClaims.exp - successorClaims.iat).toBeGreaterThan(0) + expect(successorClaims.exp - successorClaims.iat).toBe(60 * 60 * 24 * 7) + 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' }) + 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(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( + validateLegacyUserSession(first, firstClaims, { db: databasePool }) + ).resolves.toEqual({ status: 'revoked', reason: 'logout' }) + await expect( + validateLegacyUserSession(successor, successorClaims, { db: databasePool }) + ).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({ + 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..bd6c5ba18e --- /dev/null +++ b/control-api/test/legacyV1SessionGeneration.test.ts @@ -0,0 +1,237 @@ +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('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) + return { rows: [{ token_hash: values[0] }], rowCount: 1 } + } + if (sql.includes('FROM users') && sql.includes('FOR NO KEY 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 { + 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 +} + +// 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') + 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', + }) + }) +}) + +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/membershipInvitationIdempotency.realPostgres.integration.test.ts b/control-api/test/membershipInvitationIdempotency.realPostgres.integration.test.ts new file mode 100644 index 0000000000..d86377d432 --- /dev/null +++ b/control-api/test/membershipInvitationIdempotency.realPostgres.integration.test.ts @@ -0,0 +1,665 @@ +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 { getInvitationRecordById } from '../src/services/directory/invitationStore.js' +import { + acceptInvitationById, + acceptInvitationForEmail, + acceptPendingMembershipInvitation, + 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() + 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 + +type InvitationPurpose = 'member_invitation' | 'password_reset' | 'admin_desktop_access' +type InvitationStatus = 'draft' | 'pending' | 'accepted' | 'revoked' + +type InvitationFixture = { + id: string + email: string + token: string +} + +type InvitationStateRow = { + status: InvitationStatus + accepted_user_id: string | null + accepted_at: Date | null +} + +type MembershipRow = { + team_id: string + role: string + status: string +} + +describeRealPostgres('membership invitation acceptance idempotency on real PostgreSQL', () => { + const database = `control_api_invitation_acceptance_${randomBytes(6).toString('hex')}` + let adminPool: Pool + let teamId: string + + async function createInvitation(options: { + label: string + email?: string + purpose?: InvitationPurpose + status?: InvitationStatus + expiresAt?: Date + }): Promise { + const email = options.email ?? `${options.label}-${randomUUID()}@example.test` + const token = `token-${randomUUID()}` + const inserted = await holder.pool.query<{ id: string }>( + `INSERT INTO invitations(email, role, token, status, purpose, expires_at) + VALUES ($1, 'member', $2, $3, $4, $5) + RETURNING id::text AS id`, + [ + email, + token, + options.status ?? 'pending', + options.purpose ?? 'member_invitation', + options.expiresAt ?? new Date(Date.now() + 60 * 60 * 1000), + ] + ) + const id = inserted.rows[0]!.id + await holder.pool.query( + `INSERT INTO invitation_teams(invitation_id, team_id, role) + VALUES ($1, $2, 'member')`, + [id, teamId] + ) + return { id, email, token } + } + + async function invitationState(invitationId: string): Promise { + const result = await holder.pool.query( + `SELECT status, accepted_user_id, accepted_at + FROM invitations + WHERE id = $1`, + [invitationId] + ) + return result.rows[0]! + } + + async function memberships(userId: string): Promise { + const result = await holder.pool.query( + `SELECT team_id, role, status + FROM team_members + WHERE user_id = $1 + ORDER BY team_id`, + [userId] + ) + return result.rows + } + + async function invitationUserCount(email: string): Promise { + const result = await holder.pool.query<{ count: number }>( + `SELECT count(*)::int AS count FROM users WHERE email = $1`, + [email] + ) + return result.rows[0]!.count + } + + async function membershipWriteCount(): Promise { + const result = await holder.pool.query<{ writes: number }>( + `SELECT writes FROM team_membership_write_probe` + ) + return result.rows[0]!.writes + } + + async function resetMembershipWriteProbe(): Promise { + await holder.pool.query(`UPDATE team_membership_write_probe SET writes = 0`) + } + + // Acceptance takes the user before the invitation. The first acceptance + // inserts the invitee and queues on the barrier's invitation row lock; the + // second queues on that user (its insert or its lock) behind the first. + // Seeing both waiting before the barrier is released proves a real overlap + // rather than two calls that happened to run one after the other. + async function waitForQueuedAcceptances(): Promise { + const deadline = Date.now() + 5_000 + while (Date.now() < deadline) { + const result = await holder.pool.query<{ invitation: number; user: number }>( + `SELECT count(*) FILTER (WHERE query LIKE '%FOR UPDATE OF i%')::int AS invitation, + count(*) FILTER ( + WHERE query LIKE '%INSERT INTO users%' + OR query LIKE '%lifecycle_version FROM users WHERE id = $1 FOR NO KEY UPDATE%' + )::int AS user + FROM pg_stat_activity + WHERE datname = current_database() + AND wait_event_type = 'Lock'` + ) + if (result.rows[0]!.invitation >= 1 && result.rows[0]!.user >= 1) return + await new Promise(resolve => setTimeout(resolve, 20)) + } + throw new Error( + 'expected one acceptance waiting on the invitation row and one waiting on the user' + ) + } + + 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, 'Idempotency Team')`, [teamId]) + // Test-only write observer on the scratch database: every membership + // INSERT/UPDATE increments one counter, so a concurrent replay that + // re-applies an identical UPSERT is visible even when the final row is + // unchanged. + await holder.pool.query(` + CREATE TABLE team_membership_write_probe (writes integer NOT NULL) + `) + await holder.pool.query(`INSERT INTO team_membership_write_probe(writes) VALUES (0)`) + await holder.pool.query(` + CREATE FUNCTION record_team_membership_write() RETURNS trigger + LANGUAGE plpgsql AS $$ + BEGIN + UPDATE team_membership_write_probe SET writes = writes + 1; + RETURN NULL; + END; + $$ + `) + await holder.pool.query(` + CREATE TRIGGER team_membership_write_probe_trigger + AFTER INSERT OR UPDATE ON team_members + FOR EACH ROW EXECUTE FUNCTION record_team_membership_write() + `) + }, 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('accepts a pending member invitation and creates one membership', async () => { + const invitation = await createInvitation({ label: 'first-accept' }) + + const accepted = await acceptInvitationForEmail( + invitation.email, + invitation.token, + invitation.id + ) + + expect(accepted).toMatchObject({ + data: { + accepted: true, + id: invitation.id, + email: invitation.email, + role: 'member', + status: 'accepted', + }, + }) + if (!('data' in accepted)) throw new Error('first acceptance did not return a result') + const userId = String(accepted.data.userId) + + const state = await invitationState(invitation.id) + expect(state.status).toBe('accepted') + expect(state.accepted_user_id).toBe(userId) + expect(state.accepted_at).toBeInstanceOf(Date) + + await expect(memberships(userId)).resolves.toEqual([ + { team_id: teamId, role: 'member', status: 'active' }, + ]) + }) + + it('replays the same result for a repeated acceptance without a second membership', async () => { + const invitation = await createInvitation({ label: 'replay' }) + const first = await acceptInvitationForEmail(invitation.email, invitation.token, invitation.id) + if (!('data' in first)) throw new Error('first acceptance did not return a result') + const userId = String(first.data.userId) + const firstState = await invitationState(invitation.id) + await expect(memberships(userId)).resolves.toEqual([ + { team_id: teamId, role: 'member', status: 'active' }, + ]) + // A later role change must survive the replay; re-applying the invitation + // memberships would reset it to the invited role. + await holder.pool.query( + `UPDATE team_members SET role = 'inviter' WHERE team_id = $1 AND user_id = $2`, + [teamId, userId] + ) + + const replay = await acceptInvitationForEmail(invitation.email, invitation.token, invitation.id) + + // The replay returns the user row it locked first (AUTH-5), which is still active. + expect(replay).toEqual({ + data: { ...first.data, replay: true }, + lockedUser: { lifecycleState: 'active', lifecycleVersion: expect.any(String) }, + }) + const replayState = await invitationState(invitation.id) + expect(replayState.accepted_user_id).toBe(userId) + const firstAcceptedAt = firstState.accepted_at + if (!(firstAcceptedAt instanceof Date)) { + throw new Error('first acceptance did not record an accepted_at timestamp') + } + expect(replayState.accepted_at?.toISOString()).toBe(firstAcceptedAt.toISOString()) + await expect(memberships(userId)).resolves.toEqual([ + { team_id: teamId, role: 'inviter', status: 'active' }, + ]) + }) + + it('refuses to replay an accepted invitation for a different email', async () => { + const invitation = await createInvitation({ label: 'accepted-other-email' }) + const accepted = await acceptInvitationForEmail( + invitation.email, + invitation.token, + invitation.id + ) + if (!('data' in accepted)) throw new Error('first acceptance did not return a result') + const userId = String(accepted.data.userId) + const acceptedState = await invitationState(invitation.id) + + await expect( + acceptInvitationForEmail('intruder@example.test', invitation.token, invitation.id) + ).resolves.toEqual({ error: 'forbidden' }) + + await expect(memberships(userId)).resolves.toEqual([ + { team_id: teamId, role: 'member', status: 'active' }, + ]) + const rejectedState = await invitationState(invitation.id) + expect(rejectedState.accepted_user_id).toBe(userId) + const acceptedAt = acceptedState.accepted_at + if (!(acceptedAt instanceof Date)) { + throw new Error('acceptance did not record an accepted_at timestamp') + } + expect(rejectedState.accepted_at?.toISOString()).toBe(acceptedAt.toISOString()) + }) + + 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 }>( + `INSERT INTO users(email, name) + VALUES ($1, 'Previous Owner') + RETURNING id::text AS id`, + [`previous-${randomUUID()}@example.test`] + ) + const previousOwnerId = other.rows[0]!.id + const owner = await holder.pool.query<{ id: string }>( + `INSERT INTO users(email, name) + VALUES ($1, 'Current Owner') + RETURNING id::text AS id`, + [invitation.email] + ) + const ownerId = owner.rows[0]!.id + await holder.pool.query( + `UPDATE invitations + SET status = 'accepted', + accepted_user_id = $2, + accepted_at = NOW() + WHERE id = $1`, + [invitation.id, previousOwnerId] + ) + + await expect( + acceptInvitationForEmail(invitation.email, invitation.token, invitation.id) + ).resolves.toEqual({ error: 'forbidden' }) + await expect(invitationState(invitation.id)).resolves.toMatchObject({ + status: 'accepted', + accepted_user_id: previousOwnerId, + }) + await expect(memberships(ownerId)).resolves.toEqual([]) + + // Positive witness for the empty-membership assertion above: the same + // membership observation path returns a row after a pending acceptance for + // this user, so an empty read is not a query that can never see rows. + const witnessInvitation = await createInvitation({ + label: 'owner-membership-witness', + email: invitation.email, + }) + await expect( + 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(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, + }) + }) + + it('refuses an expired pending invitation until it is no longer expired', async () => { + const invitation = await createInvitation({ + label: 'expired', + expiresAt: new Date(Date.now() - 60 * 1000), + }) + + await expect( + acceptInvitationForEmail(invitation.email, invitation.token, invitation.id) + ).resolves.toEqual({ error: 'expired' }) + await expect(invitationState(invitation.id)).resolves.toMatchObject({ + status: 'pending', + accepted_user_id: null, + }) + await expect(invitationUserCount(invitation.email)).resolves.toBe(0) + + await holder.pool.query( + `UPDATE invitations SET expires_at = NOW() + INTERVAL '1 hour' WHERE id = $1`, + [invitation.id] + ) + const accepted = await acceptInvitationForEmail( + invitation.email, + invitation.token, + invitation.id + ) + expect(accepted).toMatchObject({ data: { accepted: true } }) + if (!('data' in accepted)) throw new Error('witness acceptance did not return a result') + await expect(memberships(String(accepted.data.userId))).resolves.toHaveLength(1) + }) + + it('refuses a revoked invitation until it is explicitly restored to pending', async () => { + const invitation = await createInvitation({ label: 'revoked', status: 'revoked' }) + + await expect( + acceptInvitationForEmail(invitation.email, invitation.token, invitation.id) + ).resolves.toEqual({ error: 'not_pending' }) + await expect(invitationState(invitation.id)).resolves.toMatchObject({ + status: 'revoked', + accepted_user_id: null, + }) + await expect(invitationUserCount(invitation.email)).resolves.toBe(0) + + await holder.pool.query(`UPDATE invitations SET status = 'pending' WHERE id = $1`, [ + invitation.id, + ]) + const accepted = await acceptInvitationForEmail( + invitation.email, + invitation.token, + invitation.id + ) + expect(accepted).toMatchObject({ data: { accepted: true } }) + if (!('data' in accepted)) throw new Error('witness acceptance did not return a result') + await expect(memberships(String(accepted.data.userId))).resolves.toHaveLength(1) + }) + + it('refuses a password-reset invitation until its purpose is a member invitation', async () => { + const invitation = await createInvitation({ + label: 'password-reset-purpose', + purpose: 'password_reset', + }) + + await expect( + acceptInvitationForEmail(invitation.email, invitation.token, invitation.id) + ).resolves.toEqual({ error: 'not_pending' }) + await expect(invitationState(invitation.id)).resolves.toMatchObject({ + status: 'pending', + accepted_user_id: null, + }) + await expect(invitationUserCount(invitation.email)).resolves.toBe(0) + + await holder.pool.query(`UPDATE invitations SET purpose = 'member_invitation' WHERE id = $1`, [ + invitation.id, + ]) + const accepted = await acceptInvitationForEmail( + invitation.email, + invitation.token, + invitation.id + ) + expect(accepted).toMatchObject({ data: { accepted: true } }) + if (!('data' in accepted)) throw new Error('witness acceptance did not return a result') + await expect(memberships(String(accepted.data.userId))).resolves.toHaveLength(1) + }) + + it('refuses a password-reset invitation addressed by id', async () => { + const invitation = await createInvitation({ + label: 'by-id-password-reset', + purpose: 'password_reset', + }) + + await expect(acceptInvitationById(invitation.email, invitation.id)).resolves.toEqual({ + error: 'not_pending', + }) + await expect(invitationState(invitation.id)).resolves.toMatchObject({ + status: 'pending', + accepted_user_id: null, + }) + await expect(invitationUserCount(invitation.email)).resolves.toBe(0) + + await holder.pool.query( + `UPDATE invitations SET purpose = 'admin_desktop_access' WHERE id = $1`, + [invitation.id] + ) + const accepted = await acceptInvitationById(invitation.email, invitation.id) + expect(accepted).toMatchObject({ data: { accepted: true, status: 'accepted' } }) + if (!('data' in accepted)) throw new Error('witness acceptance did not return a result') + await expect(memberships(String(accepted.data.userId))).resolves.toEqual([ + { team_id: teamId, role: 'member', status: 'active' }, + ]) + }) + + it('refuses an unknown invitation and still accepts a real one', async () => { + await expect( + acceptInvitationForEmail('unknown@example.test', `missing-${randomUUID()}`, randomUUID()) + ).resolves.toEqual({ error: 'not_found' }) + + const invitation = await createInvitation({ label: 'unknown-witness' }) + const accepted = await acceptInvitationForEmail( + invitation.email, + invitation.token, + invitation.id + ) + expect(accepted).toMatchObject({ data: { accepted: true } }) + if (!('data' in accepted)) throw new Error('witness acceptance did not return a result') + 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' }) + }) + + // INV-6: the consuming UPDATE's own predicates refuse a revoked, expired or + // password-reset row even when no caller refusal ran first; both callers + // refuse those rows earlier, so only a direct call observes the predicates + // (status = 'pending', purpose <> 'password_reset', expires_at > NOW()). + it('consumes only a pending, unexpired member invitation in the single-use transition', async () => { + const consume = async (label: string, options: Parameters[0]) => { + const invitation = await createInvitation(options) + const user = await holder.pool.query<{ id: string }>( + `INSERT INTO users(email, name) VALUES ($1, $2) RETURNING id::text AS id`, + [invitation.email, label] + ) + const userId = user.rows[0]!.id + const row = await getInvitationRecordById(holder.pool, invitation.id) + if (!row) throw new Error(`invitation ${label} was not seeded`) + const before = await invitationState(invitation.id) + const client = await holder.pool.connect() + try { + await client.query('BEGIN') + const consumed = await acceptPendingMembershipInvitation(client, row, userId) + await client.query('COMMIT') + return { consumed, userId, before, after: await invitationState(invitation.id) } + } finally { + await client.query('ROLLBACK').catch(() => undefined) + client.release() + } + } + + // Witness: the same direct call consumes a pending, unexpired invitation. + const pending = await consume('inv6-pending', { label: 'inv6-pending' }) + expect(pending.consumed).toBe(true) + expect(pending.after).toMatchObject({ status: 'accepted', accepted_user_id: pending.userId }) + await expect(memberships(pending.userId)).resolves.toEqual([ + { team_id: teamId, role: 'member', status: 'active' }, + ]) + + const revoked = await consume('inv6-revoked', { label: 'inv6-revoked', status: 'revoked' }) + expect(revoked.consumed).toBe(false) + expect(revoked.after).toEqual(revoked.before) + expect(revoked.after).toEqual({ status: 'revoked', accepted_user_id: null, accepted_at: null }) + await expect(memberships(revoked.userId)).resolves.toEqual([]) + + const expired = await consume('inv6-expired', { + label: 'inv6-expired', + expiresAt: new Date(Date.now() - 60 * 1000), + }) + expect(expired.consumed).toBe(false) + expect(expired.after).toEqual(expired.before) + expect(expired.after).toEqual({ status: 'pending', accepted_user_id: null, accepted_at: null }) + await expect(memberships(expired.userId)).resolves.toEqual([]) + + const reset = await consume('inv6-reset', { label: 'inv6-reset', purpose: 'password_reset' }) + expect(reset.consumed).toBe(false) + expect(reset.after).toEqual(reset.before) + expect(reset.after).toEqual({ status: 'pending', accepted_user_id: null, accepted_at: null }) + await expect(memberships(reset.userId)).resolves.toEqual([]) + }) + + it('writes one membership when two acceptances overlap behind a held invitation row', async () => { + const invitation = await createInvitation({ label: 'concurrent' }) + await resetMembershipWriteProbe() + + // Hold the invitation row so both acceptances queue before either can + // consume the pending transition. Releasing it then lets exactly one win + // and the other observe the accepted row. + const barrier = await holder.pool.connect() + let accepting: Array> = [] + let barrierCommitted = false + try { + await barrier.query('BEGIN') + await barrier.query(`SELECT id FROM invitations WHERE id = $1 FOR UPDATE`, [invitation.id]) + // Start both acceptances only after the barrier owns the row, so both + // necessarily queue behind it. + accepting = [ + acceptInvitationForEmail(invitation.email, invitation.token, invitation.id), + acceptInvitationForEmail(invitation.email, invitation.token, invitation.id), + ] + await waitForQueuedAcceptances() + await barrier.query('COMMIT') + barrierCommitted = true + + const [left, right] = await Promise.all(accepting) + + expect(left).toMatchObject({ data: { accepted: true, status: 'accepted' } }) + expect(right).toMatchObject({ data: { accepted: true, status: 'accepted' } }) + if (!('data' in left) || !('data' in right)) { + throw new Error('concurrent acceptance did not return results') + } + // Exactly one of the two acceptances consumed the pending row. + const replays = [left.data.replay, right.data.replay].filter(Boolean) + expect(replays).toHaveLength(1) + expect(left.data.userId).toBe(right.data.userId) + expect(left.data.acceptedAt).toBe(right.data.acceptedAt) + const userId = String(left.data.userId) + + // The observer counts actual membership writes, so an identical UPSERT + // applied by the replay would raise this above one even though the final + // row, role and timestamps stay identical. + await expect(membershipWriteCount()).resolves.toBe(1) + + await expect(memberships(userId)).resolves.toEqual([ + { team_id: teamId, role: 'member', status: 'active' }, + ]) + const state = await invitationState(invitation.id) + expect(state.status).toBe('accepted') + expect(state.accepted_user_id).toBe(userId) + const acceptedAt = state.accepted_at + if (!(acceptedAt instanceof Date)) { + throw new Error('concurrent acceptance did not record an accepted_at timestamp') + } + expect(acceptedAt.toISOString()).toBe(left.data.acceptedAt) + + // Liveness witness: the probe observes a real membership update, so the + // one-write assertion above is not a trigger that never fires. + await holder.pool.query(`UPDATE team_members SET role = role WHERE user_id = $1`, [userId]) + await expect(membershipWriteCount()).resolves.toBe(2) + } finally { + if (!barrierCommitted) { + await barrier.query('ROLLBACK') + await Promise.allSettled(accepting) + } + barrier.release() + } + }) +}) diff --git a/control-api/test/membershipPasswordSecurity.test.ts b/control-api/test/membershipPasswordSecurity.test.ts new file mode 100644 index 0000000000..ca24aa1c23 --- /dev/null +++ b/control-api/test/membershipPasswordSecurity.test.ts @@ -0,0 +1,80 @@ +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(), + runPasswordMutation: 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, +})) +vi.mock('../src/services/auth/passwordWorkOwnership.js', () => ({ + runPasswordMutation: mocks.runPasswordMutation, +})) + +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) + 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: [{ 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( + 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', '4', 'next-hash', 'old-hash'] + ) + expect(mocks.revokeAll).toHaveBeenCalledWith( + 'user-1', + 'password_changed', + expect.objectContaining({ query: mocks.transactionQuery }), + undefined, + '7' + ) + }) + + it('does not revoke when a concurrent credential change wins first', async () => { + 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: 'credential_changed' }) + expect(mocks.revokeAll).not.toHaveBeenCalled() + }) +}) diff --git a/control-api/test/middleware.externalSessionAuth.legacyToken.test.ts b/control-api/test/middleware.externalSessionAuth.legacyToken.test.ts index a707594ac9..5cc75811c1 100644 --- a/control-api/test/middleware.externalSessionAuth.legacyToken.test.ts +++ b/control-api/test/middleware.externalSessionAuth.legacyToken.test.ts @@ -14,18 +14,32 @@ 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', }) -function legacySessionToken(): string { +function legacySessionToken(authGeneration?: number): string { return jwt.sign( { userId: '11111111-1111-4111-8111-111111111111', email: 'legacy@example.com', teamId: null, role: 'member', + ...(authGeneration === undefined ? {} : { authGeneration }), }, config.sessionJwtPrivateKey, { @@ -53,13 +67,21 @@ function app() { describe('external session legacy token cut-over', () => { beforeEach(() => { poolQuery.mockClear() - poolQuery.mockResolvedValue({ - rows: [{ lifecycle_state: 'active', lifecycle_version: '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('rejects a valid RS256 legacy token that has no auth generation', async () => { + it('denies a signature-valid token without a lifecycle generation', async () => { const token = legacySessionToken() const cryptographicallyValidClaims = jwt.verify(token, sessionJwtPublicKey, { @@ -73,7 +95,30 @@ describe('external session legacy token cut-over', () => { 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(response.body).not.toEqual({ ok: true }) expect(poolQuery).not.toHaveBeenCalled() }) + + it('accepts the same token shape carrying generation one', async () => { + const token = legacySessionToken(1) + + expect(verifyExternalSessionToken(token)).toEqual( + expect.objectContaining({ + userId: '11111111-1111-4111-8111-111111111111', + authGeneration: 1, + }) + ) + + const response = await request(app()).get('/protected').set('x-user-session-token', token) + + expect(response.status).toBe(200) + expect(response.body).toEqual({ ok: true }) + 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 95749bfa2f..7ebbd14a49 100644 --- a/control-api/test/middleware.externalSessionAuth.lifecycle.test.ts +++ b/control-api/test/middleware.externalSessionAuth.lifecycle.test.ts @@ -5,25 +5,50 @@ import { DatabaseError } from 'pg' import request from 'supertest' const poolQuery = vi.hoisted(() => vi.fn()) +const poolConnect = vi.hoisted(() => vi.fn()) +// Statements sent on a client checked out with pool.connect(), and its release. +const clientQuery = vi.hoisted(() => vi.fn()) +const clientRelease = vi.hoisted(() => vi.fn()) +const withTransaction = 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) }, -})) +type MockTransactionClient = { + query: (...args: unknown[]) => Promise + release: () => void +} +type MockPool = { connect: () => Promise } + +vi.mock('../src/db.js', () => { + const pool = { + query: (...args: unknown[]) => poolQuery(...args), + connect: (...args: unknown[]) => poolConnect(...args), + } + return { + pool, + // Same signature as the real helper: the transaction runs on a client of + // txPool, which defaults to the core pool. + withTransaction: (work: unknown, txPool: unknown = pool, options?: unknown) => + withTransaction(work, txPool, options), + } +}) 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), })) - 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, } @@ -44,7 +69,12 @@ function app() { 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) => { @@ -57,20 +87,55 @@ function app() { describe('external session lifecycle gate', () => { beforeEach(() => { poolQuery.mockReset() + poolConnect.mockReset() + clientQuery.mockReset() + clientRelease.mockReset() + withTransaction.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, + }, + ] + const authorityResult = async () => ({ + rows: authorityRows, + rowCount: authorityRows.length, }) + poolQuery.mockImplementation(authorityResult) + // A checked-out client answers like the pool, so validation moved into a + // transaction still succeeds and the test reaches its transaction assertions. + clientQuery.mockImplementation(authorityResult) + poolConnect.mockImplementation( + async (): Promise => ({ + query: (...args: unknown[]) => clientQuery(...args), + release: () => clientRelease(), + }) + ) + withTransaction.mockImplementation( + async (work: (client: MockTransactionClient) => Promise, txPool: MockPool) => { + const client = await txPool.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() + } + } + ) }) 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 +146,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 +166,80 @@ 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 lifecycle query that carries the database clock. expect(poolQuery).toHaveBeenCalledTimes(1) - expect(poolQuery.mock.calls[0]?.[1]).toEqual([claims.userId]) + expect(String(poolQuery.mock.calls[0]?.[0])).toContain("date_trunc('second', now())") 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') + poolQuery.mockRejectedValueOnce(acquireFailure) + + const response = await request(app()) + .get('/protected') + .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).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(withTransaction).not.toHaveBeenCalled() + // Witness: the lifecycle row was read once, on the pool, with the database clock. + expect(poolQuery).toHaveBeenCalledTimes(1) + expect(String(poolQuery.mock.calls[0]?.[0])).toContain("date_trunc('second', now())") + const statements = [...poolQuery.mock.calls, ...clientQuery.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 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() + }) + 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/middleware.externalSessionAuth.requestLogger.test.ts b/control-api/test/middleware.externalSessionAuth.requestLogger.test.ts new file mode 100644 index 0000000000..63e6e9f73b --- /dev/null +++ b/control-api/test/middleware.externalSessionAuth.requestLogger.test.ts @@ -0,0 +1,67 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import express from 'express' +import request from 'supertest' +import { correlationIdMiddleware } from '../src/middleware/correlationId.js' +import { + handleExternalSessionBackendFailure, + markAuthorityUnavailable, +} from '../src/middleware/externalSessionAuth.js' +import { type Logger, rootLogger } from '../src/observability/logger.js' +import { ExternalSessionBackendUnavailableError } from '../src/services/auth/sessionDatabaseFailure.js' + +const CORRELATION_ID = '11111111-1111-4111-8111-111111111111' + +describe('external outage logs', () => { + const childWarn = vi.fn() + const rootWarn = vi.spyOn(rootLogger, 'warn').mockImplementation(() => undefined) + const child = vi.spyOn(rootLogger, 'child') + + beforeEach(() => { + childWarn.mockReset() + rootWarn.mockClear() + child.mockReset() + child.mockReturnValue({ warn: childWarn } as unknown as Logger) + }) + + afterEach(() => { + expect(child).toHaveBeenCalledWith({ correlationId: CORRELATION_ID }) + expect(childWarn).toHaveBeenCalledTimes(1) + expect(rootWarn).not.toHaveBeenCalled() + }) + + it('logs markAuthorityUnavailable through the correlated request logger', async () => { + const app = express() + app.use(correlationIdMiddleware) + app.get('/probe', (req, res) => { + markAuthorityUnavailable(req, res, new Error('outage'), 'probe_event') + res.status(503).json({ error: 'authority_unavailable' }) + }) + + const res = await request(app).get('/probe').set('x-correlation-id', CORRELATION_ID) + + expect(res.status).toBe(503) + expect(res.headers['cache-control']).toBe('no-store') + expect(childWarn.mock.calls[0]?.[0]).toMatchObject({ event: 'probe_event' }) + }) + + it('logs handleExternalSessionBackendFailure through the correlated request logger', async () => { + const app = express() + app.use(correlationIdMiddleware) + app.get('/probe', (req, res, next) => { + handleExternalSessionBackendFailure( + new ExternalSessionBackendUnavailableError(new Error('outage')), + req, + res, + next + ) + }) + + const res = await request(app).get('/probe').set('x-correlation-id', CORRELATION_ID) + + expect(res.status).toBe(503) + expect(res.body.error).toBe('session_backend_unavailable') + expect(childWarn.mock.calls[0]?.[0]).toMatchObject({ + event: 'external_session_backend_unavailable', + }) + }) +}) 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..44c73261e1 --- /dev/null +++ b/control-api/test/migrationExecutionPolicy.realPostgres.integration.test.ts @@ -0,0 +1,1030 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +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 { CONTROL_API_MIGRATIONS, type DbClient, assertDbReady, initDb } from '../src/db.js' +import { migrationSessionBoundsSql } from '../src/migrations/migrationExecutionPolicy.js' +import { + PR1_MIGRATION_VERSIONS, + applyPendingPr1Migrations, +} from '../src/migrations/migrationRunner.js' +import { + PR1_ONLINE_INDEX_PLAN, + ensureOnlineIndex, + expectedIndexDefinition, + onlineIndex, +} 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 +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_SET_SHA256 = + 'acb72ad12e1d342310832cf7628ab8351a80cee7311111039b4c2b9720077307' +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 = + latestProducer.sourceHashes['control-api/src/migrate.ts'] +const DEV_PASSWORD_SUCCESS_ADMISSION_VERSION = '0130_password_login_success_admission' + +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) +) { + throw new Error( + 'R61-B1 real PostgreSQL lane requires both pinned DEV producer source directories' + ) +} + +type HistoricalRunnerPin = { + label: string + sourceDir: string | undefined + commit: string + sourceFiles: ReadonlyArray +} + +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]], +} + +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 + 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', + 'external_user_sessions_idle_idx', + 'external_v1_session_revocations_user_idx', + 'external_v1_session_revocations_expiry_idx', +]) + +const EARLIER_HEAD_DOC_REFERENCE = + 'see docs/deploy/user-access-foundation-upgrade.md#databases-that-ran-an-earlier-head-of-this-change' + +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 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 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) +} + +// The upgrade delta is derived from head, so a dev merge that brings migrations needs +// no edit here: the pinned producer describes the start, and head describes the rest. +async function upgradeFromProducerReceipts( + pool: Pool, + producerReceipts: readonly string[] +): Promise<{ pending: string[] }> { + const headVersions = CONTROL_API_MIGRATIONS.map(migration => migration.version) + const pending = headVersions.filter(version => !producerReceipts.includes(version)) + // Witness: the derived delta ends in the versions this change owns. + expect(pending.slice(-PR1_MIGRATION_VERSIONS.length)).toEqual([...PR1_MIGRATION_VERSIONS]) + const upgrade = await withMigrationApplyObserver(new Set(headVersions), async () => + initDb({ connect: () => pool.connect() }) + ) + expect(upgrade.applyOrder).toEqual(pending) + expect(new Set(await versions(pool))).toEqual(new Set(headVersions)) + return { pending } +} + +async function removeMigrationReceiptForReplay(pool: Pool, version: string): Promise { + 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', () => { + 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 + const isolatedDatabases: string[] = [] + + 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 () => { + 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 + WHERE datname = $1 + AND pid <> pg_backend_pid()`, + [database] + ) + await adminPool.query(`DROP DATABASE IF EXISTS ${quoteIdentifier(database)}`) + } + } finally { + 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(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(9) + 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(9) + expect(indexes.rows.every(row => row.indisvalid)).toBe(true) + + await initDb({ connect: () => databasePool.connect() }) + expect(await versions(databasePool)).toEqual(firstVersions) + }) + + it('builds every planned index with exactly the expected PostgreSQL definition', async () => { + for (const entry of PR1_ONLINE_INDEX_PLAN) { + const actual = await databasePool.query<{ definition: string }>( + 'SELECT pg_get_indexdef($1::regclass) AS definition', + [entry.name] + ) + expect(actual.rows, entry.name).toEqual([ + { definition: expectedIndexDefinition(entry, 'public') }, + ]) + } + }) + + 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 removeMigrationReceiptForReplay(databasePool, 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('reuses every deparsed planned index without rebuilding its physical index', async () => { + const oids = async () => + ( + await databasePool.query<{ index_name: string; oid: string }>( + `SELECT index_name, index_name::regclass::oid::text AS oid + FROM unnest($1::text[]) AS index_name + ORDER BY index_name`, + [PR1_ONLINE_INDEX_PLAN.map(index => index.name)] + ) + ).rows + const before = await oids() + expect(before).toHaveLength(PR1_ONLINE_INDEX_PLAN.length) + await removeMigrationReceiptForReplay(databasePool, '0137_user_access_foundation') + + await initDb({ connect: () => databasePool.connect() }) + + expect(await oids()).toEqual(before) + expect(await versions(databasePool)).toContain('0137_user_access_foundation') + }) + + it('rejects same-name indexes with changed order, predicate, expression, uniqueness or table', async () => { + const suffix = randomBytes(4).toString('hex') + const table = `d34_index_semantics_${suffix}` + const otherTable = `d34_index_other_${suffix}` + await databasePool.query(` + CREATE TABLE ${table} ( + userid text, + id integer, + a integer, + b integer, + included_a text, + included_b text + ) + `) + await databasePool.query(`CREATE TABLE ${otherTable} (userid text)`) + const structural = (name: string, keys: string[], extra: { include?: string[] } = {}) => + onlineIndex({ + migrationVersion: '0137_user_access_foundation', + name, + table, + keys, + ...extra, + }) + + // Positive control: an index built exactly as planned is reused, not rebuilt. + const exact = structural(`d34_exact_${suffix}`, ['userid', 'id'], { include: ['included_a'] }) + await databasePool.query( + `CREATE INDEX ${exact.name} ON ${table} (userid, id) INCLUDE (included_a)` + ) + const exactOid = await databasePool.query<{ oid: string }>( + 'SELECT $1::regclass::oid::text AS oid', + [exact.name] + ) + await ensureOnlineIndex(databasePool, exact) + expect( + ( + await databasePool.query<{ oid: string }>('SELECT $1::regclass::oid::text AS oid', [ + exact.name, + ]) + ).rows + ).toEqual(exactOid.rows) + + const cases = [ + { + entry: structural(`d34_key_order_${suffix}`, ['userid', 'id']), + actual: `ON ${table} (id, userid)`, + }, + { + entry: structural(`d34_include_order_${suffix}`, ['userid'], { + include: ['included_a', 'included_b'], + }), + actual: `ON ${table} (userid) INCLUDE (included_b, included_a)`, + }, + { + entry: structural(`d34_predicate_${suffix}`, ['userid']), + actual: `ON ${table} (userid) WHERE userid IS NOT NULL`, + }, + { + entry: structural(`d34_expression_${suffix}`, ['a']), + actual: `ON ${table} ((a + b))`, + }, + { + entry: structural(`d34_other_table_${suffix}`, ['userid']), + actual: `ON ${otherTable} (userid)`, + }, + ] + for (const candidate of cases) { + await databasePool.query(`CREATE INDEX ${candidate.entry.name} ${candidate.actual}`) + await expect(ensureOnlineIndex(databasePool, candidate.entry)).rejects.toThrow( + `Non-equivalent existing index: ${candidate.entry.name}` + ) + } + + const uniqueName = `d34_unique_mismatch_${suffix}` + await databasePool.query(`CREATE INDEX ${uniqueName} ON ${table} (userid)`) + await expect( + ensureOnlineIndex( + databasePool, + onlineIndex({ + migrationVersion: '0137_user_access_foundation', + name: uniqueName, + table, + unique: true, + keys: ['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 = onlineIndex({ + migrationVersion: '0137_user_access_foundation', + name, + table: 'd34_interrupted_index', + unique: true, + keys: ['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') + const repaired = 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(repaired.rows).toEqual([{ indisvalid: true }]) + }) + + 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 entry = onlineIndex({ + migrationVersion: '0137_user_access_foundation', + name: `d34_slow_index_${suffix}_idx`, + table, + keys: ['value'], + }) + await databasePool.query(`CREATE TABLE ${table}(value integer NOT NULL)`) + await databasePool.query(`INSERT INTO ${table}(value) VALUES (1)`) + // An open writer keeps CREATE INDEX CONCURRENTLY waiting after it has + // committed the invalid catalog entry. Both sessions are fresh (initDb's + // session bounds live on databasePool's connections): the writer has no + // idle-in-transaction timeout and the online client no lock timeout, so + // only the 120 s statement bound can end that wait. + const writerPool = new Pool({ connectionString, max: 1 }) + const onlinePool = new Pool({ connectionString, max: 1 }) + const writer = await writerPool.connect() + try { + const idleTimeout = await writer.query<{ idle_in_transaction_session_timeout: string }>( + 'SHOW idle_in_transaction_session_timeout' + ) + expect(idleTimeout.rows).toEqual([{ idle_in_transaction_session_timeout: '0' }]) + await writer.query('BEGIN') + await writer.query(`INSERT INTO ${table}(value) VALUES (2)`) + const lockTimeout = await onlinePool.query<{ lock_timeout: string }>('SHOW lock_timeout') + expect(lockTimeout.rows).toEqual([{ lock_timeout: '0' }]) + + const started = Date.now() + await expect(ensureOnlineIndex(onlinePool, entry)).rejects.toMatchObject({ code: '57014' }) + const elapsed = Date.now() - started + expect(elapsed).toBeGreaterThanOrEqual(119_000) + expect(elapsed).toBeLessThan(130_000) + } finally { + await writer.query('ROLLBACK') + writer.release() + await endPoolAndWaitForClients(onlinePool) + await endPoolAndWaitForClients(writerPool) + } + 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`, + [entry.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 removeMigrationReceiptForReplay(databasePool, 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 mid-statement', async () => { + // The child applies the migration session bounds exactly as initDb does, + // takes the advisory lock and blocks in a long statement. Without the + // connection check its backend would keep the lock until the 15 s + // statement bound cancels the sleep. + const childScript = ` + const { Client } = require('pg'); + (async () => { + const client = new Client({ connectionString: process.env.D34_DATABASE_URL }); + await client.connect(); + for (const sql of JSON.parse(process.env.D34_SESSION_BOUNDS_SQL)) await client.query(sql); + const backend = await client.query('SELECT pg_backend_pid() AS pid'); + await client.query("SELECT pg_advisory_lock(hashtext('control-api-init-db-v1')::bigint)"); + process.stdout.write('locked ' + backend.rows[0].pid + '\\n'); + await client.query('SELECT pg_sleep(60)'); + })().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, + D34_SESSION_BOUNDS_SQL: JSON.stringify(migrationSessionBoundsSql(false)), + }, + stdio: ['ignore', 'pipe', 'pipe'], + }) + const backendPid = new Promise((resolve, reject) => { + const timer = setTimeout( + () => reject(new Error('child did not acquire advisory lock')), + 10_000 + ) + child.stdout.on('data', chunk => { + const match = /locked (\d+)/.exec(String(chunk)) + if (!match) return + clearTimeout(timer) + resolve(Number(match[1])) + }) + child.once('error', error => { + clearTimeout(timer) + reject(error) + }) + }) + const pid = await backendPid + + // Liveness witness: the child's backend is running the sleep, with the + // connection check applied, before the process is killed. + const sleepDeadline = Date.now() + 5_000 + let sleeping = false + while (!sleeping) { + const activity = await databasePool.query<{ running: boolean }>( + `SELECT state = 'active' AND query = 'SELECT pg_sleep(60)' AS running + FROM pg_stat_activity + WHERE pid = $1`, + [pid] + ) + sleeping = activity.rows[0]?.running === true + if (!sleeping) { + if (Date.now() > sleepDeadline) throw new Error('child backend never started its sleep') + await new Promise(resolve => setTimeout(resolve, 50)) + } + } + child.kill('SIGKILL') + await once(child, 'exit') + + const killedAt = Date.now() + const lockCheck = await databasePool.connect() + try { + let acquired = false + while (!acquired && Date.now() - killedAt < 5_000) { + const attempt = await lockCheck.query<{ acquired: boolean }>( + `SELECT pg_try_advisory_lock(hashtext('control-api-init-db-v1')::bigint) AS acquired` + ) + acquired = attempt.rows[0]?.acquired === true + if (!acquired) await new Promise(resolve => setTimeout(resolve, 100)) + } + expect(acquired).toBe(true) + await lockCheck.query(`SELECT pg_advisory_unlock(hashtext('control-api-init-db-v1')::bigint)`) + } finally { + lockCheck.release() + } + }, 20_000) + + it('bounds ordinary DDL lock acquisition and never runs the later version', async () => { + const locker = await databasePool.connect() + // 0137 replays without touching users (its CREATE TABLE IF NOT EXISTS + // skips existing tables); 0138's SELECT FROM users is the first statement + // that waits on the lock. + await removeMigrationReceiptForReplay(databasePool, '0137_user_access_foundation') + await removeMigrationReceiptForReplay( + databasePool, + '0138_legacy_password_security_epoch_backfill' + ) + await locker.query('BEGIN') + await locker.query('LOCK TABLE users 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).toContain('0137_user_access_foundation') + expect(failedVersions).not.toContain('0138_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, 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 === '0138_legacy_password_security_epoch_backfill') { + 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('0138_legacy_password_security_epoch_backfill') + } finally { + client.release(true) + } + }, 25_000) + + async function withFreshHeadDatabase(work: (pool: Pool) => Promise): Promise { + const databaseName = `r7_earlier_head_${randomBytes(6).toString('hex')}` + await adminPool.query(`CREATE DATABASE ${quoteIdentifier(databaseName)}`) + isolatedDatabases.push(databaseName) + const isolatedPool = new Pool({ connectionString: databaseUrl(adminUrl!, databaseName) }) + try { + await initDb({ connect: () => isolatedPool.connect() }) + await work(isolatedPool) + } finally { + await endPoolAndWaitForClients(isolatedPool) + } + } + + it('rejects a database that recorded an earlier-head migration version', async () => { + await withFreshHeadDatabase(async pool => { + for (const earlierVersion of [ + '0133_catalog_utf8_ordering', + '0126_user_access_foundation', + '010a_invitation_delivery_commands', + '0131_user_access_foundation', + '0132_legacy_password_security_epoch_backfill', + ]) { + await pool.query('INSERT INTO schema_migrations(version) VALUES ($1)', [earlierVersion]) + const rejection = initDb({ connect: () => pool.connect() }) + await expect(rejection).rejects.toThrow( + `recorded versions [${earlierVersion}]; functions []; tables []; ${EARLIER_HEAD_DOC_REFERENCE}` + ) + await pool.query('DELETE FROM schema_migrations WHERE version = $1', [earlierVersion]) + } + // Witness that the rejections came from the recorded versions alone. + await initDb({ connect: () => pool.connect() }) + }) + }) + + it('rejects earlier-head objects, and accepts the database after the cleanup SQL', async () => { + await withFreshHeadDatabase(async pool => { + // Witness that the baseline is a real HEAD database, not an empty table. + const freshReceipts = await versions(pool) + expect(freshReceipts).toEqual( + expect.arrayContaining([ + '0137_user_access_foundation', + '0138_legacy_password_security_epoch_backfill', + ]) + ) + await pool.query(` + CREATE FUNCTION authorization_bump_user_row_revision() RETURNS trigger + LANGUAGE plpgsql AS $$ BEGIN RETURN NEW; END $$; + CREATE TRIGGER users_authorization_revision + AFTER UPDATE ON users + FOR EACH ROW EXECUTE FUNCTION authorization_bump_user_row_revision(); + CREATE FUNCTION authorization_bump_user_revision(target_user_id uuid) RETURNS void + LANGUAGE plpgsql AS $$ BEGIN NULL; END $$; + CREATE TABLE authorization_catalog_writer_components(component text PRIMARY KEY); + INSERT INTO schema_migrations(version) VALUES + ('0131_invitation_delivery_commands'), + ('010a_invitation_delivery_commands'), + ('012b_user_access_foundation_definer_temp_shadow_hardening'); + `) + + const rejection = initDb({ connect: () => pool.connect() }) + for (const named of [ + 'authorization_bump_user_row_revision()', + 'authorization_bump_user_revision(uuid)', + 'authorization_catalog_writer_components', + '0131_invitation_delivery_commands', + '010a_invitation_delivery_commands', + '012b_user_access_foundation_definer_temp_shadow_hardening', + EARLIER_HEAD_DOC_REFERENCE, + ]) { + await expect(rejection, named).rejects.toThrow(named) + } + + // The operator's check query, exactly as the upgrade doc prints it. + const upgradeDoc = readFileSync( + new URL('../../docs/deploy/user-access-foundation-upgrade.md', import.meta.url), + 'utf8' + ) + const section = upgradeDoc + .split('\n## Databases that ran an earlier head of this change\n')[1] + ?.split('\n## ')[0] + const sqlBlocks = [...(section ?? '').matchAll(/```sql\n([\s\S]+?)```/g)] + if (sqlBlocks.length !== 1) { + throw new Error( + `expected exactly one sql block in the upgrade doc's earlier-head section, found ${sqlBlocks.length}` + ) + } + const checkQuery = sqlBlocks[0]![1]! + const found = (await pool.query<{ kind: string; name: string }>(checkQuery)).rows + .map(row => `${row.kind}:${row.name}`) + .sort() + expect(found).toEqual( + [ + 'function:authorization_bump_user_revision(uuid)', + 'function:authorization_bump_user_row_revision()', + 'table:authorization_catalog_writer_components', + 'version:010a_invitation_delivery_commands', + 'version:0131_invitation_delivery_commands', + 'version:012b_user_access_foundation_definer_temp_shadow_hardening', + ].sort() + ) + + const cleanupSql = readFileSync( + new URL( + '../../docs/deploy/sql/user-access-foundation-earlier-head-cleanup.sql', + import.meta.url + ), + 'utf8' + ) + await pool.query(cleanupSql) + + const remaining = await pool.query<{ + row_trigger_function: string | null + argument_function: string | null + trigger_count: number + writer_components: string | null + }>( + `SELECT to_regprocedure('authorization_bump_user_row_revision()')::text + AS row_trigger_function, + to_regprocedure('authorization_bump_user_revision(uuid)')::text + AS argument_function, + (SELECT count(*)::int FROM pg_trigger + WHERE tgname = 'users_authorization_revision') AS trigger_count, + to_regclass('authorization_catalog_writer_components')::text + AS writer_components` + ) + expect(remaining.rows).toEqual([ + { + row_trigger_function: null, + argument_function: null, + trigger_count: 0, + writer_components: null, + }, + ]) + // Zero earlier-head receipts remain, independent of the regex under test. + expect(await versions(pool)).toEqual(freshReceipts) + expect((await pool.query(checkQuery)).rows).toEqual([]) + + await initDb({ connect: () => pool.connect() }) + const sessionTableNames = [ + 'external_user_sessions', + 'external_user_session_security_epochs', + 'external_v1_session_revocations', + ] + const sessionTables = await pool.query<{ table_name: string }>( + `SELECT table_name + FROM unnest($1::text[]) AS table_name + WHERE to_regclass(table_name) IS NOT NULL`, + [sessionTableNames] + ) + expect(sessionTables.rows.map(row => row.table_name).sort()).toEqual( + [...sessionTableNames].sort() + ) + const receipts = await versions(pool) + expect(receipts).toContain('0137_user_access_foundation') + expect(receipts).toContain('0138_legacy_password_security_epoch_backfill') + }) + }) + + it('upgrades a database from the latest DEV producer with only this change and accepts its migrator', 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) + + // 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( + CONTROL_API_MIGRATIONS.map(migration => migration.version).filter( + version => !latestDevReceipts.includes(version) + ), + 'latestDev is stale: move it in tests/contracts/certification-inputs.json to the merge base with dev' + ).toEqual([...PR1_MIGRATION_VERSIONS]) + expect(latestDevReceipts).toContain(DEV_PASSWORD_SUCCESS_ADMISSION_VERSION) + + const { pending } = await upgradeFromProducerReceipts(isolatedPool, latestDevReceipts) + expect(pending).toEqual([...PR1_MIGRATION_VERSIONS]) + // Each PR1 migration commits in its own bounded transaction. + const receiptTransactions = await isolatedPool.query<{ version: string; xmin: string }>( + 'SELECT version, xmin::text AS xmin FROM schema_migrations WHERE version = ANY($1::text[])', + [[...PR1_MIGRATION_VERSIONS]] + ) + expect(receiptTransactions.rows).toHaveLength(PR1_MIGRATION_VERSIONS.length) + expect(new Set(receiptTransactions.rows.map(row => row.xmin)).size).toBe( + PR1_MIGRATION_VERSIONS.length + ) + expect( + ( + await isolatedPool.query( + // 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()] + ) + ).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) + await assertDbReady(isolatedPool) + + // Rollback: the DEV image's migrator runs against the upgraded database. + const beforeRollbackRunner = await versions(isolatedPool) + await runPinnedMigrationProducer(LATEST_DEV_RUNNER, isolatedUrl) + expect(await versions(isolatedPool)).toEqual(beforeRollbackRunner) + await assertDbReady(isolatedPool) + + const firstReceipts = await versions(isolatedPool) + const replay = await withMigrationApplyObserver( + new Set(CONTROL_API_MIGRATIONS.map(migration => migration.version)), + 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 a database from the pinned pre-fence DEV producer without replay', 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(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) + } + // This start reaches the password-login fence, which no later DEV state can. + expect(observedDevReceipts).not.toContain(DEV_PASSWORD_SUCCESS_ADMISSION_VERSION) + + const { pending } = await upgradeFromProducerReceipts(isolatedPool, observedDevReceipts) + expect(pending).toContain(DEV_PASSWORD_SUCCESS_ADMISSION_VERSION) + 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) + } + }) +}) diff --git a/control-api/test/migrationExecutionPolicy.test.ts b/control-api/test/migrationExecutionPolicy.test.ts new file mode 100644 index 0000000000..998fcc9944 --- /dev/null +++ b/control-api/test/migrationExecutionPolicy.test.ts @@ -0,0 +1,582 @@ +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, + expectedIndexDefinition, + onlineIndex, + preparePr1Migration, +} from '../src/migrations/pr1OnlineIndexPlan.js' + +const EARLIER_HEAD_GUARD_MARKER = '/* pr1-earlier-head-guard */' +const EARLIER_HEAD_DOC_REFERENCE = + 'see docs/deploy/user-access-foundation-upgrade.md#databases-that-ran-an-earlier-head-of-this-change' + +type IndexStateRow = { + schema_name: string + table_name: string + indisunique: boolean + indisvalid: boolean + definition: string +} + +function plannedIndexState(entry: (typeof PR1_ONLINE_INDEX_PLAN)[number]): IndexStateRow { + return { + schema_name: 'public', + table_name: entry.table, + indisunique: Boolean(entry.unique), + indisvalid: true, + definition: expectedIndexDefinition(entry, 'public'), + } +} + +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', +]) + +const USER_ACCESS_FOUNDATION_VERSION = '0137_user_access_foundation' + +describe('D34 migration execution policy', () => { + it('classifies the two PR1 migrations that follow the dev batch', () => { + expect(PR1_MIGRATION_VERSIONS).toEqual([ + '0137_user_access_foundation', + '0138_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') + }) + + 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, + }) + }) + + // 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 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 === USER_ACCESS_FOUNDATION_VERSION + ) + ).toHaveLength(5) + 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('pins the reviewed PR1 migration bodies byte-for-byte', async () => { + // PR1 occupies slots 0137-0138, after dev's 0136, and no shared + // environment holds a receipt for either. 0137 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. 0138 backfills the security epochs from + // historical password events. PR1 installs no trigger and no function. + const files = [ + [ + 'src/services/access/userAccessFoundationSchema.ts', + 'de83799480ec54079f27e18abc5592780594e8b2d1a82759238a5edf1c8ff400', + ], + ] 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 PR1 index exactly once', async () => { + const historicalSql = await Promise.all([ + readFile( + new URL('../src/services/access/userAccessFoundationSchema.ts', import.meta.url), + 'utf8' + ), + ]) + const inTransactionNames = historicalSql + .flatMap(sql => [...sql.matchAll(/CREATE (?:UNIQUE )?INDEX IF NOT EXISTS (\w+)/g)]) + .map(match => match[1]!) + .sort() + 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(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. + const canonical = (value: string) => + value + .replace(/\bCONCURRENTLY\b|\bIF NOT EXISTS\b/g, '') + .replace(/;\s*$/, '') + .replace(/\s+/g, ' ') + .replace(/\s*([(),])\s*/g, '$1') + .trim() + 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( + 'ecb09f793b6d95cc9473a91270536e7e17184db23e808b8271625e9ed04a3e8f' + ) + }) + + it('derives every planned createSql as an online CREATE INDEX statement', () => { + for (const index of PR1_ONLINE_INDEX_PLAN) { + expect(index.createSql, index.name).toMatch(/^CREATE (?:UNIQUE )?INDEX CONCURRENTLY /) + expect(index.createSql, index.name).toBe( + `CREATE ${index.unique ? 'UNIQUE ' : ''}INDEX CONCURRENTLY ${index.name} ON ${index.table} (${index.keys.join(', ')})` + + (index.include ? ` INCLUDE (${index.include.join(', ')})` : '') + ) + } + }) + + it('states the exact PostgreSQL 16 deparsed definition of a planned index', () => { + const entry = PR1_ONLINE_INDEX_PLAN.find( + index => index.name === 'team_members_user_active_idx' + )! + expect(expectedIndexDefinition(entry, 'public')).toBe( + 'CREATE INDEX team_members_user_active_idx ON public.team_members USING btree (user_id, status, team_id) INCLUDE (role, updated_at)' + ) + const unique = onlineIndex({ + migrationVersion: USER_ACCESS_FOUNDATION_VERSION, + name: 'synthetic_unique_idx', + table: 'synthetic_table', + unique: true, + keys: ['value'], + }) + expect(unique.createSql).toBe( + 'CREATE UNIQUE INDEX CONCURRENTLY synthetic_unique_idx ON synthetic_table (value)' + ) + expect(expectedIndexDefinition(unique, 'tenant_schema')).toBe( + 'CREATE UNIQUE INDEX synthetic_unique_idx ON tenant_schema.synthetic_table USING btree (value)' + ) + expect(() => expectedIndexDefinition(unique, 'Tenant-Schema')).toThrow( + 'Online index synthetic_unique_idx has a non-plain schema: "Tenant-Schema"' + ) + }) + + it.each([ + ['a quoted key', { keys: ['"userId"'] }, 'key'], + ['a mixed-case key', { keys: ['userId'] }, 'key'], + ['an expression key', { keys: ['a + b'] }, 'key'], + ['a parenthesized key', { keys: ['(a)'] }, 'key'], + ['an expression INCLUDE column', { keys: ['a'], include: ['lower(b)'] }, 'INCLUDE column'], + ['a schema-qualified table', { keys: ['a'], table: 'public.sample' }, 'table'], + ])('rejects %s at plan load', (_case, override, role) => { + expect(() => + onlineIndex({ + migrationVersion: USER_ACCESS_FOUNDATION_VERSION, + name: 'synthetic_idx', + table: 'sample', + ...override, + }) + ).toThrow(`Online index synthetic_idx has a non-plain ${role}`) + }) + + it('rejects an index without keys or with an empty INCLUDE list', () => { + const base = { + migrationVersion: USER_ACCESS_FOUNDATION_VERSION, + name: 'synthetic_idx', + table: 'sample', + } as const + expect(() => onlineIndex({ ...base, keys: [] })).toThrow( + 'Online index synthetic_idx has no keys' + ) + expect(() => onlineIndex({ ...base, keys: ['a'], include: [] })).toThrow( + 'Online index synthetic_idx has an empty INCLUDE list' + ) + }) +}) + +describe('D34 PR1 migration runner', () => { + it('records an accepted PR1 alias receipt in its own transaction without replaying the body', async () => { + const transactions: string[] = [] + let transaction = 0 + const aliasedVersion: string = PR1_MIGRATION_VERSIONS[1] + const alias = 'synthetic_pr1_alias' + const migrations = PR1_MIGRATION_VERSIONS.map(version => ({ + version, + ...(version === aliasedVersion ? { legacyVersions: [alias] } : {}), + apply: vi.fn(async () => undefined), + })) + const appliedVersions = new Set([ + ...PR1_MIGRATION_VERSIONS.filter(version => version !== aliasedVersion), + alias, + ]) + const recorded: string[] = [] + await applyPendingPr1Migrations({ + db: { + 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}`) + return { rows: [], rowCount: 0 } + }), + }, + migrations, + appliedVersions, + recordMigration: async (_db, version) => { + recorded.push(version) + transactions.push(`receipt:${transaction}:${version}`) + }, + }) + + 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 () => { + const queries: Array<{ sql: string; values?: unknown[] }> = [] + const indexStates = new Map() + const db = { + query: vi.fn(async (sql: string, values?: unknown[]) => { + queries.push({ sql, values }) + if (sql.includes(EARLIER_HEAD_GUARD_MARKER)) return { rows: [], rowCount: 0 } + 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, plannedIndexState(entry)) + } + 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]) + // 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) + }) + + 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[1]) 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[0]) + expect(query).toHaveBeenCalledWith('ROLLBACK') + }) + + it('fails closed for an unclassified migration after the dev batch', async () => { + await expect( + applyPendingPr1Migrations({ + db: { query: vi.fn() }, + migrations: [ + ...PR1_MIGRATION_VERSIONS.map(version => ({ version, apply: vi.fn() })), + { version: '0199_unclassified', apply: vi.fn() }, + ], + appliedVersions: new Set(), + recordMigration: vi.fn(), + }) + ).rejects.toThrow('Unclassified post-dev migrations: 0199_unclassified') + }) + + it('rejects a historical alias that collides with a canonical DEV identity', async () => { + const migrations = [ + { version: '0129_workflow_schedules_concurrency_policy', apply: vi.fn() }, + ...PR1_MIGRATION_VERSIONS.map(version => ({ + version, + ...(version === USER_ACCESS_FOUNDATION_VERSION + ? { legacyVersions: ['0129_workflow_schedules_concurrency_policy'] } + : {}), + 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('leaves dev migrations to the initDb dev batch and applies only PR1 versions', async () => { + const indexStates = new Map() + const db = { + query: vi.fn(async (sql: string, values?: unknown[]) => { + if (sql.includes(EARLIER_HEAD_GUARD_MARKER)) return { rows: [], rowCount: 0 } + 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, plannedIndexState(entry)) + } + return { rows: [], rowCount: 0 } + }), + } + const applyOrder: string[] = [] + const recorded: string[] = [] + const devMigrations = [ + '0129_workflow_schedules_concurrency_policy', + '0130_password_login_success_admission', + ].map(version => ({ version, apply: vi.fn(async () => undefined) })) + await applyPendingPr1Migrations({ + db, + migrations: [ + ...devMigrations, + ...PR1_MIGRATION_VERSIONS.map(version => ({ + version, + apply: vi.fn(async () => { + applyOrder.push(version) + }), + })), + ], + appliedVersions: new Set(), + recordMigration: async (_db, version) => { + recorded.push(version) + }, + }) + + const expectedOrder = [...PR1_MIGRATION_VERSIONS] + expect(applyOrder).toEqual(expectedOrder) + expect(recorded).toEqual(expectedOrder) + for (const migration of devMigrations) { + expect(migration.apply, migration.version).not.toHaveBeenCalled() + } + }) +}) + +describe('PR1 earlier-head guard', () => { + function guardedDb(guardRows: unknown[]) { + const queries: Array<{ sql: string; values?: unknown[] }> = [] + const query = vi.fn(async (sql: string, values?: unknown[]) => { + queries.push({ sql, values }) + if (sql.includes(EARLIER_HEAD_GUARD_MARKER)) { + return { rows: guardRows, rowCount: guardRows.length } + } + return { rows: [], rowCount: 0 } + }) + return { db: { query }, queries } + } + + function pr1Migrations(legacyVersionsFor: Record = {}) { + return PR1_MIGRATION_VERSIONS.map(version => ({ + version, + ...(legacyVersionsFor[version] ? { legacyVersions: legacyVersionsFor[version] } : {}), + apply: vi.fn(async () => undefined), + })) + } + + it('rejects recorded earlier-head versions, including prefixes below 0130 and hexadecimal prefixes, before any PR1 work', async () => { + const { db, queries } = guardedDb([]) + const migrations = pr1Migrations() + const recordMigration = vi.fn() + + const rejection = applyPendingPr1Migrations({ + db, + migrations, + appliedVersions: new Set([ + '0129_workflow_schedules_concurrency_policy', + '0130_password_login_success_admission', + '0126_user_access_foundation', + '0133_catalog_utf8_ordering', + '0137_user_access_foundation', + // The versions earlier heads recorded just below the current PR1 numbers. + '0131_user_access_foundation', + '0132_legacy_password_security_epoch_backfill', + // Dev's own migrations under those same numbers: the exact message below + // proves the guard reads the name, not the number. + '0131_llm_provider_catalog_settings', + '0132_llm_catalog_dismissed', + // Earlier heads also recorded hexadecimal prefixes. + '010a_invitation_delivery_commands', + '012b_user_access_foundation_definer_temp_shadow_hardening', + // A dev receipt with a hexadecimal prefix whose name is not an + // earlier-head name: the exact message below proves it is not named. + '00a0_codex_subscription_connections', + ]), + recordMigration, + }) + + await expect(rejection).rejects.toThrow( + 'recorded versions [010a_invitation_delivery_commands, 0126_user_access_foundation, 012b_user_access_foundation_definer_temp_shadow_hardening, 0131_user_access_foundation, 0132_legacy_password_security_epoch_backfill, 0133_catalog_utf8_ordering]; functions []; tables []; ' + + EARLIER_HEAD_DOC_REFERENCE + ) + // Liveness witness: the guard ran its catalog probe once, with the table list. + const guardQueries = queries.filter(({ sql }) => sql.includes(EARLIER_HEAD_GUARD_MARKER)) + expect(guardQueries).toHaveLength(1) + expect(guardQueries[0]!.values?.[0]).toHaveLength(12) + expect(queries.map(({ sql }) => sql)).toEqual([guardQueries[0]!.sql]) + for (const migration of migrations) expect(migration.apply).not.toHaveBeenCalled() + expect(recordMigration).not.toHaveBeenCalled() + }) + + it('rejects earlier-head functions and tables that the catalog probe finds', async () => { + const { db, queries } = guardedDb([ + { kind: 'function', name: 'authorization_bump_user_revision(uuid)' }, + { kind: 'function', name: 'authorization_bump_user_row_revision()' }, + { kind: 'table', name: 'authorization_catalog_writer_components' }, + ]) + + await expect( + applyPendingPr1Migrations({ + db, + migrations: pr1Migrations(), + appliedVersions: new Set(['0131_invitation_delivery_commands']), + recordMigration: vi.fn(), + }) + ).rejects.toThrow( + 'recorded versions [0131_invitation_delivery_commands]; ' + + 'functions [authorization_bump_user_revision(uuid), authorization_bump_user_row_revision()]; ' + + 'tables [authorization_catalog_writer_components]; ' + + EARLIER_HEAD_DOC_REFERENCE + ) + expect(queries).toHaveLength(1) + }) + + it('accepts a registered alias of an earlier name and proceeds when the probe is empty', async () => { + const { db, queries } = guardedDb([]) + const recorded: string[] = [] + const migrations = pr1Migrations({ + '0138_legacy_password_security_epoch_backfill': [ + '0134_legacy_password_security_epoch_backfill', + ], + }) + + await applyPendingPr1Migrations({ + db, + migrations, + appliedVersions: new Set([ + '0137_user_access_foundation', + '0134_legacy_password_security_epoch_backfill', + ]), + recordMigration: async (_db, version) => { + recorded.push(version) + }, + }) + + expect(queries.filter(({ sql }) => sql.includes(EARLIER_HEAD_GUARD_MARKER))).toHaveLength(1) + expect(recorded).toEqual(['0138_legacy_password_security_epoch_backfill']) + }) +}) + +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, + { ...plannedIndexState(candidate), indisvalid: candidate.name !== entry.name }, + ]) + ) + 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, plannedIndexState(candidate)) + } + return { rows: [], rowCount: 0 } + }) + await preparePr1Migration({ query }, entry.migrationVersion) + expect(query).toHaveBeenCalledWith(`DROP INDEX CONCURRENTLY ${entry.name}`) + expect(query).toHaveBeenCalledWith(entry.createSql) + + // Exact comparison: the createSql text (not PostgreSQL's deparsed form) and + // a reordered INCLUDE list are both rejected. + for (const definition of [ + entry.createSql, + expectedIndexDefinition(entry, 'public').replace( + 'INCLUDE (role, updated_at)', + 'INCLUDE (updated_at, role)' + ), + 'CREATE INDEX wrong ON team_members (team_id)', + ]) { + states.set(entry.name, { ...plannedIndexState(entry), definition }) + 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..525c17b14a --- /dev/null +++ b/control-api/test/migrationPreviousImageCompatibility.realPostgres.integration.test.ts @@ -0,0 +1,149 @@ +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' +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 = + '3f73f787acd4b7aac85b9a489c540403518256460d34c5ae2761121b3ff69843' + +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 }) + await initDb({ connect: () => databasePool.connect() }) + }) + + afterAll(async () => { + 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)}`) + } + } finally { + await adminPool?.end() + } + }) + + it("runs dev's team writer against the PR1 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' }]) + }) + + 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 runtime delete')`, + [userId, `d34-runtime-delete-${userId}@example.test`] + ) + await databasePool.query(`INSERT INTO teams(id, name) VALUES ($1, 'D34 runtime delete')`, [ + teamId, + ]) + await databasePool.query( + `INSERT INTO team_members(team_id, user_id, role, status) + VALUES ($1, $2, 'member', 'deleted')`, + [teamId, userId] + ) + + vi.resetModules() + 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'), + ]) + + 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, + }) + }) +}) diff --git a/control-api/test/passwordLogin.realPostgres.integration.test.ts b/control-api/test/passwordLogin.realPostgres.integration.test.ts index c6f9209d74..12ff999f20 100644 --- a/control-api/test/passwordLogin.realPostgres.integration.test.ts +++ b/control-api/test/passwordLogin.realPostgres.integration.test.ts @@ -212,7 +212,8 @@ realPg('BUG-192 observable password-login regressions on real PostgreSQL', () => }) .expect(200) expect(reset.body).not.toHaveProperty('sessionContext') - expect(reset.body.token).toEqual(expect.any(String)) + expect(reset.body.reauthenticationRequired).toBe(true) + expect(reset.body).not.toHaveProperty('token') const replay = await request(app) .post('/external/invitations/password-token') @@ -224,16 +225,6 @@ realPg('BUG-192 observable password-login regressions on real PostgreSQL', () => }) .expect(409) expect(replay.body).not.toHaveProperty('token') - - const access = await request(app) - .post('/external/auth/verify') - .send({ token: reset.body.token }) - expect(access.status).toBe(200) - expect(access.body.claims).toMatchObject({ - userId, - email: 'member@example.invalid', - }) - return access.body.claims } beforeAll(async () => { admin = new Pool({ connectionString: adminUrl }) @@ -453,8 +444,7 @@ realPg('BUG-192 observable password-login regressions on real PostgreSQL', () => "INSERT INTO team_members(team_id, user_id, role, status) VALUES ($1, $2, 'admin', 'active')", [teamId, userId] ) - const claims = await recoverPassword('Synthetic-BUG192-recovered-during-identifier-cooldown') - expect(claims).toMatchObject({ teamId, role: 'admin' }) + await recoverPassword('Synthetic-BUG192-recovered-during-identifier-cooldown') const stateAfterRecovery = ( await holder.pool.query( 'SELECT attempts,successes,failures,locked_until_ms FROM password_identifier_state WHERE identifier_key = $1', @@ -760,8 +750,11 @@ realPg('BUG-192 observable password-login regressions on real PostgreSQL', () => expect(recoveryUpdateCommitted).toBe(true) expect(result).toBeDefined() expect(result).toMatchObject({ - data: { passwordUpdated: true, sessionContext: { userId } }, + 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/passwordPersistence.realPostgres.integration.test.ts b/control-api/test/passwordPersistence.realPostgres.integration.test.ts index fbf05dcfb3..b68d1709a0 100644 --- a/control-api/test/passwordPersistence.realPostgres.integration.test.ts +++ b/control-api/test/passwordPersistence.realPostgres.integration.test.ts @@ -753,9 +753,16 @@ realPg('Spec 043 persistence, migration, atomicity and fencing design acceptance }) it('real password-change producer fences stale failure and success results', async () => { const stale = 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) if (!stale) throw new Error('expected capture') const lease = await acquirePasswordWork() if (!lease) throw new Error('expected password work owner') @@ -831,12 +838,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) if (!stale) throw new Error('expected capture') const lease = await acquirePasswordWork() if (!lease) throw new Error('expected password work owner') @@ -888,6 +902,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', @@ -931,6 +1025,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( @@ -943,6 +1040,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/passwordRecovery.http.contract.test.ts b/control-api/test/passwordRecovery.http.contract.test.ts index c376a01193..7e52f27619 100644 --- a/control-api/test/passwordRecovery.http.contract.test.ts +++ b/control-api/test/passwordRecovery.http.contract.test.ts @@ -1,15 +1,13 @@ 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' 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, @@ -17,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: '', @@ -61,14 +76,8 @@ vi.mock('../src/middleware/rateLimitMiddleware.js', async importOriginal => { next(), } }) -vi.mock('../src/middleware/externalSessionAuth.js', () => ({ - requireExternalUserParamMatch: () => (_req: unknown, _res: unknown, next: () => void) => next(), - rejectBodyUserTeamMismatch: ( - _req: express.Request, - _res: express.Response, - next: express.NextFunction - ) => next(), - requireValidExternalSessionToken: ( +vi.mock('../src/middleware/externalSessionAuth.js', () => { + const authenticate = ( req: express.Request, _res: express.Response, next: express.NextFunction @@ -81,8 +90,20 @@ vi.mock('../src/middleware/externalSessionAuth.js', () => ({ authGeneration: 1, } next() - }, -})) + } + return { + requireExternalUserParamMatch: () => (_req: unknown, _res: unknown, next: () => void) => next(), + rejectBodyUserTeamMismatch: ( + _req: express.Request, + _res: express.Response, + next: express.NextFunction + ) => next(), + requireValidExternalSessionToken: authenticate, + // The users router registers `/external/users/:userId/me` with this + // factory, so it must return the same middleware. + requireExternalSessionRateLimitContext: () => authenticate, + } +}) const preview = { id: 'r6-reset-row', email: 'r6-member@example.invalid', @@ -484,14 +505,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) => { @@ -613,8 +629,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', @@ -633,8 +649,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', @@ -653,8 +669,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', @@ -663,8 +679,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) @@ -704,7 +720,9 @@ describe('invitation/password producer response mappings', () => { 'r6-user', 'r6-member@example.invalid', scenario.invitationId, - scenario.password + scenario.password, + undefined, + { rejectIfPasswordAlreadySet: true } ) expect(directory.setInvitationPasswordForUser).toHaveBeenCalledTimes(2) expect(directory.acceptInvitationForEmail).not.toHaveBeenCalled() @@ -782,13 +800,6 @@ describe('invitation/password producer response mappings', () => { lifecycleState: 'active', authGeneration: 7, passwordUpdated: true, - sessionContext: { - userId: preview.userId, - email: preview.email, - teamId: null, - role: 'member', - authGeneration: 7, - }, }, }) @@ -810,21 +821,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, @@ -837,9 +839,7 @@ describe('invitation/password producer response mappings', () => { expect(directory.acceptInvitationForEmail).not.toHaveBeenCalled() }) - it('keeps a dropped post-dispatch reset acknowledgement unknown and never retries it', async () => { - const originalControlApiBaseUrl = externalConfig.controlApiBaseUrl - externalConfig.controlApiBaseUrl = `http://127.0.0.1:${responseDropProxyPort}/api/v1` + it('returns no session and sets no cookie from the reset producer', async () => { directory.setInvitationPasswordForEmail.mockResolvedValueOnce({ data: { id: preview.id, @@ -852,6 +852,7 @@ describe('invitation/password producer response mappings', () => { lifecycleState: 'active', authGeneration: 7, passwordUpdated: true, + token: 'synthetic-producer-session-token', sessionContext: { userId: preview.userId, email: preview.email, @@ -862,6 +863,47 @@ describe('invitation/password producer response mappings', () => { }, }) + const response = await request(producerServer) + .post('/api/v1/external/invitations/password-token') + .set('authorization', `Bearer ${internalServiceToken}`) + .set('x-service-token', 'external-rest-api') + .send({ + token: 'r6-synthetic-reset-proof', + email: preview.email, + invitationId: preview.id, + password: 'R6-synthetic-valid-password', + }) + + expect(response.status).toBe(200) + expect(response.body).toMatchObject({ + userId: preview.userId, + passwordUpdated: true, + reauthenticationRequired: true, + }) + expect(response.body).not.toHaveProperty('token') + expect(response.body).not.toHaveProperty('sessionContext') + expect(response.headers['set-cookie']).toBeUndefined() + expect(directory.setInvitationPasswordForEmail).toHaveBeenCalledOnce() + }) + + it('keeps a dropped post-dispatch reset acknowledgement unknown and never retries it', async () => { + const originalControlApiBaseUrl = externalConfig.controlApiBaseUrl + externalConfig.controlApiBaseUrl = `http://127.0.0.1:${responseDropProxyPort}/api/v1` + directory.setInvitationPasswordForEmail.mockResolvedValueOnce({ + data: { + id: preview.id, + userId: preview.userId, + email: preview.email, + teamId: null, + role: 'member', + purpose: 'password_reset', + status: 'accepted', + lifecycleState: 'active', + authGeneration: 7, + passwordUpdated: true, + }, + }) + const response = await (async () => { try { return await request(await publicApp()) @@ -1045,14 +1087,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({ @@ -1061,13 +1096,6 @@ describe('invitation/password producer response mappings', () => { status: 'accepted', role: 'member', passwordUpdated: true, - sessionContext: { - userId: preview.userId, - email: preview.email, - teamId: null, - role: 'member', - authGeneration: 7, - }, }, }) let writes = 0 diff --git a/control-api/test/passwordRecovery.http.realPostgres.integration.test.ts b/control-api/test/passwordRecovery.http.realPostgres.integration.test.ts index f1acc5a6c4..6069f9e4b5 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) @@ -219,11 +221,26 @@ realPg('R2-M1/R2-M2 public password authority contract on PostgreSQL 16', () => const session = await acceptedInvitationSession() const setPassword = await invitationPasswordRequest(session, nextSourceIp(), currentPassword) expect(setPassword.status).toBe(200) + expect(setPassword.body.reauthenticationRequired).toBe(true) + expect(String(setPassword.headers['set-cookie'])).toContain('profile_session=;') + await db.pool.query( + "UPDATE password_verification_pace SET next_permit = clock_timestamp() - interval '1 second'" + ) + const login = await request(publicApp) + .post('/api/v1/auth/password-login') + .set('x-forwarded-for', nextSourceIp()) + .send({ email: session.email, password: currentPassword }) + .expect(200) + expect(login.body.token).toEqual(expect.any(String)) const user = await db.pool.query('SELECT password_hash FROM users WHERE id = $1', [ session.userId, ]) expect(user.rows[0].password_hash).toEqual(expect.any(String)) - return { ...session, passwordHash: user.rows[0].password_hash as string } + return { + ...session, + token: login.body.token as string, + passwordHash: user.rows[0].password_hash as string, + } } it('returns a sanitized busy response, preserves invitation state, and succeeds on retry', async () => { @@ -253,7 +270,8 @@ realPg('R2-M1/R2-M2 public password authority contract on PostgreSQL 16', () => const retried = await invitationPasswordRequest(session, sourceIp) expect(retried.status).toBe(200) - expect(String(retried.headers['set-cookie'])).toContain('profile_session=') + expect(retried.body.reauthenticationRequired).toBe(true) + expect(String(retried.headers['set-cookie'])).toContain('profile_session=;') expect( await bcrypt.compare( invitationPassword, @@ -377,12 +395,13 @@ realPg('R2-M1/R2-M2 public password authority contract on PostgreSQL 16', () => expect(response.status, JSON.stringify(response.body)).toBe(200) expect(response.body).toMatchObject({ passwordUpdated: true, + reauthenticationRequired: true, purpose, status: 'accepted', email, role, }) - expect(String(response.headers['set-cookie'])).toContain('profile_session=') + expect(String(response.headers['set-cookie'])).toContain('profile_session=;') const invitationRow = await db.pool.query( 'SELECT purpose, status, accepted_user_id FROM invitations WHERE id = $1', @@ -407,7 +426,16 @@ realPg('R2-M1/R2-M2 public password authority contract on PostgreSQL 16', () => ) expect(memberships.rows).toEqual([{ team_id: teamId, role, status: 'active' }]) - const sessionCookie = String(response.headers['set-cookie']) + expect(response.body).not.toHaveProperty('token') + await db.pool.query( + "UPDATE password_verification_pace SET next_permit = clock_timestamp() - interval '1 second'" + ) + const login = await request(publicApp) + .post('/api/v1/auth/password-login') + .set('x-forwarded-for', nextSourceIp()) + .send({ email, password }) + .expect(200) + const sessionCookie = String(login.headers['set-cookie']) .split(';', 1)[0] .replace(/^profile_session=/, '') const identity = await request(publicApp) @@ -443,7 +471,8 @@ realPg('R2-M1/R2-M2 public password authority contract on PostgreSQL 16', () => const retried = await invitationPasswordRequest(session, sourceIp) expect(retried.status).toBe(200) - expect(String(retried.headers['set-cookie'])).toContain('profile_session=') + expect(retried.body.reauthenticationRequired).toBe(true) + expect(String(retried.headers['set-cookie'])).toContain('profile_session=;') }) it('maps authenticated password-change authority outcomes and permits a later retry', async () => { @@ -493,4 +522,82 @@ realPg('R2-M1/R2-M2 public password authority contract on PostgreSQL 16', () => ).rows[0].password_hash ).toBe(unavailableMember.passwordHash) }) + + 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) + expect(beforeReset.status).toBe(200) + expect(beforeReset.body).toMatchObject({ id: member.userId, email: member.email }) + + const { createSilentPasswordResetInvitation } = + await import('../src/services/directory/passwordResetInvitations.js') + const invitation = await createSilentPasswordResetInvitation({ + inviteeName: 'Synthetic pre-reset session', + email: member.email, + }) + const flowToken = `synthetic-reset-revokes-${randomBytes(20).toString('hex')}` + invitationFlowFixtures.set(flowToken, { + email: member.email, + invitationUuid: invitation.token, + }) + const resetPassword = `Synthetic-Reset-Revokes-${randomBytes(8).toString('hex')}` + + const reset = await request(publicApp) + .post('/api/v1/invitations/password') + .set('cookie', sessionCookie) + .set('origin', 'http://127.0.0.1') + .set('x-forwarded-for', nextSourceIp()) + .send({ + token: flowToken, + email: member.email, + invitationId: invitation.id, + password: resetPassword, + }) + + expect(reset.status, JSON.stringify(reset.body)).toBe(200) + expect(reset.body).toMatchObject({ + userId: member.userId, + passwordUpdated: true, + reauthenticationRequired: true, + }) + expect(reset.body).not.toHaveProperty('token') + expect(reset.body).not.toHaveProperty('sessionContext') + expect(String(reset.headers['set-cookie'])).toContain('profile_session=;') + expect(String(reset.headers['set-cookie'])).not.toMatch(/profile_session=[^;]+/) + + const revoked = await request(publicApp).get('/api/v1/me').set('cookie', sessionCookie) + expect(revoked.status).toBe(401) + // control-api declares the revoked session with its envelope, and the edge forwards it. + expect(revoked.body).toMatchObject({ error: { code: 'invalid_session' } }) + + 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'" + ) + const login = await request(publicApp) + .post('/api/v1/auth/password-login') + .set('x-forwarded-for', nextSourceIp()) + .send({ email: member.email, password: resetPassword }) + expect(login.status).toBe(200) + expect(login.body.token).toEqual(expect.any(String)) + const freshCookie = `profile_session=${login.body.token as string}` + const freshSession = await request(publicApp).get('/api/v1/me').set('cookie', freshCookie) + expect(freshSession.status).toBe(200) + expect(freshSession.body).toMatchObject({ id: member.userId, email: member.email }) + expect( + await bcrypt.compare( + resetPassword, + (await db.pool.query('SELECT password_hash FROM users WHERE id = $1', [member.userId])) + .rows[0].password_hash + ) + ).toBe(true) + }) }) diff --git a/control-api/test/passwordRecovery.memberRegistration.realPostgres.integration.test.ts b/control-api/test/passwordRecovery.memberRegistration.realPostgres.integration.test.ts index 9e730a834e..e471b74207 100644 --- a/control-api/test/passwordRecovery.memberRegistration.realPostgres.integration.test.ts +++ b/control-api/test/passwordRecovery.memberRegistration.realPostgres.integration.test.ts @@ -205,7 +205,8 @@ realCrossService( password: 'Synthetic-Issue1055-new-password', }) .expect(200) - expect(reset.body.token).toEqual(expect.any(String)) + expect(reset.body.reauthenticationRequired).toBe(true) + expect(reset.body).not.toHaveProperty('token') expect(reset.body).not.toHaveProperty('sessionContext') expect( ( @@ -216,12 +217,6 @@ realCrossService( ).rows[0].attempts ).toEqual(attemptsBeforeRecovery) - const authenticated = await request(app) - .post('/external/auth/verify') - .send({ token: reset.body.token }) - .expect(200) - expect(authenticated.body.claims).toMatchObject({ userId, email }) - await holder.pool.query( "UPDATE password_verification_pace SET next_permit = clock_timestamp() - interval '1 second'" ) diff --git a/control-api/test/passwordSessionAuthentication.test.ts b/control-api/test/passwordSessionAuthentication.test.ts new file mode 100644 index 0000000000..ad235e4b4e --- /dev/null +++ b/control-api/test/passwordSessionAuthentication.test.ts @@ -0,0 +1,91 @@ +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: 'synthetic-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: 'synthetic-old-password', + contract: 'v2', + }) + await vi.waitFor(() => expect(mocks.passwordLoginData).toHaveBeenCalledTimes(1)) + + releasePasswordChange() + + await expect(login).resolves.toBeNull() + 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', lifecycle_state: 'active', lifecycle_version: 5 }], + rowCount: 1, + }) + + await expect( + authenticatePasswordAndIssueSession({ + email: 'user@example.com', + password: 'synthetic-current-password', + contract: 'v2', + }) + ).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'] }), + 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/pr1MigrationLocks.realPostgres.integration.test.ts b/control-api/test/pr1MigrationLocks.realPostgres.integration.test.ts new file mode 100644 index 0000000000..ceb7f8f06f --- /dev/null +++ b/control-api/test/pr1MigrationLocks.realPostgres.integration.test.ts @@ -0,0 +1,296 @@ +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[] } + +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' +// Online indexes the removed catalog migration (0133) and the removed +// catalog rows of 0137 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}` + 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)) +} + +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[] +} + +async function migrate(pool: Pool, observer: Pool): Promise { + const run: MigrationRun = { + preExistingTables: new Set(), + commits: [], + } + 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 0137 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))) { + run.commits.push({ versions, locks: await relationLocks(observer, pid) }) + } + } + 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 baseUrl = adminUrl ?? 'postgresql://postgres@127.0.0.1/postgres' + let adminPool: Pool + let freshPool: Pool + let freshObserver: Pool + let fresh: MigrationRun + + beforeAll(async () => { + adminPool = new Pool({ connectionString: adminUrl }) + await adminPool.query(`DROP ROLE IF EXISTS ${runtimeRoles.join(', ')}`) + await adminPool.query(`CREATE DATABASE ${quoteIdentifier(freshDatabase)}`) + freshPool = new Pool({ connectionString: databaseUrl(baseUrl, freshDatabase), max: 2 }) + freshObserver = new Pool({ connectionString: databaseUrl(baseUrl, freshDatabase), max: 3 }) + fresh = await migrate(freshPool, freshObserver) + }, 120_000) + + afterAll(async () => { + try { + for (const pool of [freshPool, freshObserver]) { + if (pool) 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()`, + [freshDatabase] + ) + await adminPool.query(`DROP DATABASE IF EXISTS ${quoteIdentifier(freshDatabase)}`) + 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 and the pre-PR1 + // table set was captured. + 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) + + expect(blockingLocks(fresh)).toEqual([]) + }) + + 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 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 0137. + 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('_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[]) + ) + ORDER BY 1`, + [[...REMOVED_CATALOG_INDEXES]] + ) + expect(relations.rows).toEqual([]) + }) +}) 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..fe39de3b42 --- /dev/null +++ b/control-api/test/profileUiRecoveryFixture.parity.realPostgres.integration.test.ts @@ -0,0 +1,668 @@ +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 rejected invitation mutations without creating a session', async () => { + const rejected = [ + { + 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 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: { + 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 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(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', + `/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) + // The session probe declares a revoked session with the public envelope, never the + // bare 401 that the service-credential guard also sends. + expect(realRevoked.body).toMatchObject({ error: { code: 'invalid_session' } }) + expect(fixtureRevoked.body).toMatchObject({ error: { code: 'invalid_session' } }) + }) + + 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) + // The session probe declares a revoked session with the public envelope, never the + // bare 401 that the service-credential guard also sends. + expect(realRevoked.body).toMatchObject({ error: { code: 'invalid_session' } }) + expect(fixtureRevoked.body).toMatchObject({ error: { code: 'invalid_session' } }) + + 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: 'synthetic-definitely-not-the-password', + }, + }) + + // Liveness witness: the live session reads its own /me on both implementations, so the + // 403 below comes from the path-user check, not from a rejected session. + const realOwnMe = await requestReal( + 'GET', + `/external/users/${fixtureModule.RECOVERY_USERS.logout.id}/me?teamId=`, + { sessionToken: realToken } + ) + const fixtureOwnMe = await requestFixture( + 'GET', + `/external/users/${fixtureModule.RECOVERY_USERS.logout.id}/me?teamId=`, + { sessionToken: fixtureToken } + ) + expect(fixtureOwnMe.status).toBe(realOwnMe.status) + expect(bodyShape(fixtureOwnMe.body)).toEqual(bodyShape(realOwnMe.body)) + expect(realOwnMe.status).toBe(200) + + // A valid session asking for another user's /me is a 403 Forbidden on the real route + // (session authentication, then requireExternalUserParamMatch), never the + // invalid_session envelope that the edge forwards as a session end. + const realOtherMe = await requestReal( + 'GET', + `/external/users/${fixtureModule.RECOVERY_USERS.recovery.id}/me?teamId=`, + { sessionToken: realToken } + ) + const fixtureOtherMe = await requestFixture( + 'GET', + `/external/users/${fixtureModule.RECOVERY_USERS.recovery.id}/me?teamId=`, + { sessionToken: fixtureToken } + ) + expect(fixtureOtherMe.status).toBe(realOtherMe.status) + expect(bodyShape(fixtureOtherMe.body)).toEqual(bodyShape(realOtherMe.body)) + expect(realOtherMe.status).toBe(403) + expect(realOtherMe.body).toEqual({ error: 'Forbidden' }) + expect(fixtureOtherMe.body).toEqual({ error: 'Forbidden' }) + + 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) + // The session probe declares a revoked session with the public envelope, never the + // bare 401 that the service-credential guard also sends. + expect(realRevoked.body).toMatchObject({ error: { code: 'invalid_session' } }) + expect(fixtureRevoked.body).toMatchObject({ error: { code: 'invalid_session' } }) + + // 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' } }) + 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', + `/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/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/registry.test.ts b/control-api/test/registry.test.ts index 48caf5f579..f3455c8de9 100644 --- a/control-api/test/registry.test.ts +++ b/control-api/test/registry.test.ts @@ -178,6 +178,12 @@ 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 fixedNow = Math.floor(Date.now() / 60_000) * 60_000 + 59_999 + const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(fixedNow) + adminSvc.findAdminById.mockResolvedValue({ id: 'admin-uuid-123', username: 'rl-admin', @@ -192,16 +198,18 @@ describe('POST /registry/identity-voucher', () => { app.use(express.json()) app.use(createRegistryRouter()) - // Drain the bucket. Cap is 30/min (set in the route definition). - // Keep the burst in one quota window, including its final millisecond. - const fixedNow = Math.floor(Date.now() / 60_000) * 60_000 + 59_999 - vi.spyOn(Date, 'now').mockReturnValue(fixedNow) - 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). + // The clock is held at the window's last millisecond to keep the burst together. + 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 () => { 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 e381437371..d547e7c96f 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 @@ -61,6 +61,26 @@ const mockPoolQueryDispatch = vi.fn(async (sql: unknown, params?: unknown[]) => consumedRefreshJtis.add(jti) return { rows: [{ jti }], 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 } } @@ -69,7 +89,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), 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, }) } diff --git a/control-api/test/routes.entityChangeStream.test.ts b/control-api/test/routes.entityChangeStream.test.ts index 53b5a6adc9..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 => { @@ -271,7 +289,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 +308,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 +328,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.external.oauthGrants.test.ts b/control-api/test/routes.external.oauthGrants.test.ts index 3d00a160fe..7ec72d5293 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,14 @@ 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(), + backendAvailable: true, + }) }) // T3 (route wiring): fails at the parent sha, where GET calls @@ -136,4 +149,18 @@ describe('/external/oauth/grants', () => { expect(res.status).toBe(400) expect(storeMock.deleteOAuthGrant).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('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 5fe2418e70..c16b5c085d 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,14 @@ 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(), + backendAvailable: true, + }) linkSessionMock.createMediumLinkSession.mockReset() slackTargetMock.attachSlackTargetsToAccounts.mockImplementation( async (_gateway: unknown, _userId: string, accounts: unknown[]) => accounts @@ -302,6 +312,26 @@ describe('external workflow approval medium routes', () => { ) }) + 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('creates medium challenges without an added user quota', async () => { + identityServiceMock.createMediumChallenge.mockResolvedValue({ + id: 'challenge-parity', + nonce: '123456', + }) + await request(makeApp()) + .post('/external/workflow-approval-mediums/challenges') + .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 () => { providerEventMock.disableVerifiedMediumAccountWithTelegramAssociations.mockResolvedValueOnce( true 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..55fbdf9b84 --- /dev/null +++ b/control-api/test/routes.externalAuth.authorityUnavailable.test.ts @@ -0,0 +1,472 @@ +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 { rootLogger } from '../src/observability/logger.js' +import { createExternalAuthRouter } from '../src/routes/external/auth.js' +import { createExternalDirectoryRouter } from '../src/routes/external/directory.js' +import { + ExternalSessionBackendUnavailableError, + externalSessionDatabaseFailure, +} from '../src/services/auth/sessionDatabaseFailure.js' + +const sessions = vi.hoisted(() => ({ + authenticateExternalUserSession: 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(), + searchDirectory: vi.fn(), + DIRECTORY_SEARCH_MAX_QUERY_LENGTH: 200, +})) +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')) +} + +const warn = vi.spyOn(rootLogger, 'warn').mockImplementation(() => undefined) + +/** + * The handler-level 503 is marked as a retryable outage: both headers, and one + * warn log naming the event and carrying the outage cause (the witness that the + * marking path ran, not only the response). + */ +function expectMarkedAuthorityUnavailable(response: request.Response, event: string): void { + expect(response.headers['retry-after']).toBe('2') + expect(response.headers['cache-control']).toBe('no-store') + expect(warn).toHaveBeenCalledOnce() + expect(warn).toHaveBeenCalledWith( + expect.objectContaining({ + event, + err: expect.objectContaining({ message: 'private pg-pool sentinel' }), + }), + 'external authority is unavailable' + ) +} + +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', + role: 'admin', + ...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') + expectMarkedAuthorityUnavailable(response, 'external_auth_authority_unavailable') + }) + + 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') + expectMarkedAuthorityUnavailable(response, 'external_auth_authority_unavailable') + }) + }) + + 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, + }, + }) + expectMarkedAuthorityUnavailable(response, 'external_auth_authority_unavailable') + }) + + 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, + }, + }) + expectMarkedAuthorityUnavailable(response, 'external_auth_authority_unavailable') + }) + + 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, + }, + }) + expectMarkedAuthorityUnavailable(response, 'external_auth_authority_unavailable') + }) + + 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, + }, + }) + expectMarkedAuthorityUnavailable(response, 'external_auth_authority_unavailable') + }) + + 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, + }, + }) + expectMarkedAuthorityUnavailable(response, 'external_auth_authority_unavailable') + }) + + 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() + }) + + 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() + }) + }) + + describe('GET /external/directory/search (team parameter authorization)', () => { + function directoryApp() { + const value = express() + value.use(express.json()) + value.use(createExternalDirectoryRouter()) + value.use(clerumErrorHandler) + return value + } + + it('answers the flat 503 authority_unavailable marked as a retryable outage', async () => { + liveTeam.getLiveTeamMembership.mockRejectedValue(unavailable()) + + const response = await request(directoryApp()) + .get('/external/directory/search') + .query({ teamId: 'team-1', q: 'synthetic' }) + .set('x-forwarded-for', '203.0.113.44') + .set('x-user-session-token', 'synthetic-session-token') + + expect(response.status).toBe(503) + expect(response.body).toEqual({ error: 'authority_unavailable' }) + expectMarkedAuthorityUnavailable(response, 'external_team_authority_unavailable') + expect(liveTeam.getLiveTeamMembership).toHaveBeenCalledWith('user-1', 'team-1', { + onDatabaseFailure: externalSessionDatabaseFailure, + }) + expect(directory.searchDirectory).not.toHaveBeenCalled() + }) + }) +}) 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..1b0756a063 --- /dev/null +++ b/control-api/test/routes.externalAuth.googleLoginRejection.test.ts @@ -0,0 +1,144 @@ +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 { 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, + })), +})) + +/** + * 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 +} + +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()) + Object.values(requestLog).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 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(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("answers dev's 401 for a retired Google account", 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(401) + expect(response.body).toEqual({ error: 'Unauthorized' }) + expect(directory.googleLoginData).toHaveBeenCalledWith({ + email: 'user@example.invalid', + name: 'User', + picture: undefined, + }) + }) +}) diff --git a/control-api/test/routes.externalDirectory.test.ts b/control-api/test/routes.externalDirectory.test.ts new file mode 100644 index 0000000000..d48baabad0 --- /dev/null +++ b/control-api/test/routes.externalDirectory.test.ts @@ -0,0 +1,182 @@ +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(), + checkAndIncrement: 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 } +}) +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() + 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', + authGeneration: 1, + }) + mocks.searchDirectory.mockResolvedValue({ items: [] }) + mocks.checkAndIncrement.mockResolvedValue(allowedRateLimitResult()) + }) + + 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({ onDatabaseFailure: expect.any(Function) }) + ) + expect(mocks.searchDirectory).toHaveBeenCalledWith('team-1', 'person') + }) + + 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).toEqual({ error: 'Forbidden' }) + expect(mocks.membership).toHaveBeenCalledWith( + 'user-1', + 'team-1', + expect.objectContaining({ onDatabaseFailure: expect.any(Function) }) + ) + 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("answers dev's 200 with an empty page for a blank query", async () => { + mocks.membership.mockResolvedValue({ teamId: 'team-1', role: 'member' }) + + const response = await request(app()) + .get('/external/directory/search?teamId=team-1&q=') + .set('x-user-session-token', 'session') + + expect(response.status).toBe(200) + expect(response.body).toEqual({ items: [] }) + // Witness: the blank query reached the directory service. + expect(mocks.searchDirectory).toHaveBeenCalledWith('team-1', '') + }) + + it('relays the directory item shape unchanged', async () => { + mocks.membership.mockResolvedValue({ teamId: 'team-1', role: 'member' }) + const page = { + items: [{ id: 'u1', email: 'a@example.test', name: null, display_name: 'A' }], + } + mocks.searchDirectory.mockResolvedValueOnce(page) + + const response = await request(app()) + .get('/external/directory/search?teamId=team-1&q=a') + .set('x-user-session-token', 'session') + + expect(response.status).toBe(200) + expect(response.body).toEqual(page) + expect(mocks.searchDirectory).toHaveBeenCalledWith('team-1', 'a') + }) + + 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') + }) + + 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()) + .get('/external/directory/search?teamId=team-1&q=person') + .set('x-user-session-token', 'session') + .expect(200) + + // Witness: the search ran with live membership and its budget. + expect(mocks.membership).toHaveBeenCalledWith( + 'user-1', + 'team-1', + expect.objectContaining({ onDatabaseFailure: expect.any(Function) }) + ) + 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() + }) +}) 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/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.externalGfs.test.ts b/control-api/test/routes.externalGfs.test.ts index 35035194e8..1f26b7f728 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 @@ -22,14 +23,15 @@ 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()) 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', @@ -96,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), @@ -149,6 +159,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() @@ -186,6 +219,7 @@ async function buildAdminGrantsApp(sub: string) { beforeEach(() => { mockVerifyExternalSessionToken.mockReset() mockSignGfsToken.mockReset() + mockSignCurrentExternalGfsUserToken.mockReset() mockQuery.mockReset() mockAppendPermissionEvents.mockReset() mockWithTransaction.mockReset() @@ -205,13 +239,31 @@ 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 }) afterEach(() => vi.unstubAllGlobals()) -const auth = () => mockVerifyExternalSessionToken.mockReturnValue(SESSION) +const auth = () => + mockVerifyExternalSessionToken.mockResolvedValue({ + status: 'authenticated', + contract: 'v1', + claims: SESSION, + }) function activeSessionLifecycleResult( text: string @@ -326,23 +378,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 () => { @@ -351,26 +415,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 @@ -379,22 +439,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) }) @@ -406,28 +466,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 @@ -1011,7 +1071,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) @@ -1242,8 +1305,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 () => { @@ -1251,19 +1312,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 @@ -1291,9 +1353,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 () => { @@ -1324,20 +1385,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', @@ -1385,36 +1446,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) @@ -1541,6 +1601,46 @@ 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[]) => { + 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) + } + 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([ @@ -1669,17 +1769,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') ) @@ -1692,20 +1792,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) @@ -2250,7 +2350,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() @@ -2414,6 +2517,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[]) => { diff --git a/control-api/test/routes.externalGfsRateLimitFailClosed.realPostgres.integration.test.ts b/control-api/test/routes.externalGfsRateLimitFailClosed.realPostgres.integration.test.ts index 3b1ba053ee..062388e9bb 100644 --- a/control-api/test/routes.externalGfsRateLimitFailClosed.realPostgres.integration.test.ts +++ b/control-api/test/routes.externalGfsRateLimitFailClosed.realPostgres.integration.test.ts @@ -23,6 +23,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 4b3a837e04..e0d55bec44 100644 --- a/control-api/test/routes.externalGfsRateLimitStress.realPostgres.integration.test.ts +++ b/control-api/test/routes.externalGfsRateLimitStress.realPostgres.integration.test.ts @@ -49,6 +49,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.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) + }) +}) diff --git a/control-api/test/routes.externalInvitations.unavailable.test.ts b/control-api/test/routes.externalInvitations.unavailable.test.ts index 484e3ce2ce..f7af1d0ad0 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,11 +23,15 @@ const directory = vi.hoisted(() => ({ })) vi.mock('../src/services/directory/index.js', () => directory) -vi.mock('../src/middleware/rateLimitMiddleware.js', () => ({ - rateLimitMiddleware: - () => (_req: express.Request, _res: express.Response, next: express.NextFunction) => - next(), +const database = vi.hoisted(() => ({ + query: vi.fn(), + withTransaction: vi.fn(), })) +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/externalSessionAuth.js', () => ({ rejectBodyUserTeamMismatch: ( _req: express.Request, @@ -35,13 +39,24 @@ 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() + }, +})) +const tokenSigner = vi.hoisted(() => ({ sign: vi.fn(() => 'synthetic-session-token') })) +vi.mock('../src/utils/auth/externalSessionAuthToken.js', () => ({ + signExternalSessionToken: tokenSigner.sign, })) -const sessionToken = vi.hoisted(() => ({ signExternalSessionToken: vi.fn(() => 'session-token') })) -vi.mock('../src/utils/auth/externalSessionAuthToken.js', () => sessionToken) // Router-level harness: proves the ROUTE GUARDS rethrow instead of swallowing. // The real app.ts middleware is covered separately in @@ -64,6 +79,15 @@ 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, + 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 () => { @@ -82,162 +106,196 @@ describe('external invitation routes when the hub is unavailable', () => { expect(res.body.error).toBe('member_registration_unavailable') }) - 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') - expect(res.status).toBe(400) - expect(res.body.error).toBe('invalid_invitation') + it('issues a session only for the first acceptance and returns a replay without one', async () => { + flow.validateInvitationFlowToken.mockResolvedValue({ + email: 'a@b.c', + invitationUuid: 'database-capability', + }) + const replayData = { + accepted: true as const, + userId: '00000000-0000-4000-8000-000000000001', + email: 'a@b.c', + teamId: null, + role: 'member', + authGeneration: 1, + replay: true as const, + } + directory.acceptInvitationForEmailInTransaction + .mockResolvedValueOnce({ + data: { + accepted: true, + userId: '00000000-0000-4000-8000-000000000001', + email: 'a@b.c', + teamId: null, + 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 }, + }) + database.query.mockImplementation(async sql => { + const text = String(sql) + 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}`) + }) + + const first = await request(app()) + .post('/external/invitations/accept') + .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: 'synthetic-one-use-flow' }) + + expect(first.status).toBe(200) + expect(first.body.token).toBe('synthetic-session-token') + expect(first.body).not.toHaveProperty('replay') + expect(replay.status).toBe(200) + expect(replay.body).toEqual(replayData) + expect(replay.body).not.toHaveProperty('token') + expect(replay.body).not.toHaveProperty('sessionContract') + expect(tokenSigner.sign).toHaveBeenCalledTimes(1) + // 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) }) - it('issues a normal session only after trusted password-reset validation and completion', async () => { + 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' }) + + 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({ - email: 'member@example.invalid', - invitationUuid: 'Synthetic-producer-token-1', + email: 'a@b.c', + invitationUuid: 'database-secret-capability', }) directory.getInvitationByToken.mockResolvedValue({ - id: 'database-row-1', - token: 'Synthetic-producer-token-1', - email: 'member@example.invalid', + id: '00000000-0000-4000-8000-000000000200', + email: 'a@b.c', purpose: 'password_reset', status: 'pending', }) directory.setInvitationPasswordForEmail.mockResolvedValue({ data: { - id: 'database-row-1', - userId: 'user-1', - email: 'member@example.invalid', - teamId: null, - role: 'member', - purpose: 'password_reset', - status: 'accepted', - lifecycleState: 'active', - authGeneration: 7, + id: '00000000-0000-4000-8000-000000000200', + userId: '00000000-0000-4000-8000-000000000001', passwordUpdated: true, + authGeneration: 1, + lifecycleState: 'active', + token: 'synthetic-producer-session-token', sessionContext: { - userId: 'user-1', - email: 'member@example.invalid', - teamId: 'team-1', - role: 'admin', - authGeneration: 7, + userId: '00000000-0000-4000-8000-000000000001', + email: 'a@b.c', + teamId: null, + role: 'member', + authGeneration: 1, }, }, }) - const res = await request(app()) - .post('/external/invitations/password-token') - .send({ - token: 'Synthetic-trusted-reset-proof', - email: 'member@example.invalid', - invitationId: 'database-row-1', - password: 'Synthetic-new-password', - }) - .expect(200) + const response = await request(app()).post('/external/invitations/password-token').send({ + email: 'a@b.c', + token: 'synthetic-member-registration-flow-token', + invitationId: '00000000-0000-4000-8000-000000000200', + password: 'synthetic-valid-password', + }) - expect(flow.validateInvitationFlowToken).toHaveBeenCalledWith( - 'Synthetic-trusted-reset-proof', - 'member@example.invalid' - ) + expect(response.status).toBe(200) + expect(response.body.reauthenticationRequired).toBe(true) + expect(response.body).not.toHaveProperty('token') + expect(response.body).not.toHaveProperty('sessionContext') + expect(response.headers['set-cookie']).toBeUndefined() + expect(directory.getInvitationByToken).toHaveBeenCalledWith('database-secret-capability') expect(directory.setInvitationPasswordForEmail).toHaveBeenCalledWith( - 'member@example.invalid', - 'database-row-1', - 'Synthetic-new-password' + 'a@b.c', + '00000000-0000-4000-8000-000000000200', + 'synthetic-valid-password' ) - expect(sessionToken.signExternalSessionToken).toHaveBeenCalledWith({ - userId: 'user-1', - email: 'member@example.invalid', - teamId: 'team-1', - role: 'admin', - authGeneration: 7, - }) - expect(res.body.token).toBe('session-token') - expect(res.body).not.toHaveProperty('sessionContext') + expect(tokenSigner.sign).not.toHaveBeenCalled() }) - it('does not issue a session when reset proof or pending state is invalid', async () => { - directory.setInvitationPasswordForEmail.mockReset() - sessionToken.signExternalSessionToken.mockReset().mockReturnValue('session-token') - flow.validateInvitationFlowToken.mockRejectedValue(new Error('invalid')) - const invalidProof = await request(app()) - .post('/external/invitations/password-token') - .send({ - token: 'Synthetic-invalid-reset-proof', - email: 'member@example.invalid', - invitationId: 'reset-1', - password: 'Synthetic-new-password', - }) - .expect(400) - expect(invalidProof.body.error).toBe('invalid_invitation') - + it('rejects a recovery row id that does not match the trusted token capability', async () => { flow.validateInvitationFlowToken.mockResolvedValue({ - email: 'member@example.invalid', - invitationUuid: 'Synthetic-producer-token-1', + email: 'a@b.c', + invitationUuid: 'database-secret-capability', }) directory.getInvitationByToken.mockResolvedValue({ - id: 'database-row-1', - token: 'Synthetic-producer-token-1', - email: 'member@example.invalid', + id: '00000000-0000-4000-8000-000000000201', + email: 'a@b.c', purpose: 'password_reset', - status: 'accepted', + status: 'pending', }) - await request(app()) - .post('/external/invitations/password-token') - .send({ - token: 'Synthetic-redeemed-reset-proof', - email: 'member@example.invalid', - invitationId: 'database-row-1', - password: 'Synthetic-new-password', - }) - .expect(409) - expect(directory.setInvitationPasswordForEmail).not.toHaveBeenCalled() - expect(sessionToken.signExternalSessionToken).not.toHaveBeenCalled() - - directory.getInvitationByToken.mockReset().mockResolvedValue({ - id: 'database-row-1', - token: 'Synthetic-producer-token-1', - email: 'member@example.invalid', - purpose: 'password_reset', - status: 'pending', + const response = await request(app()).post('/external/invitations/password-token').send({ + email: 'a@b.c', + token: 'synthetic-member-registration-flow-token', + invitationId: '00000000-0000-4000-8000-000000000200', + password: 'synthetic-valid-password', }) - directory.setInvitationPasswordForEmail.mockResolvedValue({ error: 'not_pending' }) - await request(app()) - .post('/external/invitations/password-token') - .send({ - token: 'Synthetic-raced-reset-proof', - email: 'member@example.invalid', - invitationId: 'database-row-1', - password: 'Synthetic-new-password', - }) - .expect(409) - expect(sessionToken.signExternalSessionToken).not.toHaveBeenCalled() + + expect(response.status).toBe(403) + expect(directory.setInvitationPasswordForEmail).not.toHaveBeenCalled() + expect(tokenSigner.sign).not.toHaveBeenCalled() }) - it('rejects a database row ID that does not match the trusted flow token', async () => { - flow.validateInvitationFlowToken.mockResolvedValue({ - email: 'member@example.invalid', - invitationUuid: 'Synthetic-producer-token-1', - }) - directory.getInvitationByToken.mockResolvedValue({ - id: 'database-row-1', - token: 'Synthetic-producer-token-1', - email: 'member@example.invalid', - purpose: 'password_reset', - status: 'pending', - }) + it('lists invitations only for the authenticated email', async () => { + directory.listPendingInvitations.mockResolvedValue([]) - const res = await request(app()) - .post('/external/invitations/password-token') - .send({ - token: 'Synthetic-trusted-reset-proof', - email: 'member@example.invalid', - invitationId: 'another-row', - password: 'Synthetic-new-password', - }) - .expect(403) + const res = await request(app()).get('/external/invitations/pending?email=victim@example.com') - expect(res.body.error).toBe('forbidden') - expect(directory.setInvitationPasswordForEmail).not.toHaveBeenCalled() - expect(sessionToken.signExternalSessionToken).not.toHaveBeenCalled() + expect(res.status).toBe(200) + expect(directory.listPendingInvitations).toHaveBeenCalledWith('caller@example.com') + expect(directory.listPendingInvitations).not.toHaveBeenCalledWith('victim@example.com') + }) + + 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 () => { + flow.validateInvitationFlowToken.mockRejectedValue(new Error('invalid_invitation')) + const res = await request(app()).get('/external/invitations/token/some-token') + expect(res.status).toBe(400) + expect(res.body.error).toBe('invalid_invitation') }) it('POST desktop-authorization returns 503, NOT 404, when the hub-rejection message shape collides with the "(404)" not_found string match', async () => { diff --git a/control-api/test/routes.externalMemberVisibility.test.ts b/control-api/test/routes.externalMemberVisibility.test.ts new file mode 100644 index 0000000000..ea5049cd5f --- /dev/null +++ b/control-api/test/routes.externalMemberVisibility.test.ts @@ -0,0 +1,318 @@ +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(), + getMe: 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({ onDatabaseFailure: expect.any(Function) }) + ) +} + +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() + } + ) + + describe('session probe /me', () => { + const mePath = '/external/users/user-1/me' + + it('answers a revoked session with the invalid_session envelope', async () => { + const token = sessionToken() + authority.validateLegacyUserSession.mockResolvedValue({ + status: 'revoked', + reason: 'legacy_token_revoked', + }) + + const response = await request(app()) + .get(mePath) + .set('x-user-session-token', token) + .expect(401) + + expect(response.body).toEqual({ + error: { + code: 'invalid_session', + message: 'The session is not valid.', + correlationId: expect.any(String), + retryable: false, + }, + }) + // The live session check ran, and the revoked session stopped before the read. + expect(authority.validateLegacyUserSession).toHaveBeenCalledTimes(1) + expect(authority.validateLegacyUserSession).toHaveBeenCalledWith( + token, + expect.objectContaining({ userId: 'user-1' }), + expect.anything() + ) + expect(directory.getMe).not.toHaveBeenCalled() + }) + + it('keeps the bare 401 on the other users routes for the same revoked session', async () => { + const token = sessionToken() + authority.validateLegacyUserSession.mockResolvedValue({ + status: 'revoked', + reason: 'legacy_token_revoked', + }) + + await request(app()) + .get('/external/users/user-1/teams') + .set('x-user-session-token', token) + .expect(401, { error: 'Unauthorized' }) + + expect(authority.validateLegacyUserSession).toHaveBeenCalledTimes(1) + expect(directory.listTeams).not.toHaveBeenCalled() + }) + + it('answers a missing token with the invalid_session envelope', async () => { + const response = await request(app()).get(mePath).expect(401) + + expect(response.body.error.code).toBe('invalid_session') + expect(authority.validateLegacyUserSession).not.toHaveBeenCalled() + expect(directory.getMe).not.toHaveBeenCalled() + }) + + it('serves a live session through the rate limits and the user match', async () => { + directory.getMe.mockResolvedValue({ user: { id: 'user-1' } }) + + const response = await request(app()) + .get(`${mePath}?teamId=team-1`) + .set('x-user-session-token', sessionToken()) + .expect(200, { user: { id: 'user-1' } }) + + expect(response.headers.ratelimit).toEqual(expect.any(String)) + expect(directory.getMe).toHaveBeenCalledWith('user-1', 'team-1') + }) + + it('denies another user /me after checking live session authority', async () => { + await request(app()) + .get('/external/users/unrelated-user/me') + .set('x-user-session-token', sessionToken()) + .expect(403, { error: 'Forbidden' }) + + expect(authority.validateLegacyUserSession).toHaveBeenCalledTimes(1) + expect(directory.getMe).not.toHaveBeenCalled() + }) + }) +}) diff --git a/control-api/test/routes.externalMembers.test.ts b/control-api/test/routes.externalMembers.test.ts index 40d4caa90f..85cb1c5154 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(), @@ -21,7 +25,10 @@ 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 } + } + externalReq.externalAuth = { userId: 'manager-1', } next() @@ -30,6 +37,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()) @@ -45,11 +57,19 @@ 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 () => { directoryMock.createManagedInvitationForUser.mockResolvedValueOnce({ - invitation: { id: 'inv-1' }, + invitation: { id: 'inv-1', token: 'secret' }, }) await request(makeApp()) @@ -69,6 +89,73 @@ describe('external members routes', () => { ) }) + 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.', 'a@b.c'])( + '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' }], + '' + ) + } + ) + + 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') @@ -82,6 +169,78 @@ describe('external members routes', () => { expect(directoryMock.createManagedInvitationForUser).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('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('rejects an upper-case copy of the caller id as a managed self target', async () => { + // One answer each, consumed by the witness requests below. + directoryMock.updateManagedMemberRoleForUser.mockResolvedValueOnce({ + membership: { team_id: 'team-1', user_id: 'target-1', role: 'member', status: 'active' }, + }) + directoryMock.deleteManagedMemberForUser.mockResolvedValueOnce({ + deleted: { team_id: 'team-1', user_id: 'target-1', role: 'member', status: 'deleted' }, + }) + + await request(makeApp()) + .patch('/external/members/MANAGER-1/teams/team-1/role') + .send({ role: 'member' }) + .expect(400, { error: 'invalid_target' }) + await request(makeApp()) + .delete('/external/members/MANAGER-1/teams/team-1') + .expect(400, { error: 'invalid_target' }) + expect(directoryMock.updateManagedMemberRoleForUser).not.toHaveBeenCalled() + expect(directoryMock.deleteManagedMemberForUser).not.toHaveBeenCalled() + + // Witness: a different user's id reaches each service once. + await request(makeApp()) + .patch('/external/members/target-1/teams/team-1/role') + .send({ role: 'member' }) + .expect(200) + await request(makeApp()).delete('/external/members/target-1/teams/team-1').expect(200) + expect(directoryMock.updateManagedMemberRoleForUser).toHaveBeenCalledOnce() + expect(directoryMock.updateManagedMemberRoleForUser).toHaveBeenCalledWith( + 'manager-1', + 'target-1', + 'team-1', + 'member' + ) + expect(directoryMock.deleteManagedMemberForUser).toHaveBeenCalledOnce() + expect(directoryMock.deleteManagedMemberForUser).toHaveBeenCalledWith( + 'manager-1', + 'target-1', + 'team-1' + ) + }) + + it('creates invitations without an added user quota', async () => { + directoryMock.createManagedInvitationForUser.mockResolvedValue({ + invitation: { id: 'inv-parity' }, + }) + await request(makeApp()) + .post('/external/members/invitations') + .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 () => { await request(makeApp()) .delete('/external/members/target-1') diff --git a/control-api/test/routes.externalMembersInvitationContract.test.ts b/control-api/test/routes.externalMembersInvitationContract.test.ts new file mode 100644 index 0000000000..a2d9f78190 --- /dev/null +++ b/control-api/test/routes.externalMembersInvitationContract.test.ts @@ -0,0 +1,140 @@ +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' +import { + MemberRegistrationMisconfiguredError, + MemberRegistrationUnavailableError, + memberRegistrationErrorResponse, +} from '../src/services/memberRegistrationErrors.js' + +const invitationErrorContract = JSON.parse( + readFileSync( + new URL('../../tests/contracts/external-member-invitation-errors.json', import.meta.url), + 'utf8' + ) +) 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(), + 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).not.toHaveBeenCalled() + }) + + 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-api/test/routes.externalNotificationPreferencesRateLimit.test.ts b/control-api/test/routes.externalNotificationPreferencesRateLimit.test.ts new file mode 100644 index 0000000000..0fb52f18d1 --- /dev/null +++ b/control-api/test/routes.externalNotificationPreferencesRateLimit.test.ts @@ -0,0 +1,68 @@ +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 dev parity', () => { + 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(), + backendAvailable: true, + }) + }) + + 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('preserves uncharged preference put behavior', async () => { + await request(makeApp()) + .put('/external/me/notification-preferences') + .send({ preferredMedium: 'email' }) + .expect(200) + expect(preferencesMock.upsertUserNotificationPreferences).toHaveBeenCalled() + expect(rateLimitMock.checkAndIncrement).not.toHaveBeenCalled() + }) +}) 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..fd1a469bef --- /dev/null +++ b/control-api/test/routes.externalNotifications.currentness.realPostgres.integration.test.ts @@ -0,0 +1,239 @@ +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(), +})) +const heartbeatAuthority = vi.hoisted(() => ({ + gate: null as Promise | null, + heldObservations: 0, +})) + +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/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 (the batch observation and any 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(), + ], +})) +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/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('withholds a real V2 notification batch when revoke-all commits between its read and its observation', 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() + const heartbeatGate = deferred() + heartbeatAuthority.gate = heartbeatGate.promise + heartbeatAuthority.heldObservations = 0 + 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: 'batch-event-id', + cursor: 'batch-cursor', + approvalRequestId: 'batch-approval-id', + status: 'approved', + }, + ]) + // The batch observation starts after the read resolves and is held at the + // gate with any heartbeat observation. Release them once one is waiting: + // every one of them 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') + // The batch read before the revocation committed is observed after it, on + // the real database, so it is never written and the stream closes. + expect(response.text).not.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(frames.some(frame => frame.type === 'approval.updated')).toBe(false) + // Witness: the batch was read exactly once. + expect(producerDatabase.listEvents).toHaveBeenCalledOnce() + }) +}) 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..e566d7e73d --- /dev/null +++ b/control-api/test/routes.externalNotifications.currentness.test.ts @@ -0,0 +1,389 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import express from 'express' +import { EventEmitter } from 'node:events' +import request from 'supertest' +import { config } from '../src/config.js' +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(), + backendFailure: vi.fn(), + listener: null as (EventEmitter & { release: () => void }) | null, +})) + +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: ( + _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/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 } +} + +type StreamFrame = { type: string; id?: string; reason?: string } + +function parseFrames(text: string): StreamFrame[] { + return text + .split('\n') + .filter(line => line.length > 0) + .map(line => JSON.parse(line) as StreamFrame) +} + +function batchEvent() { + return { + eventType: 'approval.updated', + id: 'batch-event-id', + cursor: 'batch-cursor', + approvalRequestId: 'batch-approval-id', + status: 'approved', + } +} + +function openStream() { + const responsePromise = request(makeApp()).get('/external/notifications/stream') + const result: { response?: { status: number; text: string } } = {} + void responsePromise.then(response => { + result.response = { status: response.status, text: response.text } + }) + return { responsePromise, result } +} + +// Lets every pending microtask run: a resolved batch is observed and written +// (or withheld) entirely in microtasks. +function settle(): Promise { + return new Promise(resolve => setImmediate(resolve)) +} + +// Heartbeats are driven by hand so that the observation order is exact: the +// first observation after the three startup ones is the batch observation. +// vi.waitFor advances fake timers on every poll, so these tests wait with +// waitUntil instead. +function useManualHeartbeats() { + vi.useFakeTimers({ toFake: ['setInterval', 'clearInterval'] }) +} + +async function waitUntil(condition: () => boolean, describeState: () => string): Promise { + const deadline = Date.now() + 2_000 + while (!condition()) { + if (Date.now() > deadline) throw new Error(`timed out waiting: ${describeState()}`) + await new Promise(resolve => setTimeout(resolve, 5)) + } +} + +const current = { status: 'current' } as const +const denied = { status: 'denied', reason: 'v2_session_revoked_or_changed' } as const + +describe('external notification stream currentness', () => { + beforeEach(() => { + vi.resetAllMocks() + streamState.observer.mockResolvedValue({ status: 'current' }) + streamState.listActive.mockResolvedValue([]) + streamState.listEvents.mockResolvedValue([]) + streamState.newestCursor.mockReturnValue(null) + streamState.listener = null + streamState.openListener.mockImplementation(async () => { + const listener = new EventEmitter() as EventEmitter & { release: () => void } + listener.release = vi.fn() + streamState.listener = listener + return listener + }) + streamState.backendFailure.mockReset() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + 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("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('withholds a batch whose post-read observation is denied and closes with session_expired', async () => { + useManualHeartbeats() + const batch = deferred() + streamState.observer + .mockResolvedValueOnce(current) + .mockResolvedValueOnce(current) + .mockResolvedValueOnce(current) + .mockResolvedValue(denied) + streamState.listEvents.mockReturnValueOnce(batch.promise) + + const { responsePromise, result } = openStream() + await waitUntil( + () => streamState.listEvents.mock.calls.length > 0, + () => JSON.stringify(result.response) + ) + // The read itself is not preceded by an observation of its own. + expect(streamState.observer).toHaveBeenCalledTimes(3) + batch.resolve([batchEvent()]) + await settle() + // A later heartbeat would close the stream too; it must find nothing left + // to do, because the batch observation already closed it. + vi.advanceTimersByTime(config.notificationStreamHeartbeatMs) + const response = await responsePromise + + expect(response.status).toBe(200) + const frames = parseFrames(response.text) + expect(frames.map(frame => frame.type)).toEqual(['notification.snapshot', 'stream.closing']) + expect(frames.at(-1)).toMatchObject({ type: 'stream.closing', reason: 'session_expired' }) + expect(response.text).not.toContain('batch-event-id') + // Witnesses: the batch was read once and observed once, after the read. + expect(streamState.listEvents).toHaveBeenCalledOnce() + expect(streamState.observer).toHaveBeenCalledTimes(4) + }) + + it('withholds a batch and ends without a frame when its post-read observation is unavailable', async () => { + useManualHeartbeats() + const batch = deferred() + streamState.observer + .mockResolvedValueOnce(current) + .mockResolvedValueOnce(current) + .mockResolvedValueOnce(current) + .mockResolvedValue({ status: 'unavailable', error: new Error('database unavailable') }) + streamState.listEvents.mockReturnValueOnce(batch.promise) + + const { responsePromise, result } = openStream() + await waitUntil( + () => streamState.listEvents.mock.calls.length > 0, + () => JSON.stringify(result.response) + ) + batch.resolve([batchEvent()]) + await settle() + vi.advanceTimersByTime(config.notificationStreamHeartbeatMs) + const response = await responsePromise + + expect(response.status).toBe(200) + // The stream ends after the snapshot: no batch, closing frame, heartbeat or + // backend error body. + const frames = parseFrames(response.text) + expect(frames.map(frame => frame.type)).toEqual(['notification.snapshot']) + expect(response.text).not.toContain('batch-event-id') + expect(response.text).not.toContain('session_backend_unavailable') + // Witnesses: the batch was read once and observed once, after the read. + expect(streamState.listEvents).toHaveBeenCalledOnce() + expect(streamState.observer).toHaveBeenCalledTimes(4) + }) + + it('writes an in-flight batch whose observation is current and closes on the next heartbeat after a revocation', async () => { + useManualHeartbeats() + const batch = deferred() + // Three startup observations and the batch observation are current; the + // next heartbeat observes the revocation. This is the accepted window: a + // revocation that commits after the batch observation. + streamState.observer + .mockResolvedValueOnce(current) + .mockResolvedValueOnce(current) + .mockResolvedValueOnce(current) + .mockResolvedValueOnce(current) + .mockResolvedValue(denied) + streamState.listEvents.mockReturnValueOnce(batch.promise) + + const { responsePromise, result } = openStream() + await waitUntil( + () => streamState.listEvents.mock.calls.length > 0, + () => JSON.stringify(result.response) + ) + batch.resolve([batchEvent()]) + await settle() + // The batch was observed (current) before any heartbeat ran. + expect(streamState.observer).toHaveBeenCalledTimes(4) + vi.advanceTimersByTime(config.notificationStreamHeartbeatMs) + const response = await responsePromise + + expect(response.status).toBe(200) + const frames = parseFrames(response.text) + expect(frames.map(frame => frame.type)).toEqual([ + 'notification.snapshot', + 'approval.updated', + 'stream.closing', + ]) + expect(frames[1]).toMatchObject({ id: 'batch-event-id' }) + expect(frames.at(-1)).toMatchObject({ type: 'stream.closing', reason: 'session_expired' }) + // Three startup observations, the batch observation and the heartbeat. + expect(streamState.observer).toHaveBeenCalledTimes(5) + }) + + it('reads and writes nothing for a notification that arrives before the snapshot frame', async () => { + useManualHeartbeats() + const snapshot = deferred() + streamState.listActive.mockReturnValueOnce(snapshot.promise) + streamState.listEvents.mockResolvedValueOnce([batchEvent()]).mockResolvedValue([]) + streamState.observer + .mockResolvedValueOnce(current) + .mockResolvedValueOnce(current) + .mockResolvedValueOnce(current) + .mockResolvedValueOnce(current) + .mockResolvedValue(denied) + + const { responsePromise, result } = openStream() + await waitUntil( + () => streamState.listActive.mock.calls.length > 0, + () => JSON.stringify(result.response) + ) + streamState.listener!.emit('notification') + await settle() + // Witness: startup is parked at the snapshot read, with the listener live. + expect(streamState.listActive).toHaveBeenCalledOnce() + expect(streamState.listener!.listenerCount('notification')).toBe(1) + expect(streamState.listEvents).not.toHaveBeenCalled() + + snapshot.resolve([]) + await waitUntil( + () => streamState.listEvents.mock.calls.length > 0, + () => JSON.stringify(result.response) + ) + await settle() + // Three startup observations and the catch-up batch's observation. + expect(streamState.observer).toHaveBeenCalledTimes(4) + vi.advanceTimersByTime(config.notificationStreamHeartbeatMs) + const response = await responsePromise + + expect(response.status).toBe(200) + // The event queued during startup is delivered by the catch-up after the + // snapshot frame, never before it. + const frames = parseFrames(response.text) + expect(frames.map(frame => frame.type)).toEqual([ + 'notification.snapshot', + 'approval.updated', + 'stream.closing', + ]) + expect(frames[1]).toMatchObject({ id: 'batch-event-id' }) + expect(streamState.observer).toHaveBeenCalledTimes(5) + }) + + 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() + }) +}) 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..93bc5611b1 --- /dev/null +++ b/control-api/test/routes.externalNotifications.heartbeat.test.ts @@ -0,0 +1,253 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import express from 'express' +import { EventEmitter } from 'node:events' +import request from 'supertest' +import { notificationStreamEventsSentTotal } from '../src/observability/metrics.js' +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', + } +} + +type StreamFrame = { type: string; id?: string; reason?: string } + +function parseFrames(text: string): StreamFrame[] { + return text + .split('\n') + .filter(line => line.length > 0) + .map(line => JSON.parse(line) as StreamFrame) +} + +// Witness that a batch reached the response: the route counts every written +// approval.updated frame on the sent-events metric. +function writtenApprovalUpdates(): number { + return vi + .mocked(notificationStreamEventsSentTotal.inc) + .mock.calls.filter( + ([labels]) => + (labels as { event_type?: string } | undefined)?.event_type === 'approval.updated' + ).length +} + +function openStream() { + const responsePromise = request(makeApp()).get('/external/notifications/stream') + const result: { response?: { status: number; text: string } } = {} + void responsePromise.then(response => { + result.response = { status: response.status, text: response.text } + }) + return { responsePromise, result } +} + +describe('external notification stream authority checks per heartbeat and per batch', () => { + 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 at startup, once per heartbeat and once per non-empty batch, then closes on the next heartbeat after denial', async () => { + const { responsePromise, result } = openStream() + await vi.waitFor(() => + expect(streamState.listActive, JSON.stringify(result.response)).toHaveBeenCalledOnce() + ) + + for (const index of [1, 2, 3]) { + streamState.pendingEvents.push(approvalUpdated(index)) + streamState.listener!.emit('notification') + // Wait for the write, not only for the read: the batch observation runs + // between them, and the next event must start a batch of its own. + await vi.waitFor(() => expect(writtenApprovalUpdates()).toBe(index)) + } + + streamState.observer.mockResolvedValue({ + status: 'denied', + reason: 'v2_session_revoked_or_changed', + }) + const response = await responsePromise + + expect(response.status).toBe(200) + const frames = parseFrames(response.text) + 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, one for the + // heartbeat that closed the stream, and one per non-empty batch (three + // single-event batches). + expect(streamState.observer).toHaveBeenCalledTimes(3 + heartbeatFrames + 1 + 3) + }) + + it('observes authority once for a batch of several events, not once per event', async () => { + const { responsePromise, result } = openStream() + await vi.waitFor(() => + expect(streamState.listActive, JSON.stringify(result.response)).toHaveBeenCalledOnce() + ) + + streamState.pendingEvents.push(approvalUpdated(1), approvalUpdated(2), approvalUpdated(3)) + streamState.listener!.emit('notification') + await vi.waitFor(() => expect(writtenApprovalUpdates()).toBe(3)) + + streamState.observer.mockResolvedValue({ + status: 'denied', + reason: 'v2_session_revoked_or_changed', + }) + const response = await responsePromise + + const frames = parseFrames(response.text) + expect( + frames.filter(frame => frame.type === 'approval.updated').map(frame => frame.id) + ).toEqual(['event-1', 'event-2', 'event-3']) + expect(frames.at(-1)).toMatchObject({ type: 'stream.closing', reason: 'session_expired' }) + const heartbeatFrames = frames.filter(frame => frame.type === 'heartbeat').length + // One observation covers the whole three-event batch. + expect(streamState.observer).toHaveBeenCalledTimes(3 + heartbeatFrames + 1 + 1) + }) + + it('makes no authority observation for a wake-up whose read finds no events', async () => { + const { responsePromise, result } = openStream() + await vi.waitFor(() => + expect(streamState.listActive, JSON.stringify(result.response)).toHaveBeenCalledOnce() + ) + + for (let wakeUp = 0; wakeUp < 3; wakeUp += 1) { + const readsBefore = streamState.listEvents.mock.calls.length + streamState.listener!.emit('notification') + await vi.waitFor(() => + expect(streamState.listEvents.mock.calls.length).toBeGreaterThan(readsBefore) + ) + } + + streamState.observer.mockResolvedValue({ + status: 'denied', + reason: 'v2_session_revoked_or_changed', + }) + const response = await responsePromise + + const frames = parseFrames(response.text) + expect(frames.at(-1)).toMatchObject({ type: 'stream.closing', reason: 'session_expired' }) + // Witness: the empty reads did happen (the post-snapshot catch-up plus at + // least the three wake-ups). + expect(streamState.listEvents.mock.calls.length).toBeGreaterThanOrEqual(4) + expect(frames.some(frame => frame.type === 'approval.updated')).toBe(false) + const heartbeatFrames = frames.filter(frame => frame.type === 'heartbeat').length + // Startup, heartbeats and the closing heartbeat only: empty reads add none. + expect(streamState.observer).toHaveBeenCalledTimes(3 + heartbeatFrames + 1) + }) +}) diff --git a/control-api/test/routes.externalNotifications.wakeCoalesce.test.ts b/control-api/test/routes.externalNotifications.wakeCoalesce.test.ts new file mode 100644 index 0000000000..5dff1c298e --- /dev/null +++ b/control-api/test/routes.externalNotifications.wakeCoalesce.test.ts @@ -0,0 +1,233 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import express from 'express' +import { EventEmitter } from 'node:events' +import request from 'supertest' +import { + notificationStreamEventsFilteredTotal, + notificationStreamEventsSentTotal, +} from '../src/observability/metrics.js' +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, + // No heartbeat can fire during the test, so only a wake-up can deliver. + notificationStreamHeartbeatMs: 600_000, + notificationStreamMaxLifetimeMs: 1_500, + }, + } +}) +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', + } +} + +type StreamFrame = { type: string; id?: string; reason?: string } + +function parseFrames(text: string): StreamFrame[] { + return text + .split('\n') + .filter(line => line.length > 0) + .map(line => JSON.parse(line) as StreamFrame) +} + +// Witness that a batch reached the response: the route counts every written +// approval.updated frame on the sent-events metric. +function writtenApprovalUpdates(): number { + return vi + .mocked(notificationStreamEventsSentTotal.inc) + .mock.calls.filter( + ([labels]) => + (labels as { event_type?: string } | undefined)?.event_type === 'approval.updated' + ).length +} + +function openStream() { + const responsePromise = request(makeApp()).get('/external/notifications/stream') + const result: { response?: { status: number; text: string } } = {} + void responsePromise.then(response => { + result.response = { status: response.status, text: response.text } + }) + return { responsePromise, result } +} + +// Witness that the post-snapshot catch-up flush finished: its empty read +// counts one filtered event before the flush returns. +function emptyReads(): number { + return vi + .mocked(notificationStreamEventsFilteredTotal.inc) + .mock.calls.filter( + ([labels]) => (labels as { reason?: string } | undefined)?.reason === 'no_new_active_events' + ).length +} + +function deferred() { + let resolve!: (value: T) => void + const promise = new Promise(res => { + resolve = res + }) + return { promise, resolve } +} + +describe('external notification stream wake-ups during an in-flight flush', () => { + beforeEach(() => { + vi.resetAllMocks() + streamState.listener = null + streamState.pendingEvents = [] + streamState.observer.mockResolvedValue({ status: 'current' }) + streamState.listActive.mockResolvedValue([]) + streamState.newestCursor.mockReturnValue(null) + 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('delivers an event whose wake-up arrives while the previous batch waits for authority', async () => { + const { responsePromise, result } = openStream() + await vi.waitFor(() => + expect(streamState.listActive, JSON.stringify(result.response)).toHaveBeenCalledOnce() + ) + // The post-snapshot catch-up flush has read nothing and returned, so the + // next wake-up starts a flush of its own. + await vi.waitFor(() => expect(emptyReads()).toBe(1)) + const readsBeforeFirstWakeUp = streamState.listEvents.mock.calls.length + expect(readsBeforeFirstWakeUp).toBe(1) + // Three startup observations: before headers, before and after the + // snapshot read. + expect(streamState.observer).toHaveBeenCalledTimes(3) + + const batchObservation = deferred<{ status: 'current' }>() + streamState.observer.mockImplementationOnce(() => batchObservation.promise) + streamState.pendingEvents.push(approvalUpdated(1)) + streamState.listener!.emit('notification') + // Witness that flush 1 has read event-1 and is parked in the per-batch + // authority observation. + await vi.waitFor(() => expect(streamState.observer).toHaveBeenCalledTimes(4)) + expect(streamState.listEvents.mock.calls.length).toBe(readsBeforeFirstWakeUp + 1) + + // event-2 is committed after flush 1 read, and its wake-up arrives while + // flush 1 is still in flight. + streamState.pendingEvents.push(approvalUpdated(2)) + streamState.listener!.emit('notification') + batchObservation.resolve({ status: 'current' }) + + // Both events are written long before the 1.5 s lifetime ends the stream. + await vi.waitFor(() => expect(writtenApprovalUpdates()).toBe(2), { timeout: 1_000 }) + + const response = await responsePromise + expect(response.status).toBe(200) + const frames = parseFrames(response.text) + expect( + frames.filter(frame => frame.type === 'approval.updated').map(frame => frame.id) + ).toEqual(['event-1', 'event-2']) + expect(frames.at(-1)).toMatchObject({ type: 'stream.closing', reason: 'max_lifetime' }) + expect(frames.filter(frame => frame.type === 'heartbeat')).toHaveLength(0) + // Counted from the first wake-up: the parked flush plus exactly one re-run + // for the wake-up recorded while it was in flight (3 reads in total with + // the post-snapshot catch-up). + expect(streamState.listEvents.mock.calls.length - readsBeforeFirstWakeUp).toBe(2) + expect(streamState.listEvents).toHaveBeenCalledTimes(3) + }) +}) diff --git a/control-api/test/routes.externalSessionAtomicExchange.test.ts b/control-api/test/routes.externalSessionAtomicExchange.test.ts new file mode 100644 index 0000000000..46db3e6333 --- /dev/null +++ b/control-api/test/routes.externalSessionAtomicExchange.test.ts @@ -0,0 +1,354 @@ +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(() => ({ + revokeAllUserSessions: vi.fn(), + revokeUserSession: vi.fn(), +})) +const issuance = vi.hoisted(() => ({ + exchangeLegacyExternalUserSession: vi.fn(), + issueExternalUserSession: vi.fn(), + selectExternalSessionRepresentation: 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/userSessionService.js', async importOriginal => { + const actual = await importOriginal() + return { ...actual, ...revocations } +}) +vi.mock('../src/services/auth/externalSessionIssuance.js', () => issuance) +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() + sessions.authenticateExternalUserSession.mockResolvedValue({ + status: 'authenticated', + contract: 'v1', + claims, + authorityContext: { + contract: 'v1', + userId: claims.userId, + tokenHash: 'source-hash', + issuedAt: claims.iat, + authGeneration: 1, + }, + policy: { issueV1: true, switchCompatibility: true }, + }) + issuance.issueExternalUserSession.mockResolvedValue({ + token: 'synthetic-stale-token', + contract: 'v1', + }) + revocations.revokeAllUserSessions.mockResolvedValue(1) + revocations.revokeUserSession.mockResolvedValue(true) + 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', 'synthetic-source-token') + .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' } }) + expect(issuance.exchangeLegacyExternalUserSession).toHaveBeenCalledWith( + expect.objectContaining({ + token: 'synthetic-source-token', + teamId: 'team-new', + role: 'admin', + claims, + }), + expect.objectContaining({ policy: expect.any(Object) }) + ) + expect(issuance.issueExternalUserSession).not.toHaveBeenCalled() + }) + + it('passes the requested role to the locked exchange and returns its replacement', async () => { + issuance.exchangeLegacyExternalUserSession.mockResolvedValue({ + status: 'issued', + 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', 'synthetic-source-token') + .send({ userId: claims.userId, email: claims.email, teamId: 'team-new', role: 'member' }) + .expect(200) + + expect(response.body).toEqual({ + token: 'synthetic-replacement-token', + 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 () => { + 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', 'synthetic-source-token') + .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, + 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', 'synthetic-source-token') + .expect(200, { revoked: 1 }) + + expect(revocations.revokeAllUserSessions).toHaveBeenCalledWith( + claims.userId, + 'user_revoked_all', + undefined, + authorityContext, + undefined, + { onDatabaseFailure: externalSessionDatabaseFailure } + ) + }) + + 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', 'synthetic-source-token') + .expect(200, { revoked: true }) + + expect(revocations.revokeUserSession).toHaveBeenCalledWith( + claims.userId, + 'target-session', + 'user_revoked', + undefined, + authorityContext, + { onDatabaseFailure: externalSessionDatabaseFailure } + ) + }) + + 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', 'synthetic-source-token') + .expect(200, { revoked: true }) + + expect(revocations.revokeUserSession).toHaveBeenCalledWith( + claims.userId, + v2Claims.sid, + 'logout', + undefined, + authorityContext, + { onDatabaseFailure: externalSessionDatabaseFailure } + ) + }) +}) diff --git a/control-api/test/routes.externalSessionRenewalRateLimit.test.ts b/control-api/test/routes.externalSessionRenewalRateLimit.test.ts new file mode 100644 index 0000000000..1b04263da8 --- /dev/null +++ b/control-api/test/routes.externalSessionRenewalRateLimit.test.ts @@ -0,0 +1,177 @@ +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(), + 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)) + value.use(clerumErrorHandler) + return value +} + +describe('external session route error boundaries and 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: 'synthetic-rotated-session', + expiresInSeconds: 3600, + 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('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(200) + expect(sessions.authenticateExternalUserSession).toHaveBeenCalledWith( + 'synthetic-session-token', + expect.objectContaining({ purpose: 'renew' }) + ) + expect(sessions.renewExternalUserSession).toHaveBeenCalledOnce() + expect(limiter.checkAndIncrement).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: 'synthetic-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: 'synthetic-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: 'synthetic-session-token' }) + + expect(response.status).toBe(200) + expect(response.body).toEqual({ + claims: { + userId: 'user-1', + email: 'user@example.test', + teamId: null, + role: 'member', + }, + }) + }) + + 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' }) + + 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') + }) +}) diff --git a/control-api/test/routes.externalSharedFilesystems.test.ts b/control-api/test/routes.externalSharedFilesystems.test.ts index 36c51842ef..fd68fce08d 100644 --- a/control-api/test/routes.externalSharedFilesystems.test.ts +++ b/control-api/test/routes.externalSharedFilesystems.test.ts @@ -1,13 +1,19 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import express from 'express' +import { DatabaseError } from 'pg' import request from 'supertest' +import { clerumErrorHandler } from '../src/http/errorHandler.js' +import { rootLogger } from '../src/observability/logger.js' import { createExternalSharedFilesystemsRouter } from '../src/routes/external/sharedFilesystems.js' +import { externalSessionDatabaseFailure } from '../src/services/auth/sessionDatabaseFailure.js' 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 rateLimitMock = vi.hoisted(() => ({ checkAndIncrement: vi.fn() })) const mockPoolQuery = vi.hoisted(() => vi.fn()) vi.mock('../src/db.js', () => ({ @@ -17,16 +23,28 @@ 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), })) +vi.mock('../src/services/rateLimiterService.js', () => rateLimitMock) vi.mock('../src/config.js', () => ({ config: { @@ -68,21 +86,92 @@ function buildApp(gateway: GatewayStub) { ), } app.use(createExternalSharedFilesystemsRouter(gatewayWithCatalog as never)) + app.use(clerumErrorHandler) return app } +const warn = vi.spyOn(rootLogger, 'warn').mockImplementation(() => undefined) + +// team_members.user_id and team_id are UUID columns: the real live-membership +// read only queries the pool for UUID ids. +const UUID_USER_ID = '1d2e3f4a-5b6c-4d7e-8f9a-0b1c2d3e4f5a' +const UUID_TEAM_ID = '7e6d5c4b-3a29-4f18-8e7d-6c5b4a392817' + +/** 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 +} + +/** + * Runs the real live-membership read against the mocked pool, so the route's + * own database-failure classifier decides, and makes only the team_members + * query fail with `failure`. + */ +async function failLiveMembershipRead(failure: Error): Promise { + const actual = await vi.importActual< + typeof import('../src/services/access/liveTeamAuthorization.js') + >('../src/services/access/liveTeamAuthorization.js') + mockGetLiveTeamMembership.mockImplementation(actual.getLiveTeamMembership) + mockVerifyExternalSessionToken.mockReturnValue({ + ...SESSION, + userId: UUID_USER_ID, + teamId: UUID_TEAM_ID, + }) + mockPoolQuery.mockImplementation(async (text: string) => { + if (text.includes('team_members')) throw failure + return { rows: [{ lifecycle_state: 'active', lifecycle_version: 1 }] } + }) +} + +function expectLiveMembershipReadOnce(): void { + const membershipReads = mockPoolQuery.mock.calls.filter(([text]) => + String(text).includes('team_members') + ) + expect(membershipReads).toEqual([[expect.any(String), [UUID_USER_ID, UUID_TEAM_ID]]]) +} + +/** Both outage headers and one warn log naming the event with the outage cause. */ +function expectMarkedUnavailable( + res: request.Response, + event: string, + cause: Record +): void { + expect(res.headers['retry-after']).toBe('2') + expect(res.headers['cache-control']).toBe('no-store') + expect(warn).toHaveBeenCalledOnce() + expect(warn).toHaveBeenCalledWith( + expect.objectContaining({ event, err: expect.objectContaining(cause) }), + 'external authority is unavailable' + ) +} + beforeEach(() => { + warn.mockClear() 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 }] }) 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(() => { vi.unstubAllGlobals() + vi.unstubAllEnvs() }) const validAuth = () => { @@ -90,6 +179,18 @@ const validAuth = () => { } describe('GET /external/contexts/:contextId/shared-filesystems', () => { + it('preserves context authorization without an added user quota', async () => { + validAuth() + 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', 'synthetic-session') + .expect(403) + expect(mockGetUserContexts).toHaveBeenCalled() + expect(rateLimitMock.checkAndIncrement).not.toHaveBeenCalled() + }) + it('returns merged spec+status for an accessible context', async () => { validAuth() mockGetUserContexts.mockResolvedValue({ userId: 'user-1', contextIds: ['ctx-a'] }) @@ -153,6 +254,66 @@ 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', { + onDatabaseFailure: externalSessionDatabaseFailure, + }) + }) + + 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 a live membership read hits a database outage', async () => { + const outage = databaseError('08006') + await failLiveMembershipRead(outage) + mockGetUserContexts.mockResolvedValue({ userId: UUID_USER_ID, contextIds: [] }) + const getResource = vi.fn() + const app = buildApp({ getResource }) + + const res = await request(app) + .get('/external/contexts/ctx-team/shared-filesystems') + .set('x-user-session-token', 'stale-team-token') + + expect(res.status).toBe(503) + expect(res.body).toEqual({ error: 'authority_unavailable' }) + expectMarkedUnavailable(res, 'external_shared_filesystems_authority_unavailable', { + name: 'ExternalSessionBackendUnavailableError', + }) + // The logged cause is the classified outage, which carries the pg error. + expect(warn.mock.calls[0]?.[0]).toMatchObject({ err: { cause: outage } }) + expectLiveMembershipReadOnce() + expect(getResource).not.toHaveBeenCalled() + }) + + it('keeps a programmer error from the live membership read on the sanitized 500 path', async () => { + vi.stubEnv('NODE_ENV', 'production') + await failLiveMembershipRead(new TypeError('synthetic membership read defect')) + mockGetUserContexts.mockResolvedValue({ userId: UUID_USER_ID, contextIds: [] }) + const getResource = vi.fn() + const app = buildApp({ getResource }) + + const res = await request(app) + .get('/external/contexts/ctx-team/shared-filesystems') + .set('x-user-session-token', 'stale-team-token') + + expect(res.status).toBe(500) + expect(res.body).toMatchObject({ error: 'Internal Server Error' }) + expect(JSON.stringify(res.body)).not.toContain('synthetic membership read defect') + expect(res.headers['retry-after']).toBeUndefined() + expectLiveMembershipReadOnce() + expect(getResource).not.toHaveBeenCalled() }) it('returns 403 when caller has no access to the context', async () => { @@ -185,6 +346,9 @@ describe('GET /external/contexts/:contextId/shared-filesystems', () => { expect(res.status).toBe(503) expect(res.body).toEqual({ error: 'context_reconciliation_unavailable' }) + expectMarkedUnavailable(res, 'external_shared_filesystems_context_reconciliation_failed', { + message: 'k8s unavailable', + }) expect(getResource).not.toHaveBeenCalled() }) 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..590a34e4b7 --- /dev/null +++ b/control-api/test/routes.externalTeamsFinalAuthority.realPostgres.integration.test.ts @@ -0,0 +1,608 @@ +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 { 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 { endPoolAndWaitForClients } from './helpers/realPostgresTeardown.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', + acceptV1: true, + issueV1: true, + acceptV2: false, + issueV2: false, + renewV2: false, + switchCompatibility: true, + minimumClientVersion: null, + enforceMinimumClient: false, +})) + +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/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 +}> + +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') +} + +// 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, + 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 + 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 + } + + async function resetPrincipal(label: string) { + 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), + ($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 + } + + 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 [ + { + 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: '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: 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 () => { + expect(invitationDelivery.registerAndSendInvitation).toHaveBeenCalledOnce() + }, + assertNoMutation: async () => { + expect(invitationDelivery.registerAndSendInvitation).not.toHaveBeenCalled() + }, + }, + { + 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') + }, + }, + ] + } + + 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 () => { + 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)}`) + } + } finally { + await adminPool?.end() + } + }) + + beforeEach(() => { + invitationDelivery.registerAndSendInvitation.mockReset() + invitationDelivery.registerAndSendInvitation.mockResolvedValue(undefined) + rateLimit.checkAndIncrement.mockResolvedValue({ + allowed: true, + count: 1, + remaining: 9, + resetMs: Date.now() + 60_000, + windowStartMs: Date.now(), + }) + }) + + 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('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('refuses a managed role change on a retired user who keeps an active membership', async () => { + await resetPrincipal('managed-role-retired-target') + // Witness: the same manager, route and membership change the active target's role. + await request(app()) + .patch(`/external/members/${targetUserId}/teams/${teamId}/role`) + .set('x-user-session-token', token) + .send({ role: 'inviter' }) + .expect(200) + const before = await databasePool.query<{ role: string; status: string }>( + `SELECT role, status FROM team_members WHERE team_id = $1 AND user_id = $2`, + [teamId, targetUserId] + ) + expect(before.rows[0]).toEqual({ role: 'inviter', status: 'active' }) + + // Retirement leaves team_members.status = 'active'. + await retireAdmin(databasePool, targetUserId, retirementActorId) + const response = await request(app()) + .patch(`/external/members/${targetUserId}/teams/${teamId}/role`) + .set('x-user-session-token', token) + .send({ role: 'member' }) + expect(response.status).toBe(404) + expect(response.body).toEqual({ error: 'not_found' }) + const after = await databasePool.query<{ role: string; status: string }>( + `SELECT role, status FROM team_members WHERE team_id = $1 AND user_id = $2`, + [teamId, targetUserId] + ) + expect(after.rows[0]).toEqual({ role: 'inviter', status: 'active' }) + }) + + 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') + }) + + 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(mutation.expectedStatus ?? 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(' ', '-')}`) + 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 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('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 } + ), + ]) + + // 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([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) + }) +}) diff --git a/control-api/test/routes.externalTeamsInvitations.test.ts b/control-api/test/routes.externalTeamsInvitations.test.ts new file mode 100644 index 0000000000..a6afe1d776 --- /dev/null +++ b/control-api/test/routes.externalTeamsInvitations.test.ts @@ -0,0 +1,102 @@ +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(), + renameTeam: 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, +})) +const rateLimitMock = vi.hoisted(() => ({ checkAndIncrement: vi.fn() })) +vi.mock('../src/services/rateLimiterService.js', () => rateLimitMock) + +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() + rateLimitMock.checkAndIncrement.mockResolvedValue({ + allowed: true, + count: 1, + remaining: 29, + resetMs: Date.now() + 60_000, + windowStartMs: Date.now(), + backendAvailable: true, + }) + }) + + it('never serializes the invitation acceptance capability', async () => { + directory.createManagedInvitationForUser.mockResolvedValueOnce({ + invitation: { id: 'inv-1', token: 'synthetic-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('synthetic-must-never-leave-control-api') + }) + + 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('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.test', role: 'member' }) + .expect(200) + expect(directory.createManagedInvitationForUser).toHaveBeenCalled() + expect(rateLimitMock.checkAndIncrement).not.toHaveBeenCalled() + }) +}) diff --git a/control-api/test/routes.externalTeamsLegacyAdminRevocation.realPostgres.integration.test.ts b/control-api/test/routes.externalTeamsLegacyAdminRevocation.realPostgres.integration.test.ts new file mode 100644 index 0000000000..d37a622d23 --- /dev/null +++ b/control-api/test/routes.externalTeamsLegacyAdminRevocation.realPostgres.integration.test.ts @@ -0,0 +1,246 @@ +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' +import { endPoolAndWaitForClients } from './helpers/realPostgresTeardown.js' + +const userId = '11111111-1111-4111-8111-111111111111' +const teamId = '22222222-2222-4222-8222-222222222222' +const targetUserId = '33333333-3333-4333-8333-333333333333' +let staleAdminToken: string + +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: 'legacy-admin-route-proof', + acceptV1: true, + issueV1: true, + acceptV2: false, + issueV2: false, + renewV2: false, + switchCompatibility: true, + minimumClientVersion: null, + enforceMinimumClient: false, +})) +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(), + renameTeam: vi.fn(), + updateManagedMemberRoleForUser: 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), + }, + } +}) +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, +})) + +type AdminMutation = Readonly<{ + name: string + invoke: (server: express.Express) => request.Test + handler: keyof Pick< + typeof directory, + 'renameTeam' | 'updateManagedMemberRoleForUser' | 'deleteManagedMemberForUser' + > +}> + +const mutations: readonly AdminMutation[] = [ + { + name: 'rename team', + invoke: server => + request(server).put(`/external/teams/${teamId}/name`).send({ name: 'Renamed' }), + handler: 'renameTeam', + }, + { + 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 +} + +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() }) + 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( + { + contract: 'v1', + userId, + email: 'admin@example.test', + teamId, + role: 'admin', + authGeneration: 1, + authenticationMethods: ['pwd'], + }, + { db: databasePool, 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, + }) + }) + + 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)}`) + } + } finally { + 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, + remaining: 9, + resetMs: Date.now() + 60_000, + windowStartMs: Date.now(), + }) + directory.renameTeam.mockResolvedValue({ 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', '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() + }) + + it(`allows ${mutation.name} only when the same token resolves to a current admin`, async () => { + const response = await mutation.invoke(app()).set('x-user-session-token', staleAdminToken) + + expect(response.status).toBe(200) + expect(directory[mutation.handler]).toHaveBeenCalledOnce() + }) + } +}) diff --git a/control-api/test/routes.externalWorkflows.test.ts b/control-api/test/routes.externalWorkflows.test.ts index de2bf55bb5..adf5458fe9 100644 --- a/control-api/test/routes.externalWorkflows.test.ts +++ b/control-api/test/routes.externalWorkflows.test.ts @@ -12,7 +12,8 @@ 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 mockRateLimitCheck = vi.fn() const mockVerifyInternalControlJwt = vi.fn() const mockIsAdminTokenRevoked = vi.fn() @@ -39,8 +40,21 @@ 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), + 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', () => ({ + checkAndIncrement: (...args: unknown[]) => mockRateLimitCheck(...args), })) vi.mock('../src/utils/auth/internalControlToken.js', () => ({ @@ -127,7 +141,8 @@ describe('routes/external/workflows', () => { mockVerifyWorkflowControlToken.mockReset() mockIsHostRefAuthorized.mockReset() mockVerifyAdminToken.mockReset() - mockVerifyExternalSessionToken.mockReset() + mockAuthenticateExternalUserSession.mockReset() + mockRateLimitCheck.mockReset() mockVerifyInternalControlJwt.mockReset() mockIsAdminTokenRevoked.mockReset() gateway = new MockGateway(RECIPE_NS) @@ -137,9 +152,45 @@ describe('routes/external/workflows', () => { expiresInSeconds: 600, }) mockIsAdminTokenRevoked.mockResolvedValue(false) - mockVerifyExternalSessionToken.mockImplementation(token => - token === 'user-session-token' ? USER_SESSION_CLAIMS : null - ) + mockRateLimitCheck.mockResolvedValue({ + allowed: true, + remaining: 29, + resetMs: Date.now() + 60_000, + windowStartMs: Date.now(), + backendAvailable: true, + count: 1, + }) + 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', + 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 ) @@ -210,7 +261,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' }) }) }) @@ -238,6 +290,72 @@ 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 any session work, as dev does', + async (method, path, limit) => { + mockRateLimitCheck.mockResolvedValue({ + allowed: false, + remaining: 0, + resetMs: Date.now() + 60_000, + windowStartMs: Date.now(), + backendAvailable: true, + 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(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) + expect(response.body).toEqual({ error: 'Unauthorized' }) + // 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], + [`/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) + // One live session validation per request, as in dev (P-7). + expect(mockAuthenticateExternalUserSession).toHaveBeenCalledOnce() + expect(mockPoolQuery).toHaveBeenCalled() + expect(mockRateLimitCheck).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) @@ -268,11 +386,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 { @@ -405,7 +519,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 () => { @@ -553,7 +667,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 () => { @@ -574,20 +688,10 @@ 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 () => { - 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({ @@ -624,13 +728,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({ @@ -1052,7 +1149,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/routes.profile.test.ts b/control-api/test/routes.profile.test.ts index 424e18eba9..b7903262e1 100644 --- a/control-api/test/routes.profile.test.ts +++ b/control-api/test/routes.profile.test.ts @@ -12,9 +12,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(), @@ -39,6 +41,7 @@ const rpcMock = vi.hoisted(() => ({ const googleAuthMock = vi.hoisted(() => ({ verifyGoogleIdToken: vi.fn(), + GoogleIdentityRejectedError: class GoogleIdentityRejectedError extends Error {}, })) const sandboxUiScopeMock = vi.hoisted(() => ({ @@ -54,6 +57,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(), })) @@ -80,6 +89,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', () => { @@ -127,9 +157,15 @@ 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()) + 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, @@ -438,6 +474,92 @@ describe('routes/profile', () => { ).expect(403) }) + it('refuses a live member and lets a live admin rename a team', async () => { + svc.renameTeam.mockResolvedValue({ id: 't1', name: 'Renamed' }) + const app = express() + app.use(express.json()) + mountInternalRoutes(app, accessCatalogGateway()) + + // The live team row says `member` (default fixture), so the live role + // check refuses before the writer. + await withInternalServiceAuthAndUserSession( + request(app).put('/external/teams/t1/name').send({ name: 'Renamed' }) + ).expect(403) + // Witness: the refusal came after the live membership lookup. + expect(liveTeamAuthorizationMock.getLiveTeamMembership).toHaveBeenCalledWith( + 'u1', + 't1', + expect.objectContaining({ onDatabaseFailure: expect.any(Function) }) + ) + expect(svc.renameTeam).not.toHaveBeenCalled() + + liveTeamAuthorizationMock.getLiveTeamMembership.mockResolvedValue({ + teamId: 't1', + role: 'admin', + }) + await withInternalServiceAuthAndUserSession( + request(app).put('/external/teams/t1/name').send({ name: 'Renamed' }) + ).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() + }) + + 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()) + mountInternalRoutes(app, accessCatalogGateway()) + + await withInternalServiceAuthAndUserSession( + request(app).get('/external/teams/t1/members') + ).expect(200, { items: [{ id: 'u2', email: 'peer@example.com', role: 'member' }] }) + + expect(svc.listMembers).toHaveBeenCalledWith('t1') + expect(liveTeamAuthorizationMock.getLiveTeamMembership).toHaveBeenCalledWith( + 'u1', + 't1', + expect.objectContaining({ onDatabaseFailure: expect.any(Function) }) + ) + }) + + 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' + ) + }) + it('allows membership lookup for another team when user claim matches', async () => { svc.findMembership.mockResolvedValue({ team_id: 't2', @@ -507,11 +629,19 @@ 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' }, + { + id: 'u1', + email: 'u@example.com', + name: 'User', + role: 'member', + status: 'active', + }, ], contextIds: ['ctx-a'], agentNames: ['agent-a'], @@ -535,6 +665,46 @@ describe('routes/profile', () => { expect(svc.getTeamContexts).toHaveBeenCalledWith('t2') expect(svc.getTeamAgents).toHaveBeenCalledWith('t2') expect(svc.listMembers).toHaveBeenCalledWith('t2') + expect(svc.listMembers).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 () => { @@ -557,9 +727,12 @@ 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.toHaveBeenCalledWith('t51') expect(svc.getTeamContexts).toHaveBeenCalledTimes(50) expect(svc.getTeamAgents).toHaveBeenCalledTimes(50) }) @@ -648,10 +821,15 @@ describe('routes/profile', () => { }) const gateway = accessCatalogGateway() gateway.getResource.mockRejectedValue(transientError) - const warn = vi.spyOn(rootLogger, 'warn').mockImplementation(() => undefined) + const structuredWarn = vi.fn() + const rootWarn = vi.spyOn(rootLogger, 'warn').mockImplementation(() => undefined) 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')) @@ -672,13 +850,35 @@ describe('routes/profile', () => { 'agent-stale', 'agent-b', ]) - expect(warn).toHaveBeenCalledTimes(2) - warn.mockRestore() + expect(structuredWarn).toHaveBeenCalledTimes(2) + expect(structuredWarn).toHaveBeenCalledWith( + expect.objectContaining({ + err: transientError, + source: 'agents', + userId: 'u1', + }), + 'external user MCP server enrichment failed' + ) + expect(structuredWarn).toHaveBeenCalledWith( + expect.objectContaining({ + err: transientError, + module: 'external-teams', + teamId: 't1', + }), + 'MCP server enrichment failed' + ) + // Both routes log through the request logger; nothing falls back to the root logger. + expect(rootWarn).not.toHaveBeenCalled() + rootWarn.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', + token: 'synthetic-rpc-token', accessScope: 'team', teamId: 'team-from-claims', scopes: ['mcp:servers:list'], @@ -726,6 +926,106 @@ describe('routes/profile', () => { ) }) + it('signs the live team role for rpc tokens, not the session token role', async () => { + const sessionToken = signExternalSessionToken({ + userId: 'user-live-role', + email: 'live-role@example.com', + teamId: 't1', + role: 'admin', + authGeneration: 1, + }) + liveTeamAuthorizationMock.getLiveTeamMembership.mockResolvedValue({ + teamId: 't1', + role: 'member', + }) + svc.getUserAgents.mockResolvedValue({ userId: 'user-live-role', agentNames: [] }) + svc.getTeamAgents.mockResolvedValue({ teamId: 't1', agentNames: ['team-host'] }) + rpcMock.issueRpcAccessToken.mockReturnValue({ + token: 'synthetic-rpc-token', + accessScope: 'team', + teamId: 't1', + scopes: ['mcp:servers:list'], + hostRefs: ['team-host'], + expiresInSeconds: 300, + }) + + const app = express() + app.use(express.json()) + mountInternalRoutes(app, { listResource: vi.fn() }) + + await withInternalServiceAuth(request(app).post('/external/rpc/token')) + .send({ sessionToken, scopes: ['mcp:servers:list'], hostRefs: ['team-host'] }) + .expect(200) + + // Witness: the role decision came from the live membership lookup. + expect(liveTeamAuthorizationMock.getLiveTeamMembership).toHaveBeenCalledWith( + 'user-live-role', + 't1', + expect.anything() + ) + expect(svc.getTeamAgents).toHaveBeenCalledWith('t1') + expect(rpcMock.issueRpcAccessToken).toHaveBeenCalledWith( + { userId: 'user-live-role', teamId: 't1', role: 'member' }, + ['mcp:servers:list'], + ['team-host'], + [] + ) + }) + + it('keeps only direct host grants for rpc tokens once the live team membership is gone', async () => { + const sessionToken = signExternalSessionToken({ + userId: 'user-removed', + email: 'removed@example.com', + teamId: 't1', + role: 'admin', + authGeneration: 1, + }) + liveTeamAuthorizationMock.getLiveTeamMembership.mockResolvedValue(null) + svc.getUserAgents.mockResolvedValue({ userId: 'user-removed', agentNames: ['direct-host'] }) + svc.getTeamAgents.mockResolvedValue({ teamId: 't1', agentNames: ['team-host'] }) + rpcMock.issueRpcAccessToken.mockReturnValue({ + token: 'synthetic-rpc-token', + accessScope: 'user', + teamId: null, + scopes: ['mcp:servers:list'], + hostRefs: ['direct-host'], + expiresInSeconds: 300, + }) + + const app = express() + app.use(express.json()) + mountInternalRoutes(app, { listResource: vi.fn() }) + + await withInternalServiceAuth(request(app).post('/external/rpc/token')) + .send({ sessionToken, scopes: ['mcp:servers:list'], hostRefs: ['team-host'] }) + .expect(403) + .expect({ error: 'direct_host_access_required' }) + + expect(svc.getTeamAgents).not.toHaveBeenCalled() + expect(rpcMock.issueRpcAccessToken).not.toHaveBeenCalled() + // Witness: the request reached the live lookup and the grant decision. + expect(liveTeamAuthorizationMock.getLiveTeamMembership).toHaveBeenCalledTimes(1) + expect(liveTeamAuthorizationMock.getLiveTeamMembership).toHaveBeenCalledWith( + 'user-removed', + 't1', + expect.anything() + ) + expect(svc.getUserAgents).toHaveBeenCalledTimes(1) + + await withInternalServiceAuth(request(app).post('/external/rpc/token')) + .send({ sessionToken, scopes: ['mcp:servers:list'], hostRefs: ['direct-host'] }) + .expect(200) + + expect(svc.getTeamAgents).not.toHaveBeenCalled() + expect(rpcMock.issueRpcAccessToken).toHaveBeenCalledTimes(1) + expect(rpcMock.issueRpcAccessToken).toHaveBeenCalledWith( + { userId: 'user-removed', teamId: null, role: 'member' }, + ['mcp:servers:list'], + ['direct-host'], + [] + ) + }) + it('issues the existing RPC token as user-scoped for a directly granted teamless agent', async () => { const sessionToken = signExternalSessionToken({ userId: 'user-teamless', @@ -1009,4 +1309,217 @@ 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(401, { error: 'Unauthorized' }) + + await withInternalServiceAuth(request(app).post('/external/auth/password-login')) + .send({ email: 'retired@example.com', password: 'synthetic-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: 'synthetic-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: 'synthetic-wrong-password' }) + .expect(401, { error: 'invalid_credentials' }) + }) + + // 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() }) + + const response = await withInternalServiceAuth( + request(app).post('/external/auth/google-login') + ).send({ idToken: 'google-id-token' }) + + expect(response.status).not.toBe(429) + expect(googleAuthMock.verifyGoogleIdToken).toHaveBeenCalledWith('google-id-token') + expect(prOnlyLimiterKeys()).toEqual([]) + }) + + 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() }) + + await withInternalServiceAuth(request(app).post('/external/auth/verify')) + .send({ token: userSessionToken }) + .expect(200) + expect(prOnlyLimiterKeys()).toEqual([]) + }) + + 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: ['host:message:invoke'], + hostRefs: ['agent-a'], + }) + + 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 () => { + rpcMock.issueRpcAccessToken.mockReturnValue({ + token: 'synthetic-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: 'synthetic-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('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() }) + + const response = await withInternalServiceAuthAndUserSession( + request(app).get('/external/users/u1/teams') + ) + + expect(response.status).not.toBe(429) + expect(svc.listTeams).toHaveBeenCalled() + expect(prOnlyLimiterKeys()).toEqual([]) + }) + + 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() }) + + const response = await withInternalServiceAuthAndUserSession( + request(app).put('/external/users/u1/profile') + ).send({ displayName: 'New Name' }) + + expect(response.status).not.toBe(429) + expect(svc.updateProfile).toHaveBeenCalled() + expect(prOnlyLimiterKeys()).toEqual([]) + }) }) diff --git a/control-api/test/routes.rateLimitProcessMemory.realPostgres.integration.test.ts b/control-api/test/routes.rateLimitProcessMemory.realPostgres.integration.test.ts index 9fc7f8d7d1..7be8c7ccb6 100644 --- a/control-api/test/routes.rateLimitProcessMemory.realPostgres.integration.test.ts +++ b/control-api/test/routes.rateLimitProcessMemory.realPostgres.integration.test.ts @@ -27,6 +27,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.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 b380c7a0ea..4f82d4e84e 100644 --- a/control-api/test/routes.rpcAccessUsersMcpHost.test.ts +++ b/control-api/test/routes.rpcAccessUsersMcpHost.test.ts @@ -1,6 +1,8 @@ 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' import { DirectRunBindingConflictError } from '../src/services/tracing/directRunAttributionBindingService.js' @@ -37,6 +39,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. // @@ -85,9 +93,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() + }, } }) @@ -99,7 +132,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), @@ -290,6 +327,15 @@ 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, + backendAvailable: true, + }) }) it('returns 200 with connection URL when user has row AND host exists AND host is not disabled', async () => { @@ -348,6 +394,35 @@ describe('GET /rpc/access/users/:userId/mcp-hosts/:hostRef — happy path', () = }) }) +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 = 'host-a' + svc.getUserAgents.mockReset() + svc.getUserAgents.mockResolvedValue({ userId, agentNames: [hostRef] }) + const { app } = buildApp([{ metadata: { name: hostRef }, spec: { enabled: true } }]) + + 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() + + 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() + }) +}) + describe('GET /rpc/access/users/:userId/mcp-hosts/:hostRef — CHECK #1 (user_agents row, the incident bug)', () => { beforeEach(() => { svc.getUserAgents.mockReset() @@ -526,6 +601,51 @@ 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('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, + body: { + kind: 'Status', + status: 'Failure', + reason: 'NotFound', + message: 'namespaces "mcp-host" not found', + }, + }) + const listResource = vi + .fn() + .mockRejectedValueOnce(k8sNotFound) + .mockResolvedValue([{ metadata: { name: 'product' }, spec: { enabled: true } }]) + const gatewayStub = { + listResource, + getResource: vi.fn(), + createResource: vi.fn(), + updateResource: vi.fn(), + deleteResource: vi.fn(), + } + const app = express() + app.use(express.json()) + app.use( + createRpcAccessUsersRouter(gatewayStub as unknown as K8sGateway, { + bindingService: { bind: vi.fn() }, + }) + ) + app.use(clerumErrorHandler) + + const dependencyFailure = await request(app).get(`/rpc/access/users/u/mcp-hosts/product`) + expect(dependencyFailure.status).toBe(404) + + // Positive control in the same test: once the dependency answers, the same + // route and handler resolve the Host normally. + const resolved = await request(app).get(`/rpc/access/users/u/mcp-hosts/product`).expect(200) + expect(resolved.body).toEqual({ + userId: 'u', + hostRef: 'product', + url: 'http://product.mcp-host.svc.cluster.local:8080', + }) + expect(listResource).toHaveBeenCalledTimes(2) + }) }) describe('GET /rpc/access/users/:userId/mcp-hosts/:hostRef — team-level access (issue #141)', () => { diff --git a/control-api/test/services.controlAdminReplaceInviter.realPostgres.integration.test.ts b/control-api/test/services.controlAdminReplaceInviter.realPostgres.integration.test.ts index 311a10382d..d1af40fc81 100644 --- a/control-api/test/services.controlAdminReplaceInviter.realPostgres.integration.test.ts +++ b/control-api/test/services.controlAdminReplaceInviter.realPostgres.integration.test.ts @@ -4,7 +4,6 @@ import { Pool } from 'pg' import { config } from '../src/config.js' import { initDb, pool } from '../src/db.js' import type { K8sGateway } from '../src/k8s.js' -import { rootLogger } from '../src/observability/logger.js' import type { RpcAccessClaims } from '../src/profileTypes.js' import { authorizeRpcHostAccess } from '../src/services/access/rpcHostAccessAuthorizer.js' import { @@ -221,7 +220,7 @@ describeRealPostgres('control admin replace-inviter invitations on real PostgreS ) if ('error' in controlAdminInvitation) throw new Error(controlAdminInvitation.error) - // This matches the pre-0131 writer's INSERT columns, which rely on the table default. + // This matches the pre-0137 writer's INSERT columns, which rely on the table default. const legacyControlAdminInvitation = await testPool.query( `INSERT INTO control_admin_invitations (email, invited_by_admin_id, replace_inviter) VALUES ($1, $2, false) diff --git a/control-api/test/services.directory.adminProvisioning.test.ts b/control-api/test/services.directory.adminProvisioning.test.ts index f9947cfa87..ba809f6cc5 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, @@ -93,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 @@ -116,18 +122,20 @@ describe('provisionAdminDesktopWorkspace', () => { expect(dbMocks.txQuery).toHaveBeenCalledWith(expect.stringContaining('UPDATE users'), [ 'user-1', 'HASH', + false, ]) expect(dbMocks.txQuery).toHaveBeenCalledWith( 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 () => { 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 }) @@ -149,7 +157,15 @@ 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 }), + undefined, + '8' + ) }) it('seedPassword:false leaves an existing user password untouched', async () => { @@ -171,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() @@ -212,7 +232,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') @@ -242,7 +262,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')) @@ -266,6 +286,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 () => { @@ -287,7 +309,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({ @@ -323,6 +345,59 @@ 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 }) // INSERT profiles + .mockResolvedValueOnce({ rows: [{ lifecycle_version: '8' }], 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, + }) + + // Users before profiles, the order Google login uses: the existing user is + // locked before the profile upsert, which precedes the password write. + const statements = dbMocks.txQuery.mock.calls.map(([sql]) => String(sql)) + const userRead = statements.findIndex( + sql => sql.includes('FROM users') && sql.includes('lower(email) = lower($1)') + ) + const profileUpsert = statements.findIndex(sql => sql.includes('INSERT INTO profiles')) + const passwordWrite = statements.findIndex( + sql => sql.includes('UPDATE users') && sql.includes('SET password_hash = $2') + ) + expect(userRead).toBeGreaterThanOrEqual(0) + expect(statements[userRead]).toContain('FOR NO KEY UPDATE') + expect(profileUpsert).toBeGreaterThan(userRead) + expect(passwordWrite).toBeGreaterThan(profileUpsert) + expect(userSessionMocks.revokeAll).toHaveBeenCalledWith( + 'user-1', + 'password_changed', + expect.objectContaining({ query: dbMocks.txQuery }), + undefined, + '8' + ) }) it('records the exact revoke and grant when an operator changes a member role', async () => { 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..ae9021b9db --- /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: 'synthetic-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') + }) +}) diff --git a/control-api/test/services.directory.login.passwordBackstop.test.ts b/control-api/test/services.directory.login.passwordBackstop.test.ts index f6915c2537..58b28a20e4 100644 --- a/control-api/test/services.directory.login.passwordBackstop.test.ts +++ b/control-api/test/services.directory.login.passwordBackstop.test.ts @@ -61,6 +61,10 @@ describe('directory Google 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('denies Google login for a retired user before profile healing or identity update', async () => { diff --git a/control-api/test/services.directory.teams.test.ts b/control-api/test/services.directory.teams.test.ts index 734ab32089..a856b470fe 100644 --- a/control-api/test/services.directory.teams.test.ts +++ b/control-api/test/services.directory.teams.test.ts @@ -12,18 +12,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: dbMocks.txQuery }), + work({ query: dbMocks.txQueryWithClock }), })) vi.mock('../src/services/tracing/controlApiPermissionEvents.js', () => ({ @@ -34,6 +42,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') }) @@ -129,7 +138,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: dbMocks.txQueryWithClock }), expect.objectContaining({ operatorSub: 'admin-1', changes: [ @@ -154,7 +163,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: dbMocks.txQueryWithClock }), expect.objectContaining({ operatorSub: 'operator-1', changes: [ @@ -183,7 +192,7 @@ describe('services/directory team management unit tests', () => { expect.objectContaining({ status: 'deleted' }) ) expect(dbMocks.appendPermissionEvents).toHaveBeenCalledWith( - expect.objectContaining({ query: dbMocks.txQuery }), + expect.objectContaining({ query: dbMocks.txQueryWithClock }), expect.objectContaining({ operatorSub: 'operator-1', changes: [ diff --git a/control-api/test/services.directory.userRetirement.test.ts b/control-api/test/services.directory.userRetirement.test.ts index 8a8fd02629..5d808cc3a5 100644 --- a/control-api/test/services.directory.userRetirement.test.ts +++ b/control-api/test/services.directory.userRetirement.test.ts @@ -77,6 +77,12 @@ 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 () => { + 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()) @@ -93,7 +99,8 @@ describe('retireDesktopUser', () => { USER_ID, 'policy retirement', 'idem-admin-1', - 'request-admin-1' + 'request-admin-1', + { authorize } ) ).resolves.toEqual({ id: USER_ID, @@ -102,6 +109,7 @@ describe('retireDesktopUser', () => { lifecycleVersion: 2, replayed: false, }) + expect(authorize).toHaveBeenCalledOnce() expect(mocks.retireParentInTransaction).toHaveBeenCalledWith( expect.anything(), @@ -178,6 +186,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 +201,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 +212,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-api/test/services.liveTeamAuthorization.test.ts b/control-api/test/services.liveTeamAuthorization.test.ts new file mode 100644 index 0000000000..f621c81c56 --- /dev/null +++ b/control-api/test/services.liveTeamAuthorization.test.ts @@ -0,0 +1,126 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import express from 'express' +import { DatabaseError } from 'pg' +import request from 'supertest' +import { clerumErrorHandler } from '../src/http/errorHandler.js' +import { + type ExternalAuthedRequest, + requireExternalTeamParamMatch, +} from '../src/middleware/externalSessionAuth.js' +import { getLiveTeamMembership } from '../src/services/access/liveTeamAuthorization.js' + +const pool = vi.hoisted(() => ({ query: vi.fn() })) + +vi.mock('../src/db.js', () => ({ pool })) + +const USER_ID = '6f1c2a8e-3b4d-4e5f-8a9b-0c1d2e3f4a5b' +const TEAM_ID = '9a8b7c6d-5e4f-4a3b-9c2d-1e0f9a8b7c6d' +const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i + +/** PostgreSQL's answer to a non-UUID literal compared with a uuid column. */ +function invalidUuidError(value: string): DatabaseError { + const error = new DatabaseError( + `synthetic invalid input syntax for type uuid: "${value}"`, + 0, + 'error' + ) + error.code = '22P02' + return error +} + +/** + * Emulates PostgreSQL for the live membership query: a non-UUID `$1` or `$2` + * raises 22P02, a UUID pair returns the active member row. + */ +function emulatePostgresMembership(values: unknown[]) { + for (const value of values) { + if (typeof value === 'string' && !UUID_PATTERN.test(value)) { + throw invalidUuidError(value) + } + } + return { rows: [{ team_id: values[1], role: 'admin' }], rowCount: 1 } +} + +describe('getLiveTeamMembership', () => { + it('answers no membership for a non-UUID team id without querying, and queries a UUID pair', async () => { + const db = { + query: vi.fn(async () => ({ rows: [{ team_id: TEAM_ID, role: 'member' }], rowCount: 1 })), + } + + await expect(getLiveTeamMembership(USER_ID, 'abc', { db })).resolves.toBeNull() + expect(db.query).not.toHaveBeenCalled() + + // Liveness witness: the same helper with a UUID team id reaches the database. + await expect(getLiveTeamMembership(USER_ID, TEAM_ID, { db })).resolves.toEqual({ + teamId: TEAM_ID, + role: 'member', + }) + expect(db.query).toHaveBeenCalledTimes(1) + expect(db.query).toHaveBeenCalledWith(expect.stringContaining("tm.status = 'active'"), [ + USER_ID, + TEAM_ID, + ]) + }) + + it('answers no membership for a non-UUID user id without querying', async () => { + const db = { + query: vi.fn(async () => ({ rows: [{ team_id: TEAM_ID, role: 'member' }], rowCount: 1 })), + } + + await expect(getLiveTeamMembership('user-1', TEAM_ID, { db })).resolves.toBeNull() + expect(db.query).not.toHaveBeenCalled() + + // Liveness witness: a UUID user id with the same team id reaches the database. + await expect(getLiveTeamMembership(USER_ID, TEAM_ID, { db })).resolves.not.toBeNull() + expect(db.query).toHaveBeenCalledTimes(1) + }) +}) + +describe('requireExternalTeamParamMatch with a malformed team id', () => { + function app() { + const value = express() + value.get( + '/team', + (req: ExternalAuthedRequest, _res, next) => { + req.externalAuth = { + userId: USER_ID, + email: 'member@example.com', + teamId: TEAM_ID, + role: 'admin', + exp: Math.floor(Date.now() / 1000) + 3600, + } + next() + }, + requireExternalTeamParamMatch(), + (req: ExternalAuthedRequest, res) => res.status(200).json({ team: req.externalTeamAuth }) + ) + value.use(clerumErrorHandler) + return value + } + + beforeEach(() => { + pool.query.mockReset() + pool.query.mockImplementation(async (_sql: string, values: unknown[]) => + emulatePostgresMembership(values) + ) + }) + + it('answers 403 Forbidden for a non-UUID teamId, as for any team the caller is not in', async () => { + await request(app()).get('/team').query({ teamId: 'abc' }).expect(403).expect({ + error: 'Forbidden', + }) + expect(pool.query).not.toHaveBeenCalled() + + // Liveness witness: a UUID teamId with a member row reaches the handler. + await request(app()) + .get('/team') + .query({ teamId: TEAM_ID }) + .expect(200) + .expect({ team: { teamId: TEAM_ID, role: 'admin' } }) + expect(pool.query).toHaveBeenCalledTimes(1) + expect(pool.query).toHaveBeenCalledWith(expect.stringContaining('team_members'), [ + USER_ID, + TEAM_ID, + ]) + }) +}) diff --git a/control-api/test/services.modelsDevClient.urlGuard.test.ts b/control-api/test/services.modelsDevClient.urlGuard.test.ts index e39f24c6b1..82c11b4894 100644 --- a/control-api/test/services.modelsDevClient.urlGuard.test.ts +++ b/control-api/test/services.modelsDevClient.urlGuard.test.ts @@ -70,6 +70,42 @@ describe('models.dev catalog URL egress guard', () => { expect(fetchImpl).not.toHaveBeenCalled() }) + it.each([ + ['username and password', 'https://user:s3cret@stub.example.com/api.json'], + ['username only', 'https://s3cret@stub.example.com/api.json'], + ['password only', 'https://:s3cret@stub.example.com/api.json'], + ])( + 'rejects credentials in the URL (%s), never echoes them, never fetches', + async (_label, url) => { + const { loadModelsDevCatalog } = await loadWith(url) + const fetchImpl = vi.fn() + const rejection = loadModelsDevCatalog({ fetchImpl: fetchImpl as never }) + await expect(rejection).rejects.toThrow('models.dev catalog URL must not contain credentials') + await expect(rejection).rejects.not.toThrow('s3cret') + expect(fetchImpl).not.toHaveBeenCalled() + } + ) + + it('does not echo a malformed URL that carries a secret', async () => { + const { loadModelsDevCatalog } = await loadWith('https://user:s3cret@ bad host/api.json') + const fetchImpl = vi.fn() + const rejection = loadModelsDevCatalog({ fetchImpl: fetchImpl as never }) + await expect(rejection).rejects.toThrow('models.dev catalog URL is not a valid URL') + await expect(rejection).rejects.not.toThrow('s3cret') + expect(fetchImpl).not.toHaveBeenCalled() + }) + + it('lets a public URL with no credentials reach the fetch', async () => { + const { loadModelsDevCatalog } = await loadWith('https://stub.example.com/api.json') + const fetchImpl = vi.fn(async () => { + throw new Error('network down') + }) + const res = await loadModelsDevCatalog({ fetchImpl: fetchImpl as never }) + expect(fetchImpl).toHaveBeenCalledTimes(1) + expect(fetchImpl).toHaveBeenCalledWith('https://stub.example.com/api.json', expect.anything()) + expect(res.source).toBe('vendored') + }) + it('passes the guard for the default https host (fetch IS attempted)', async () => { const { loadModelsDevCatalog } = await loadWith(undefined) // default https://models.dev/api.json // A throwing fetch proves the guard let us THROUGH to the network; the loader diff --git a/control-api/test/services.rateLimiter.realPostgres.integration.test.ts b/control-api/test/services.rateLimiter.realPostgres.integration.test.ts index 7148633043..3d792578c3 100644 --- a/control-api/test/services.rateLimiter.realPostgres.integration.test.ts +++ b/control-api/test/services.rateLimiter.realPostgres.integration.test.ts @@ -41,12 +41,12 @@ describeRealPostgres('rate limiter atomicity on real PostgreSQL', () => { afterAll(async () => { try { - 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]]) 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()`, + WHERE datname = $1 AND pid <> pg_backend_pid()`, [database] ) await adminPool.query(`DROP DATABASE IF EXISTS ${quoteIdent(database)}`) diff --git a/control-api/test/services.rateLimiterPoolSaturation.realPostgres.integration.test.ts b/control-api/test/services.rateLimiterPoolSaturation.realPostgres.integration.test.ts index d411b282cf..2c790031a1 100644 --- a/control-api/test/services.rateLimiterPoolSaturation.realPostgres.integration.test.ts +++ b/control-api/test/services.rateLimiterPoolSaturation.realPostgres.integration.test.ts @@ -287,6 +287,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()) diff --git a/control-api/test/services.rpcHostAccessAuthorizer.test.ts b/control-api/test/services.rpcHostAccessAuthorizer.test.ts index 64abbc801f..953c7eeeac 100644 --- a/control-api/test/services.rpcHostAccessAuthorizer.test.ts +++ b/control-api/test/services.rpcHostAccessAuthorizer.test.ts @@ -95,4 +95,34 @@ describe('authorizeRpcHostAccess', () => { authorizeRpcHostAccess(gateway, CLAIMS, 'user-1', 'host-a', directory) ).resolves.toEqual({ authorized: false, reason }) }) + + 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' }, + }) + const listResource = vi + .fn() + .mockRejectedValueOnce(notFound) + .mockResolvedValue([{ metadata: { name: 'host-a' }, spec: { enabled: true } }]) + const gateway = { listResource } as unknown as K8sGateway + const { directory } = dependencies() + + const rejection = authorizeRpcHostAccess(gateway, CLAIMS, 'user-1', 'host-a', directory) + 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. + await expect( + authorizeRpcHostAccess(gateway, CLAIMS, 'user-1', 'host-a', directory) + ).resolves.toEqual({ + authorized: true, + connection: { + userId: 'user-1', + hostRef: 'host-a', + url: 'http://host-a.mcp-host.svc.cluster.local:8080', + }, + }) + expect(listResource).toHaveBeenCalledTimes(2) + }) }) 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/services.workflowRunReadService.liveTeamGrant.test.ts b/control-api/test/services.workflowRunReadService.liveTeamGrant.test.ts new file mode 100644 index 0000000000..9f95236687 --- /dev/null +++ b/control-api/test/services.workflowRunReadService.liveTeamGrant.test.ts @@ -0,0 +1,96 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { WorkflowRunRow } from '../src/services/workflowRunService.js' +import type { WorkflowRouteCaller } from '../src/services/workflows/types.js' +import { canCallerReadWorkflowRunWithApprovalTarget } from '../src/services/workflows/workflowRunReadService.js' + +const mockPoolQuery = vi.fn() + +vi.mock('../src/db.js', () => ({ + pool: { + query: (...args: unknown[]) => mockPoolQuery(...args), + }, +})) + +const NAMESPACE = 'sandbox-recipes' +const RECIPE = 'team-scheduled-recipe' +const TEAM_ID = '7b0d3c56-2f4e-4a51-9b8e-1c2d3e4f5a60' +const READER_ID = '0f6a9e1b-8c7d-4e2f-a1b3-c4d5e6f7a809' + +function userCaller(): WorkflowRouteCaller { + return { + kind: 'user-session', + claims: { + userId: READER_ID, + email: 'live-team-grant-reader@example.invalid', + teamId: TEAM_ID, + role: 'member', + authGeneration: 1, + exp: Math.floor(Date.now() / 1000) + 3_600, + }, + session: {} as never, + } +} + +function scheduledTeamRun(): WorkflowRunRow { + return { + run_id: '5d4c3b2a-1f0e-4d9c-8b7a-6f5e4d3c2b1a', + recipe_namespace: NAMESPACE, + recipe_name: RECIPE, + phase: 'Running', + actor_type: 'scheduled', + team_id: TEAM_ID, + usage_team_id: null, + actor_id: null, + idempotency_key: null, + trigger_source: 'schedule', + inputs: null, + intermediate_parameters: null, + output_overrides: null, + child_recipe_name: null, + child_recipe_namespace: null, + owner_instance_id: null, + max_duration_seconds: null, + ttl_seconds_after_finished: null, + approval_request_id: null, + idempotency_payload_hash: null, + started_at: null, + completed_at: null, + last_reconciled_at: null, + created_at: '2026-11-05T00:00:00.000Z', + updated_at: '2026-11-05T00:00:00.000Z', + } +} + +function expectLiveGrantQueryIssuedOnce(): void { + expect(mockPoolQuery).toHaveBeenCalledTimes(1) + const [sql, params] = mockPoolQuery.mock.calls[0] as [string, unknown[]] + expect(sql).toContain('team_workflow_triggers') + expect(sql).toContain("tm.status = 'active'") + expect(params).toEqual([READER_ID, TEAM_ID, NAMESPACE, RECIPE]) +} + +describe('canCallerReadWorkflowRunWithApprovalTarget live team grant', () => { + beforeEach(() => { + mockPoolQuery.mockReset() + }) + + it('hides a team-scheduled run when the reader has no live team grant', async () => { + mockPoolQuery.mockResolvedValueOnce({ rows: [], rowCount: 0 }) + + await expect( + canCallerReadWorkflowRunWithApprovalTarget(userCaller(), scheduledTeamRun()) + ).resolves.toBe(false) + + expectLiveGrantQueryIssuedOnce() + }) + + it('shows a team-scheduled run when the reader has a live team grant', async () => { + mockPoolQuery.mockResolvedValueOnce({ rows: [{ '?column?': 1 }], rowCount: 1 }) + + await expect( + canCallerReadWorkflowRunWithApprovalTarget(userCaller(), scheduledTeamRun()) + ).resolves.toBe(true) + + expectLiveGrantQueryIssuedOnce() + }) +}) diff --git a/control-api/test/sessionDatabaseFailure.test.ts b/control-api/test/sessionDatabaseFailure.test.ts new file mode 100644 index 0000000000..f8875ee523 --- /dev/null +++ b/control-api/test/sessionDatabaseFailure.test.ts @@ -0,0 +1,48 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { EventEmitter } from 'node:events' +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, pool, { + 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() + const client = Object.assign(new EventEmitter(), { query, release }) + vi.spyOn(pool, 'connect').mockResolvedValueOnce(client as never) + + await expect( + withTransaction( + async () => { + throw applicationFailure + }, + pool, + { 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/shutdown.test.ts b/control-api/test/shutdown.test.ts index 25d7906959..f5f960d18d 100644 --- a/control-api/test/shutdown.test.ts +++ b/control-api/test/shutdown.test.ts @@ -17,6 +17,14 @@ describe('Control API shutdown', () => { expect(poolIndex).toBeGreaterThan(cronIndex) }) + 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')) + }) + it('stops the shared OAuth grant sweep before database pools close', () => { const names = [...CONTROL_API_SHUTDOWN_STEP_NAMES] as string[] diff --git a/control-api/test/userAccessPolicy.test.ts b/control-api/test/userAccessPolicy.test.ts new file mode 100644 index 0000000000..940ad1b3d9 --- /dev/null +++ b/control-api/test/userAccessPolicy.test.ts @@ -0,0 +1,244 @@ +import { describe, expect, it } from 'vitest' +import { + type ConfiguredUserAccessIntent, + type DeploymentReadiness, + UserAccessPolicyConfigurationError, + compareSemanticVersions, + compileUserAccessPolicy, + configuredUserAccessIntent, + loadConfiguredUserAccessIntent, + reconstructionReadiness, +} from '../src/services/access/userAccessPolicy.js' +import { resolveEffectiveUserAccessPolicy } from '../src/services/access/userAccessRuntimePolicy.js' + +function intent(overrides: Partial = {}): ConfiguredUserAccessIntent { + const configured: ConfiguredUserAccessIntent = { + legacyLifecycle: 'issue_and_accept', + sessionV2Acceptance: true, + sessionV2Issuance: false, + legacySwitchEndpoint: true, + minimumClientVersion: null, + minimumClientEnforcement: false, + ...overrides, + } + return configured +} + +function readiness(overrides: Partial = {}): DeploymentReadiness { + return { + revision: 'test-ready-v1', + snapshot: 'current', + legacySession: 'ready', + legacySwitchAuthorization: 'ready', + legacyDrainAccepted: true, + sessionV2Acceptance: 'ready', + sessionV2Issuance: '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( + compileUserAccessPolicy(loadConfiguredUserAccessIntent({}), reconstructionReadiness) + ).toMatchObject({ + acceptV1: true, + issueV1: true, + acceptV2: true, + issueV2: false, + renewV2: false, + switchCompatibility: true, + enforceMinimumClient: false, + }) + }) + + 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 }), + ], + [ + 'minimum_client_version_missing', + 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) + ) + }) + + it('derives renewal and the minimum-client gate only from ready dependencies', () => { + const policy = compileUserAccessPolicy( + intent({ + sessionV2Issuance: true, + minimumClientVersion: '2.3.0', + minimumClientEnforcement: true, + }), + readiness() + ) + + expect(policy).toMatchObject({ + renewV2: true, + minimumClientVersion: '2.3.0', + enforceMinimumClient: true, + }) + expect(policy.policyRevision).toMatch(/^[0-9a-f]{64}$/) + }) + + it('enumerates every configured Boolean/lifecycle combination deterministically', () => { + const booleanKeys = [ + 'sessionV2Acceptance', + 'sessionV2Issuance', + 'legacySwitchEndpoint', + 'minimumClientEnforcement', + ] as const + const lifecycles = ['issue_and_accept', 'accept_only', 'disabled'] as const + let outcomes = 0 + + for (const legacyLifecycle of lifecycles) { + for (let mask = 0; mask < 1 << booleanKeys.length; mask += 1) { + const configured = intent({ + legacyLifecycle, + minimumClientVersion: + mask & (1 << booleanKeys.indexOf('minimumClientEnforcement')) ? '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 * 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() + }) + + 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, + }) + // V2 issuance is not deployed in PR 1 (#1222), so configuring it stops startup. + await expect( + resolveEffectiveUserAccessPolicy({ intent: intent({ sessionV2Issuance: true }) }) + ).rejects.toThrowError( + new UserAccessPolicyConfigurationError('session_v2_issuance_unavailable') + ) + // Minimum-client enforcement needs V2 issuance, so it stops startup at that gate first. + await expect( + resolveEffectiveUserAccessPolicy({ + intent: intent({ + sessionV2Issuance: true, + minimumClientVersion: '2.0.0', + minimumClientEnforcement: true, + }), + }) + ).rejects.toThrowError( + new UserAccessPolicyConfigurationError('session_v2_issuance_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) + }) +}) 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..52245fb445 --- /dev/null +++ b/control-api/test/userAccessRuntimeContract.realPostgres.integration.test.ts @@ -0,0 +1,152 @@ +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 { + createUserSession, + renewUserSession, + 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 +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() + 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 () => { + 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('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, + }) + ) + 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('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() + } + }) +}) 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..f0b3267783 --- /dev/null +++ b/control-api/test/userSessionDatabaseClock.realPostgres.integration.test.ts @@ -0,0 +1,295 @@ +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, + renewUserSession, + 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 +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 () => { + 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('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() + } + }) + + 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/userSessionRevokeAllAtomic.test.ts b/control-api/test/userSessionRevokeAllAtomic.test.ts new file mode 100644 index 0000000000..c7f40d7b04 --- /dev/null +++ b/control-api/test/userSessionRevokeAllAtomic.test.ts @@ -0,0 +1,78 @@ +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 }) + .mockResolvedValueOnce({ + 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 } + }) + .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(5) + 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) + }) + + 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') + mocks.query.mockReset() + 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 } + }) + .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')).resolves.toBe(0) + + 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' + ) + expect(cutoffs).toEqual([new Date(afterLock.getTime() - 1000)]) + }) +}) diff --git a/control-api/test/userSessionService.test.ts b/control-api/test/userSessionService.test.ts new file mode 100644 index 0000000000..07a8021a6b --- /dev/null +++ b/control-api/test/userSessionService.test.ts @@ -0,0 +1,397 @@ +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, + 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('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 } } + ) + + 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('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 + 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, + 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 } }) + 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 }, + }) + 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('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(), + 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 } + 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 }, + }) + ).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 } }) + ).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: [{ 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 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' + ) + 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, + 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(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', + reason: 'security_event', + }) + }) + + it('rejects a missing-generation legacy token before reading authority', async () => { + 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(async (sql: string) => + sql.includes('clock_timestamp()') + ? { rows: [{ db_now: now }], rowCount: 1 } + : { + rows: [ + { + id: claims.userId, + lifecycle_state: 'active', + lifecycle_version: 1, + valid_after: null, + token_revoked: false, + }, + ], + rowCount: 1, + } + ), + } + + await expect(validateLegacyUserSession('legacy-v1-token', claims, { db })).resolves.toEqual({ + status: 'invalid', + reason: 'invalid_legacy_representation', + }) + expect(db.query).not.toHaveBeenCalled() + + // Liveness witness: the same claims with generation one reach the + // authority read and validate against the same active row. + const current = await validateLegacyUserSession( + 'legacy-v1-token', + { ...claims, authGeneration: 1 }, + { db } + ) + 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) + }) +}) diff --git a/control-api/test/userSessionToken.test.ts b/control-api/test/userSessionToken.test.ts new file mode 100644 index 0000000000..75df086ff5 --- /dev/null +++ b/control-api/test/userSessionToken.test.ts @@ -0,0 +1,117 @@ +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' +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 verificationTime = Math.floor(Date.now() / 1000) + 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, + verificationTime + USER_SESSION_V2_CLOCK_TOLERANCE_SECONDS + 1 + ) + + 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() + } + }) + + 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() + }) +}) 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..69312a2ef8 --- /dev/null +++ b/control-api/test/userSessionUserLockMode.realPostgres.integration.test.ts @@ -0,0 +1,875 @@ +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 = 'Synthetic-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/ +// ensureInvitationUser's locked lookup by email: an acceptance takes its first +// user lock here, before the profile insert. +const USER_EMAIL_LOCK = /FROM users\s+WHERE email = \$1\s+LIMIT 1\s+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 } + } + + // An invitation already accepted by the user, with the user's profile row, + // so a replay's profile insert takes no FK lock on the user. + async function seedAcceptedInvitation( + user: { userId: string; email: string }, + label: string + ): Promise<{ invitationId: string; flowToken: string }> { + const teamId = randomUUID() + await databasePool.query(`INSERT INTO teams(id, name) VALUES ($1, $2)`, [teamId, label]) + await databasePool.query(`INSERT INTO profiles(user_id, display_name) VALUES ($1, $2)`, [ + user.userId, + label, + ]) + const seeded = await seedInvitation(user.email, teamId, 'member') + await databasePool.query( + `UPDATE invitations + SET status = 'accepted', accepted_at = NOW(), accepted_user_id = $2 + WHERE id = $1`, + [seeded.invitationId, user.userId] + ) + return seeded + } + + 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(USER_EMAIL_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(USER_EMAIL_LOCK, gatePid) + await gate.query('COMMIT') + + const settled = await acceptance + if (!('value' in settled)) throw settled.error + // The decision is taken on the locked user row, before any invitation + // or membership write: the route's user_retired answer. + expect(settled.value.status).toBe(401) + expect(settled.value.body).toEqual({ error: 'Unauthorized' }) + expect(settled.value.body).not.toHaveProperty('token') + 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 }]) + const membership = await databasePool.query( + `SELECT 1 FROM team_members WHERE user_id = $1 AND team_id = $2`, + [user.userId, teamId] + ) + expect(membership.rowCount).toBe(0) + expect(await lifecycle(user.userId)).toEqual({ state: 'retired', version: 2 }) + } finally { + await settle([gate], pending) + } + }, + CASE_TIMEOUT_MS + ) + + // Users before invitations: a replay waits on the user row without holding + // the accepted invitation that a user delete (ON DELETE SET NULL) or a + // Google login (accepted_user_id backfill) writes after locking the user. + // After the delete commits, the replay's locked email read finds no row, so + // ensureInvitationUser creates a new user with the same email and the replay + // backfills accepted_user_id with it: the same result as a delete followed by + // a replay, now reached concurrently as well. + it( + 'a replayed acceptance waiting on a user delete recreates the user and backfills the invitation without deadlock', + async () => { + const user = await seedUser('replay-delete') + const { flowToken, invitationId } = await seedAcceptedInvitation(user, 'replay-delete') + + const gate = await databasePool.connect() + const pending: Promise[] = [] + try { + await gate.query('BEGIN') + await gate.query(`SELECT id FROM users WHERE id = $1 FOR UPDATE`, [user.userId]) + const gatePid = await backendPid(gate) + const acceptance = track(pending, accept(flowToken, user.email)) + await waitForWaitingStatement(USER_EMAIL_LOCK, gatePid) + const deleted = await gate.query(`DELETE FROM users WHERE id = $1`, [user.userId]) + expect(deleted.rowCount).toBe(1) + await gate.query('COMMIT') + + const settled = await acceptance + if (!('value' in settled)) throw settled.error + expect(settled.value.status, JSON.stringify(settled.value.body)).toBe(200) + expect(settled.value.body).toMatchObject({ + accepted: true, + userId: expect.any(String), + replay: true, + }) + expect(settled.value.body).not.toHaveProperty('token') + const newUserId = (settled.value.body as { userId: string }).userId + expect(newUserId).not.toBe(user.userId) + const state = await databasePool.query<{ accepted_user_id: string | null }>( + `SELECT accepted_user_id FROM invitations WHERE id = $1`, + [invitationId] + ) + expect(state.rows).toEqual([{ accepted_user_id: newUserId }]) + const users = await databasePool.query<{ id: string }>( + `SELECT id::text AS id FROM users WHERE email = $1`, + [user.email] + ) + expect(users.rows).toEqual([{ id: newUserId }]) + } finally { + await settle([gate], pending) + } + }, + CASE_TIMEOUT_MS + ) + + it( + 'a replayed acceptance and a Google login do not deadlock', + async () => { + const user = await seedUser('replay-login') + const { flowToken, invitationId } = await seedAcceptedInvitation(user, 'replay-login') + + const gate = await databasePool.connect() + const pending: Promise[] = [] + try { + await gate.query('BEGIN') + // The literal statements of googleLoginData (login.ts) and of + // linkAcceptedInvitationsToUser, in their order. + await gate.query( + `UPDATE users + SET name = COALESCE($2, name), + picture = COALESCE($3, picture), + updated_at = NOW() + WHERE email = $1 + RETURNING id, email, name, picture, lifecycle_version`, + [user.email, 'replay-login', null] + ) + const gatePid = await backendPid(gate) + const acceptance = track(pending, accept(flowToken, user.email)) + await waitForWaitingStatement(USER_EMAIL_LOCK, gatePid) + await gate.query( + `UPDATE invitations + SET accepted_user_id = COALESCE(accepted_user_id, $2) + WHERE LOWER(email) = LOWER($1) + AND status = 'accepted'`, + [user.email, user.userId] + ) + await gate.query('COMMIT') + + const settled = await acceptance + if (!('value' in settled)) throw settled.error + expect(settled.value.status, JSON.stringify(settled.value.body)).toBe(200) + expect(settled.value.body).toMatchObject({ + accepted: true, + userId: user.userId, + replay: true, + }) + expect(settled.value.body).not.toHaveProperty('token') + const state = await databasePool.query<{ accepted_user_id: string | null }>( + `SELECT accepted_user_id FROM invitations WHERE id = $1`, + [invitationId] + ) + expect(state.rows).toEqual([{ accepted_user_id: user.userId }]) + } finally { + await settle([gate], pending) + } + }, + CASE_TIMEOUT_MS + ) + + // Users before profiles: an existing user may have no profile row. Google + // login locks the user (UPDATE users) and then inserts the profile; an + // acceptance that inserted the profile before locking the user would hold + // the uncommitted profiles(user_id) key that login waits on while it waits + // on the user row login holds. + it( + 'an acceptance for an existing user without a profile and a Google login do not deadlock', + async () => { + const user = await seedUser('profile-login') + const teamId = randomUUID() + await databasePool.query(`INSERT INTO teams(id, name) VALUES ($1, 'profile-login')`, [teamId]) + const { flowToken, invitationId } = await seedInvitation(user.email, teamId, 'member') + const profilesBefore = await databasePool.query<{ count: number }>( + `SELECT count(*)::int AS count FROM profiles WHERE user_id = $1`, + [user.userId] + ) + expect(profilesBefore.rows).toEqual([{ count: 0 }]) + + const gate = await databasePool.connect() + const pending: Promise[] = [] + try { + await gate.query('BEGIN') + // The literal statements of googleLoginData (login.ts), in their order. + await gate.query( + `UPDATE users + SET name = COALESCE($2, name), + picture = COALESCE($3, picture), + updated_at = NOW() + WHERE email = $1 + RETURNING id, email, name, picture, lifecycle_version`, + [user.email, 'profile-login', null] + ) + const gatePid = await backendPid(gate) + const acceptance = track(pending, accept(flowToken, user.email)) + // Witness: the acceptance waits on the user lock before it inserts the + // profile. With the old order it inserts the profile and waits on the + // by-id user lock instead, and this wait times out. + await waitForWaitingStatement(USER_EMAIL_LOCK, gatePid) + await gate.query( + `INSERT INTO profiles(user_id, display_name) + VALUES($1, $2) + ON CONFLICT (user_id) DO NOTHING`, + [user.userId, 'profile-login'] + ) + await gate.query('COMMIT') + + const settled = await acceptance + if (!('value' in settled)) throw settled.error + expect(settled.value.status, JSON.stringify(settled.value.body)).toBe(200) + expect(settled.value.body).toMatchObject({ accepted: true, userId: user.userId }) + 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: 'accepted', accepted_user_id: user.userId }]) + const profiles = await databasePool.query<{ count: number }>( + `SELECT count(*)::int AS count FROM profiles WHERE user_id = $1`, + [user.userId] + ) + expect(profiles.rows).toEqual([{ count: 1 }]) + } 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/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() +}) 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..44048a7b9e --- /dev/null +++ b/control-api/test/workflowRunReadAndApprovalV1Compatibility.realPostgres.integration.test.ts @@ -0,0 +1,784 @@ +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 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[latestProducer.env]) +) { + 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( + `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() }) + 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.canCallerReadWorkflowRunWithApprovalTarget(caller, ownerRow)).resolves.toBe( + true + ) + 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}`, + membershipStatus: 'active', + }, + { + label: 'teamless V1 session', + recipeIndex: 4, + claims: () => v1Claims(OWNER_ID, null), + expectedKey: `external-rest-api:user:${OWNER_ID}:team:null`, + membershipStatus: null, + }, + { + label: 'removed team membership', + recipeIndex: 5, + claims: () => v1Claims(OWNER_ID, TEAM_ID), + expectedKey: `external-rest-api:user:${OWNER_ID}:team:${TEAM_ID}`, + membershipStatus: 'deleted', + }, + ])('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.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 status FROM team_members WHERE team_id = $1 AND user_id = $2', + [TEAM_ID, OWNER_ID] + ) + expect(member.rows[0]).toEqual({ status: scenario.membershipStatus }) + } + + 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 }) + }) + + it('shows a team-scheduled run to a non-author only while the membership and the team grant are live', async () => { + const teamId = randomUUID() + const memberId = randomUUID() + const recipeName = `s2-live-team-grant-${randomUUID()}` + const liveRunId = randomUUID() + const auditRunId = randomUUID() + + await databasePool.query( + `INSERT INTO users(id, email, name) + VALUES ($1, 'pr334-s2-team-reader@example.invalid', 'PR334 S2 Team Reader')`, + [memberId] + ) + await databasePool.query( + `INSERT INTO teams(id, name) VALUES ($1, 'PR334 S2 Live Grant Team')`, + [teamId] + ) + await databasePool.query( + `INSERT INTO team_members(team_id, user_id, role, status) + VALUES ($1, $2, 'member', 'active')`, + [teamId, memberId] + ) + await databasePool.query( + `INSERT INTO team_workflow_triggers(team_id, recipe_namespace, recipe_name) + VALUES ($1, $2, $3)`, + [teamId, NS, recipeName] + ) + await databasePool.query( + `INSERT INTO workflow_runs( + run_id, recipe_namespace, recipe_name, phase, actor_type, team_id, + usage_team_id, actor_id, trigger_source, created_at + ) VALUES ($1, $2, $3, 'Running', 'scheduled', $4, NULL, NULL, 'schedule', NOW())`, + [liveRunId, NS, recipeName, teamId] + ) + 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, started_at, completed_at, + final_phase, step_count, snapshot_sha + ) VALUES ( + $1, $2, $3, $2, $3, $4, NULL, NULL, 'scheduled', 'schedule', + 'pr334-s2-live-team-grant-audit', NOW() - interval '2 hours', + NOW() - interval '2 hours', NOW() - interval '1 hour', 'Succeeded', 0, + 'synthetic-snapshot-sha' + )`, + [auditRunId, NS, recipeName, teamId] + ) + + const caller = { + kind: 'user-session' as const, + claims: v1Claims(memberId, teamId), + session: {} as never, + } + + async function observe(): Promise<{ + unscopedList: string[] + list: string[] + single: boolean + audit: string[] + }> { + const liveRow = ( + await databasePool.query('SELECT * FROM workflow_runs WHERE run_id = $1', [liveRunId]) + ).rows[0] + expect(liveRow, 'seeded team-scheduled live run').toBeDefined() + return { + unscopedList: (await reader.listCanonicalRuns(NS, recipeName, 10)) + .map(run => `${run.source}:${run.id}`) + .sort(), + list: (await reader.listCanonicalRuns(NS, recipeName, 10, caller)) + .map(run => `${run.source}:${run.id}`) + .sort(), + single: await reader.canCallerReadWorkflowRunWithApprovalTarget(caller, liveRow), + audit: (await reader.listAuditRunsByRecipe(NS, recipeName, 10, caller)).map(run => run.id), + } + } + + const bothRuns = [`audit:${auditRunId}`, `live:${liveRunId}`].sort() + const hidden = { unscopedList: bothRuns, list: [], single: false, audit: [] } + + // Live membership and live team grant: the non-author member sees the run in every read. + expect(await observe()).toEqual({ + unscopedList: bothRuns, + list: bothRuns, + single: true, + audit: [auditRunId], + }) + + // Membership removed, grant still present: hidden from every scoped read. The unscoped + // list is the witness that both rows are still stored and returned by the reader. + await databasePool.query( + `UPDATE team_members SET status = 'deleted' WHERE team_id = $1 AND user_id = $2`, + [teamId, memberId] + ) + expect( + ( + await databasePool.query( + 'SELECT status FROM team_members WHERE team_id = $1 AND user_id = $2', + [teamId, memberId] + ) + ).rows + ).toEqual([{ status: 'deleted' }]) + expect(await observe()).toEqual(hidden) + + // Membership live again, team grant deleted: hidden from every scoped read. + await databasePool.query( + `UPDATE team_members SET status = 'active' WHERE team_id = $1 AND user_id = $2`, + [teamId, memberId] + ) + await databasePool.query( + `DELETE FROM team_workflow_triggers + WHERE team_id = $1 AND recipe_namespace = $2 AND recipe_name = $3`, + [teamId, NS, recipeName] + ) + expect( + ( + await databasePool.query( + `SELECT tm.status, + (SELECT count(*)::int FROM team_workflow_triggers WHERE team_id = $1) AS grants + FROM team_members tm + WHERE tm.team_id = $1 AND tm.user_id = $2`, + [teamId, memberId] + ) + ).rows + ).toEqual([{ status: 'active', grants: 0 }]) + expect(await observe()).toEqual(hidden) + }) +}) diff --git a/control-api/vitest.config.ts b/control-api/vitest.config.ts index 9d7852c47d..5e49f9d520 100644 --- a/control-api/vitest.config.ts +++ b/control-api/vitest.config.ts @@ -31,15 +31,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', }, diff --git a/control-ui/app/secrets/llm/[name]/edit/__tests__/page.test.tsx b/control-ui/app/secrets/llm/[name]/edit/__tests__/page.test.tsx index 1e9988a24a..40da2e4ec2 100644 --- a/control-ui/app/secrets/llm/[name]/edit/__tests__/page.test.tsx +++ b/control-ui/app/secrets/llm/[name]/edit/__tests__/page.test.tsx @@ -7,6 +7,7 @@ import { render as rtlRender, screen, waitFor, + waitForElementToBeRemoved, within, } from '@testing-library/react' import { ToastProvider } from '@components/Toast' @@ -91,7 +92,11 @@ async function renderEditor() { ) - await screen.findByRole('button', { name: 'Update secret' }) + // The loading skeleton also renders "Update secret", disabled, so only its + // removal proves that the secret and Host reads finished. + await waitForElementToBeRemoved(() => + screen.queryByRole('status', { name: 'LLM secret loading' }) + ) return view } @@ -406,7 +411,9 @@ describe('Edit LLM secret page — update payload', () => { ) - await screen.findByRole('button', { name: 'Update secret' }) + await waitForElementToBeRemoved(() => + screen.queryByRole('status', { name: 'LLM secret loading' }) + ) removeExtraSlotIn('Anthropic') first.unmount() cleanup() 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/deploy/images.json b/deploy/images.json index 6c75113256..6133c5afcf 100644 --- a/deploy/images.json +++ b/deploy/images.json @@ -40,7 +40,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/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/control-api-runtime-access-profiles.tsv b/deploy/scripts/control-api-runtime-access-profiles.tsv index b4b0771816..4534f119ae 100644 --- a/deploy/scripts/control-api-runtime-access-profiles.tsv +++ b/deploy/scripts/control-api-runtime-access-profiles.tsv @@ -17,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 upsert +external_user_sessions legacy_dml +external_v1_session_revocations append gfs_audit legacy_dml gfs_blob_manifests none gfs_desktop_operator_links link_lifecycle diff --git a/deploy/scripts/run-control-api-db-migration.sh b/deploy/scripts/run-control-api-db-migration.sh index a30437e143..df9b59a36e 100755 --- a/deploy/scripts/run-control-api-db-migration.sh +++ b/deploy/scripts/run-control-api-db-migration.sh @@ -2,12 +2,14 @@ set -euo pipefail SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +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" 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}" @@ -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,54 +151,256 @@ 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 + 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 - log "Migration job $JOB_NAME was created concurrently; waiting for the existing job" + if [[ "$diagnostics" == *"AlreadyExists"* ]]; then + 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 - [ -n "$output" ] && printf '%s\n' "$output" >&2 + if [ -n "$diagnostics" ]; then + printf '%s\n' "$diagnostics" >&2 + fi return "$status" } -OVERLAY="" -JOB_NAME="${JOB_NAME:-control-api-db-migrate}" -TIMEOUT="${TIMEOUT:-300s}" -MIGRATION_ACTIVE_DEADLINE_SECONDS="" +# 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 -timeout_seconds() { - local value="$1" - local number multiplier - case "$value" in - *s) number="${value%s}"; multiplier=1 ;; - *m) number="${value%m}"; multiplier=60 ;; - *h) number="${value%h}"; multiplier=3600 ;; - *) number="$value"; multiplier=1 ;; - esac - [[ "$number" =~ ^[0-9]+$ ]] || die "unsupported migration timeout: $value" - number=$((10#$number)) - local seconds=$((number * multiplier)) - (( seconds > 0 && seconds <= 1800 )) || die "migration timeout must be 1s..1800s" - printf '%s' "$seconds" +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 "$JOB_IDENTITY_RUBY" -e ' + execution_contract = lambda do |job| + { "jobSpec" => normalize_job_spec(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(deep_sort(expected_contract))) + actual_identity = Digest::SHA256.hexdigest(JSON.generate(deep_sort(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" } +# Sends a foreground DELETE for the Job named $JOB_NAME that only the API +# server's UID precondition lets through: a Job another caller created under +# the same name is never deleted. Prints kubectl's output and returns its +# status; the caller classifies a failure. +delete_job_with_uid_precondition() { + local uid="$1" body_file delete_status=0 + body_file="$(mktemp)" + printf '%s' \ + '{"apiVersion":"v1","kind":"DeleteOptions","propagationPolicy":"Foreground","preconditions":{"uid":"'"$uid"'"}}' \ + >"$body_file" + kctl delete --raw \ + "/apis/batch/v1/namespaces/control-plane/jobs/$JOB_NAME" -f "$body_file" 2>&1 || \ + delete_status=$? + rm -f "$body_file" + return "$delete_status" +} + +# Waits until the Job and its pods are gone, within one shared +# MIGRATION_TERMINATION_PROOF_SECONDS deadline. +prove_job_and_pods_deleted() { + local deadline now remaining + deadline="$(( SECONDS + MIGRATION_TERMINATION_PROOF_SECONDS ))" + 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 +} + +terminate_migration_job() { + local delete_output delete_status + [ "$JOB_ROLE" = owner ] || return 1 + [ -n "$JOB_UID" ] || return 1 + if delete_output="$(delete_job_with_uid_precondition "$JOB_UID")"; then + delete_status=0 + else + delete_status=$? + fi + 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 + prove_job_and_pods_deleted +} + +# Replaces a Job that was terminal when its status was read. The delete is +# bound to that Job's UID, so a running Job another caller created under the +# same name in the meantime is never deleted. +delete_terminal_job_by_uid() { + local uid="$1" delete_output delete_status + if delete_output="$(delete_job_with_uid_precondition "$uid")"; then + delete_status=0 + else + delete_status=$? + fi + if [ "$delete_status" -ne 0 ]; then + if [[ "$delete_output" == *"Precondition failed"* ]]; then + die "migration Job was replaced by another caller; not deleting it" + fi + if [[ "$delete_output" == *"NotFound"* ]]; then + log "Previous terminal migration job $JOB_NAME is already gone" + return 0 + fi + if [ -n "$delete_output" ]; then + printf '%s\n' "$delete_output" >&2 + fi + die "could not delete previous terminal migration Job $JOB_NAME" + fi + prove_job_and_pods_deleted || \ + die "previous terminal migration Job deletion could not be proven within ${MIGRATION_TERMINATION_PROOF_SECONDS}s" +} + +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 case "$1" in --overlay) @@ -166,6 +425,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 @@ -174,7 +438,6 @@ done context_is_allowed || die "CONTEXT=$CONTEXT is not in ALLOWED_CONTEXTS" JOB_NAME="$(build_effective_job_name "$JOB_NAME")" -MIGRATION_ACTIVE_DEADLINE_SECONDS="$(timeout_seconds "$TIMEOUT")" RENDERED_MANIFEST_FILE="$(mktemp)" cleanup() { @@ -312,9 +575,12 @@ extract_control_api_image() { } build_job_manifest() { - ruby -ryaml -rjson -e ' + 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") - active_deadline_seconds = Integer(ARGV[1], 10) docs = [] YAML.load_stream(File.read(ARGV[0])) do |doc| next unless doc.is_a?(Hash) @@ -346,7 +612,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", @@ -367,23 +636,24 @@ 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" => { - # Bound the migration process itself to the same budget as the caller - # kubectl wait. A detached drain must not keep migrating after the deploy - # gate has timed out and reported failure. - "activeDeadlineSeconds" => active_deadline_seconds, # Retry a transient migration abort instead of failing the whole deploy. # Migration 0090_plugin_workload_sdk_runtime_contract_reconciliation takes an EXCLUSIVE lock on agent_run_events with a bounded # lock_timeout; if a concurrent writer (e.g. the trace-retention prune # 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" => { @@ -416,8 +686,12 @@ build_job_manifest() { job["spec"]["template"]["spec"][key] = value if value end + 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))) + puts JSON.generate(job) - ' "$RENDERED_MANIFEST_FILE" "$MIGRATION_ACTIVE_DEADLINE_SECONDS" + ' "$RENDERED_MANIFEST_FILE" } db_query() { @@ -566,7 +840,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++ @@ -577,6 +851,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)" @@ -811,8 +1107,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" \ @@ -825,13 +1122,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 @@ -839,7 +1136,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)), @@ -857,6 +1154,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" \ @@ -1138,29 +1502,57 @@ 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" - 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 + 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 + if terminal + uid = job.dig("metadata", "uid").to_s + abort("terminal migration Job has no metadata.uid") if uid.empty? + puts("terminal #{uid}") + else + puts("nonterminal") + end + ')"; then + die "could not determine existing migration Job status" + fi + read -r job_state terminal_job_uid <<<"$job_state" + 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" + elif [ "$job_state" = "terminal" ] && [ -n "$terminal_job_uid" ]; then + log "Deleting previous terminal migration job $JOB_NAME (uid $terminal_job_uid)" + delete_terminal_job_by_uid "$terminal_job_uid" log "Starting migration job $JOB_NAME" create_job + else + die "could not determine existing migration Job status" fi else log "Starting migration job $JOB_NAME" 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 + 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" fi log "Migration job completed successfully" diff --git a/deploy/scripts/workflow-recipes-runtime-access-profiles.tsv b/deploy/scripts/workflow-recipes-runtime-access-profiles.tsv index 4b553d831b..99bad098c8 100644 --- a/deploy/scripts/workflow-recipes-runtime-access-profiles.tsv +++ b/deploy/scripts/workflow-recipes-runtime-access-profiles.tsv @@ -1,8 +1,107 @@ # public relationworkflow_recipes_runtime access profile +administrative_events none +agent_run_events 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_teams none +invitations none +llm_allowed_models none +llm_allowed_models_audit none +llm_catalog_dismissed none +llm_catalog_sync_runs none +llm_model_prices none +llm_provider_attempt_tickets none +llm_provider_attempts none +llm_provider_catalog_settings none +member_registration_credentials none +mcp_secret_rollback_permits none +notification_deliveries none +dynamic_clients none +oauth_grants none +password_admission_rollout_state 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 diff --git a/desktop-app/package.json b/desktop-app/package.json index d6b7a8e9d2..243319b9aa 100644 --- a/desktop-app/package.json +++ b/desktop-app/package.json @@ -21,6 +21,7 @@ "test:native:sandbox-shortcut-focus": "npm run build:main --silent && node ./scripts/run-native-sandbox-shortcut-focus.mjs", "test:native:chat-workspace-geometry": "node ./scripts/run-native-chat-workspace-geometry.mjs", "test:native:sidebar-navigation-geometry": "node ./scripts/run-native-sidebar-navigation-geometry.mjs", + "test:e2e:session-team-ux": "npm run build && playwright test --config test/e2e-playwright/playwright.session-team-ux.config.ts", "test:e2e:gfs-operator": "npm run build && playwright test --config test/e2e-playwright/playwright.gfs-desktop-operator.config.ts", "qa:recorder:auth": "npm run build && playwright test --config test/e2e-playwright/playwright.qa-recorder.config.ts qa-recorder-auth.spec.ts", "qa:recorder:navigation": "npm run build && playwright test --config test/e2e-playwright/playwright.qa-recorder.config.ts qa-recorder-navigation.spec.ts", diff --git a/desktop-app/src/__tests__/appService.bootstrap.test.ts b/desktop-app/src/__tests__/appService.bootstrap.test.ts index 865183a094..4621af8c68 100644 --- a/desktop-app/src/__tests__/appService.bootstrap.test.ts +++ b/desktop-app/src/__tests__/appService.bootstrap.test.ts @@ -593,7 +593,20 @@ describe('AppService invitation configuration lookup', () => { } service.authClient = { - getMe: vi.fn().mockRejectedValue(new ApiError('401 Unauthorized', 401, '')), + getMe: vi.fn().mockRejectedValue( + new ApiError( + '401 Unauthorized: invalid_session', + 401, + JSON.stringify({ + error: { + code: 'invalid_session', + message: 'The session is not valid.', + correlationId: 'synthetic-correlation', + retryable: false, + }, + }) + ) + ), } as never service.tokenStore = { getSessionToken: vi.fn().mockResolvedValue('stored-token'), @@ -617,8 +630,11 @@ describe('AppService invitation configuration lookup', () => { ) }) - it('clears a saved token when startup session restore is forbidden', async () => { - const configPath = await createTempConfigPath('clerum-desktop-restore-forbidden') + async function restoreWithMeAnswer( + configPrefix: string, + answer: (apiError: { new (message: string, status: number, bodyText: string): Error }) => Error + ) { + const configPath = await createTempConfigPath(configPrefix) await fs.writeFile( configPath, JSON.stringify({ @@ -638,6 +654,7 @@ describe('AppService invitation configuration lookup', () => { import('../httpClient.js'), ]) const clearSessionToken = vi.fn().mockResolvedValue(undefined) + const getMe = vi.fn().mockRejectedValue(answer(ApiError)) const service = new AppService() as unknown as { authClient: { getMe: ReturnType } tokenStore: { @@ -648,9 +665,7 @@ describe('AppService invitation configuration lookup', () => { initialize: () => Promise } - service.authClient = { - getMe: vi.fn().mockRejectedValue(new ApiError('403 Forbidden', 403, '')), - } as never + service.authClient = { getMe } as never service.tokenStore = { getSessionToken: vi.fn().mockResolvedValue('stored-token'), clearSessionToken, @@ -664,8 +679,37 @@ describe('AppService invitation configuration lookup', () => { me: null, sessionGeneration: 0, }) + // The restore asked /me with the saved token. + expect(getMe).toHaveBeenCalledTimes(1) + expect(getMe).toHaveBeenCalledWith('stored-token') + return { clearSessionToken } + } + + it.each([ + ['a bare 401 (an earlier edge)', 401, '{"error":"Unauthorized"}'], + ['a 403', 403, ''], + ['a 410', 410, ''], + ])( + 'clears a saved token when /me ends startup session restore with %s', + async (_name, status, bodyText) => { + const { clearSessionToken } = await restoreWithMeAnswer( + 'clerum-desktop-restore-ended', + ApiError => new ApiError(`${status} ended`, status, bodyText) + ) + + expect(clearSessionToken).toHaveBeenCalledTimes(1) + } + ) + + it('keeps a saved token when /me gives startup session restore no verdict', async () => { + // A 503 is an outage, not a verdict on the session: this start signs out but + // keeps the saved token for the next one. + const { clearSessionToken } = await restoreWithMeAnswer( + 'clerum-desktop-restore-unavailable', + ApiError => new ApiError('503 Service Unavailable', 503, '') + ) - expect(clearSessionToken).toHaveBeenCalled() + expect(clearSessionToken).not.toHaveBeenCalled() }) it('persists a manually saved runtime environment', async () => { diff --git a/desktop-app/src/__tests__/appService.devEdgeCompatibility.test.ts b/desktop-app/src/__tests__/appService.devEdgeCompatibility.test.ts new file mode 100644 index 0000000000..1ef6735c49 --- /dev/null +++ b/desktop-app/src/__tests__/appService.devEdgeCompatibility.test.ts @@ -0,0 +1,124 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { AppService } from '../appService.js' +import { getActiveEnvKey, getActiveLegacyEnvKeys } from '../config.js' + +// The saved-session restore talks to the configured External REST endpoint. +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(), +})) + +afterEach(() => { + vi.unstubAllGlobals() +}) + +const SAVED_TOKEN = 'synthetic-saved-session-token' + +/** + * Startup restore (`initialize()` -> `restoreSavedSession`) against each edge a + * new Desktop can meet during a staged rollout or a rollback. The `/me` answer + * comes over the real transport (`AuthClient.getMe` -> `requestJson`), so the + * error the restore classifies is the one production builds from the response. + */ +async function restoreAgainst(response: () => Response) { + const fetchSpy = vi.fn(async () => response()) + vi.stubGlobal('fetch', fetchSpy) + const tokenStore = { + getSessionToken: vi.fn().mockResolvedValue(SAVED_TOKEN), + clearSessionToken: vi.fn().mockResolvedValue(undefined), + setSessionToken: vi.fn().mockResolvedValue(undefined), + } + const service = new AppService({ tokenStore: tokenStore as never }) + + const session = await service.initialize() + + // Witness: the restore asked /me exactly once, with the saved token. + expect(fetchSpy).toHaveBeenCalledTimes(1) + const [url, init] = fetchSpy.mock.calls[0] as unknown as [string, RequestInit] + expect(url).toBe('https://api.example.test/api/v1/me') + expect(init.method).toBe('GET') + expect((init.headers as Record).authorization).toBe(`Bearer ${SAVED_TOKEN}`) + expect(tokenStore.getSessionToken).toHaveBeenCalledTimes(1) + return { session, tokenStore } +} + +function expectSavedTokenCleared(tokenStore: { clearSessionToken: ReturnType }) { + expect(tokenStore.clearSessionToken).toHaveBeenCalledTimes(1) + expect(tokenStore.clearSessionToken).toHaveBeenCalledWith(getActiveEnvKey(), { + legacyEnvKeys: getActiveLegacyEnvKeys(), + }) +} + +const signedOut = { authenticated: false, me: null, sessionGeneration: 0 } + +describe('AppService saved-session restore across edge versions', () => { + it('clears the saved token when an earlier edge answers /me with a bare 401', async () => { + // The earlier edge's requireAuth answers an invalid or expired session token + // with exactly this status and body. + const { session, tokenStore } = await restoreAgainst( + () => + new Response('{"error":"Unauthorized"}', { + status: 401, + statusText: 'Unauthorized', + headers: { 'content-type': 'application/json' }, + }) + ) + + expect(session).toEqual(signedOut) + expectSavedTokenCleared(tokenStore) + }) + + it('clears the saved token when the current edge answers /me with invalid_session', async () => { + const { session, tokenStore } = await restoreAgainst( + () => + new Response( + JSON.stringify({ + error: { + code: 'invalid_session', + message: 'The session is not valid.', + correlationId: 'synthetic-correlation', + retryable: false, + }, + }), + { + status: 401, + statusText: 'Unauthorized', + headers: { 'content-type': 'application/json' }, + } + ) + ) + + expect(session).toEqual(signedOut) + expectSavedTokenCleared(tokenStore) + }) + + it('keeps the saved token when /me answers 503', async () => { + const { session, tokenStore } = await restoreAgainst( + () => + new Response('{"error":"Service Unavailable"}', { + status: 503, + statusText: 'Service Unavailable', + headers: { 'content-type': 'application/json' }, + }) + ) + + // An outage is no verdict on the session: this start signs out, the token stays. + expect(session).toEqual(signedOut) + expect(tokenStore.clearSessionToken).not.toHaveBeenCalled() + }) +}) diff --git a/desktop-app/src/__tests__/appService.entityChanges.test.ts b/desktop-app/src/__tests__/appService.entityChanges.test.ts index 5b4f2f57b4..1b26281725 100644 --- a/desktop-app/src/__tests__/appService.entityChanges.test.ts +++ b/desktop-app/src/__tests__/appService.entityChanges.test.ts @@ -1,6 +1,5 @@ import { describe, expect, it, vi } from 'vitest' import { AppService } from '../appService.js' -import { config } from '../config.js' import { ApiError } from '../httpClient.js' import type { EntityChangeStreamEvent } from '../types.js' @@ -45,6 +44,16 @@ function setSyntheticEntityChangeSession(service: object, value: string, generat state.entityChangeSessionGeneration = generation } +// The edge's invalid_session envelope: the only /me answer that ends a session. +const INVALID_SESSION_BODY = JSON.stringify({ + error: { + code: 'invalid_session', + message: 'The session is not valid.', + correlationId: 'synthetic-correlation', + retryable: false, + }, +}) + describe('AppService entity-change fan-out', () => { it('rebinds a live subscriber after expiry through public Google login', async () => { const service = new AppService() as any @@ -66,6 +75,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 +292,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 +357,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 +416,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 +448,125 @@ 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, INVALID_SESSION_BODY)) + 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, INVALID_SESSION_BODY)), + } + 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.hostWaking.test.ts b/desktop-app/src/__tests__/appService.hostWaking.test.ts index 46cb5bcf37..f8fac49a13 100644 --- a/desktop-app/src/__tests__/appService.hostWaking.test.ts +++ b/desktop-app/src/__tests__/appService.hostWaking.test.ts @@ -150,6 +150,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__/appService.sessionRejection.test.ts b/desktop-app/src/__tests__/appService.sessionRejection.test.ts new file mode 100644 index 0000000000..f7da8061aa --- /dev/null +++ b/desktop-app/src/__tests__/appService.sessionRejection.test.ts @@ -0,0 +1,565 @@ +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, + } +} + +// A bare 401: what a non-/me call answers, and what an earlier edge's /me answers +// for an invalid or expired session token (a session end when /me sends it). +function unauthorized(): ApiError { + return new ApiError('401 Unauthorized', 401, '{"error":"Unauthorized"}') +} + +// The current edge's /me answer for an ended session: the invalid_session envelope. +function sessionEnded(): ApiError { + return new ApiError( + '401 Unauthorized: invalid_session', + 401, + JSON.stringify({ + error: { + code: 'invalid_session', + message: 'The session is not valid.', + correlationId: 'synthetic-correlation', + retryable: false, + }, + }) + ) +} + +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 = 'synthetic-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 = sessionEnded() + 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('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: 'synthetic-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('synthetic-session-token') + expect(tokenStore.clearSessionToken).not.toHaveBeenCalled() + expect(onSessionCleared).not.toHaveBeenCalled() + }) + + it.each([ + ['a bare 401 (an earlier edge)', unauthorized()], + ['a 401 that is not JSON', new ApiError('401 Unauthorized', 401, 'Unauthorized')], + ['a 403', new ApiError('403 Forbidden', 403, '{"error":"Forbidden"}')], + ['a 410', new ApiError('410 Gone', 410, '')], + ])('ends the session when /me answers %s during a team-context lookup', async (_name, error) => { + const { service, tokenStore, onSessionCleared } = createService(sessionMe({ teamId: null })) + service.authClient = { getMe: vi.fn().mockRejectedValue(error) } + const operation = vi.fn() + + await expect(service.runWithTeamContext('team-b', operation)).rejects.toBe(error) + + expect(service.authClient.getMe).toHaveBeenCalledTimes(1) + expect(service.authClient.getMe).toHaveBeenCalledWith('synthetic-session-token') + expect(operation).not.toHaveBeenCalled() + expect(service.suspendDesktopGfsUploadsForAuthBoundary).toHaveBeenCalledTimes(1) + expectSessionEnded(service, tokenStore, onSessionCleared) + }) + + it('leaves a newer committed session alone when /me rejects the old token', async () => { + const { service, tokenStore, onSessionCleared } = createService(sessionMe({ teamId: null })) + const rejection = sessionEnded() + 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 = sessionEnded() + 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: 'synthetic-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(sessionEnded()), + } + + 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('synthetic-session-token') + expectSessionEnded(service, tokenStore, onSessionCleared) + }) + + it('ends the session when a team call answers 401 and /me answers a bare 401', async () => { + const { service, tokenStore, onSessionCleared } = createService(sessionMe()) + const rejection = unauthorized() + const meAnswer = unauthorized() + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + service.authClient = { + listTeams: vi.fn().mockResolvedValue({ + currentTeamId: 'team-a', + items: [{ id: 'team-a', name: 'Team A', role: 'member' }], + }), + listWorkflows: vi.fn().mockRejectedValue(rejection), + switchTeam: vi.fn(), + getMe: vi.fn().mockRejectedValue(meAnswer), + } + + try { + await expect(service.listWorkflows()).rejects.toBe(rejection) + // An earlier edge answers an ended session on /me with a bare 401: a verdict. + expect(service.authClient.getMe).toHaveBeenCalledTimes(1) + expect(service.authClient.getMe).toHaveBeenCalledWith('synthetic-session-token') + expect(warn).not.toHaveBeenCalledWith( + '[AppService] Session check returned no verdict:', + meAnswer + ) + } finally { + warn.mockRestore() + } + expectSessionEnded(service, tokenStore, onSessionCleared) + }) + + describe('listing without a team list (F9)', () => { + const teamListings = [ + ['the team list fails', () => vi.fn().mockRejectedValue(new ApiError('503', 503, ''))], + ['the team list is empty', () => vi.fn().mockResolvedValue({ currentTeamId: '', items: [] })], + ] as const + + it.each(teamListings)( + 'ends the session when %s, the fallback answers 401 and /me ends the session', + async (_name, listTeams) => { + const { service, tokenStore, onSessionCleared } = createService(sessionMe()) + const rejection = unauthorized() + service.authClient = { + listTeams: listTeams(), + listWorkflows: vi.fn().mockRejectedValue(rejection), + getMe: vi.fn().mockRejectedValue(sessionEnded()), + } + + await expect(service.listWorkflows()).rejects.toBe(rejection) + + expect(service.authClient.listWorkflows).toHaveBeenCalledTimes(1) + expect(service.authClient.listWorkflows).toHaveBeenCalledWith('synthetic-session-token') + expect(service.authClient.getMe).toHaveBeenCalledTimes(1) + expect(service.authClient.getMe).toHaveBeenCalledWith('synthetic-session-token') + expectSessionEnded(service, tokenStore, onSessionCleared) + } + ) + + it.each(teamListings)( + 'keeps the session when %s, the fallback answers 401 and /me accepts it', + async (_name, listTeams) => { + const { service, tokenStore, onSessionCleared } = createService(sessionMe()) + const rejection = unauthorized() + service.authClient = { + listTeams: listTeams(), + listWorkflows: vi.fn().mockRejectedValue(rejection), + getMe: vi.fn().mockResolvedValue(sessionMe()), + } + + await expect(service.listWorkflows()).rejects.toBe(rejection) + + // /me was asked and accepted the session. + expect(service.authClient.getMe).toHaveBeenCalledTimes(1) + expect(service.sessionToken).toBe('synthetic-session-token') + expect(tokenStore.clearSessionToken).not.toHaveBeenCalled() + expect(onSessionCleared).not.toHaveBeenCalled() + } + ) + + it('rethrows a non-401 fallback failure without asking /me', async () => { + const { service, tokenStore } = createService(sessionMe()) + const unavailable = new ApiError('503', 503, '') + service.authClient = { + listTeams: vi.fn().mockResolvedValue({ currentTeamId: '', items: [] }), + listWorkflows: vi.fn().mockRejectedValue(unavailable), + getMe: vi.fn(), + } + + await expect(service.listWorkflows()).rejects.toBe(unavailable) + + expect(service.authClient.listWorkflows).toHaveBeenCalledTimes(1) + expect(service.authClient.getMe).not.toHaveBeenCalled() + expect(tokenStore.clearSessionToken).not.toHaveBeenCalled() + }) + }) + + 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('synthetic-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('synthetic-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: 'synthetic-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('synthetic-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: 'synthetic-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 = sessionEnded() + 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 !== 'synthetic-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('synthetic-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 !== 'synthetic-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('synthetic-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..5b379d253e --- /dev/null +++ b/desktop-app/src/__tests__/appService.sessionRejectionClosing.test.ts @@ -0,0 +1,312 @@ +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' + +// The only /me answer that ends a session: the edge's invalid_session envelope. +const sessionEnded = () => + new ApiError( + '401 Unauthorized: invalid_session', + 401, + JSON.stringify({ + error: { + code: 'invalid_session', + message: 'The session is not valid.', + correlationId: 'synthetic-correlation', + retryable: false, + }, + }) + ) +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(sessionEnded()) + 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..27df29f04f --- /dev/null +++ b/desktop-app/src/__tests__/appService.sessionRejectionLogin.test.ts @@ -0,0 +1,143 @@ +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(), +})) + +// The current edge's /me answer for an ended session: the invalid_session envelope. +const sessionEnded = () => + new ApiError( + '401 Unauthorized: invalid_session', + 401, + JSON.stringify({ + error: { + code: 'invalid_session', + message: 'The session is not valid.', + correlationId: 'synthetic-correlation', + retryable: false, + }, + }) + ) +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(sessionEnded()) + 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(sessionEnded()) + 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(sessionEnded()) } + + await service.confirmCommittedSessionWithMe() + + expect(service.sessionToken).toBeNull() + expect(tokenStore.clearSessionToken).toHaveBeenCalledTimes(1) + expect(onSessionCleared).toHaveBeenCalledTimes(1) + }) +}) diff --git a/desktop-app/src/__tests__/authClient.passwordLogin.test.ts b/desktop-app/src/__tests__/authClient.passwordLogin.test.ts new file mode 100644 index 0000000000..3f58bafcca --- /dev/null +++ b/desktop-app/src/__tests__/authClient.passwordLogin.test.ts @@ -0,0 +1,151 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { AuthClient } from '../authClient' +import { ApiError } from '../httpClient' + +function stubLoginResponse(status: number, body: string, headers: Record = {}) { + const fetchMock = vi.fn( + async () => new Response(body, { status, statusText: 'Server Answer', headers }) + ) + vi.stubGlobal('fetch', fetchMock) + return fetchMock +} + +async function loginFailure(): Promise { + const error = await new AuthClient().passwordLogin('user@example.com', 'secret').catch(e => e) + expect(error).toBeInstanceOf(Error) + return error as Error +} + +async function loginApiError(): Promise { + const error = await loginFailure() + expect(error).toBeInstanceOf(ApiError) + return error as ApiError +} + +describe('AuthClient.passwordLogin errors', () => { + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('carries the status and the wait of a rate-limited login at the end of the message', async () => { + const body = JSON.stringify({ error: 'rate_limited', retryAfterSeconds: 17 }) + const fetchMock = stubLoginResponse(429, body, { 'retry-after': '17' }) + + const error = await loginApiError() + + expect(fetchMock).toHaveBeenCalledTimes(1) + expect(error.status).toBe(429) + expect(error.message).toMatch(/ httpStatus=429 retryAfterSeconds=17$/) + expect(error.retryAfter).toBe('17') + expect(error.bodyText).toBe(body) + }) + + it('states the status alone when the response has no wait', async () => { + stubLoginResponse(429, JSON.stringify({ error: 'rate_limited' })) + + const error = await loginApiError() + + expect(error.message).toMatch(/ httpStatus=429$/) + }) + + it.each([ + ['an HTTP date', 'Wed, 21 Oct 2026 07:28:00 GMT'], + ['zero', '0'], + ['a negative number', '-5'], + ['a fraction', '1.5'], + ['text', 'abc'], + ['an exponent', '1e3'], + ['an unsafe integer', '9007199254740993'], + ])('gives no wait for a Retry-After of %s', async (_label, retryAfter) => { + stubLoginResponse(429, JSON.stringify({ error: 'rate_limited' }), { + 'retry-after': retryAfter, + }) + + const error = await loginApiError() + + expect(error.message).toMatch(/ httpStatus=429$/) + expect(error.message).not.toContain('retryAfterSeconds=') + // The header stays available on the error for logs. + expect(error.retryAfter).toBe(retryAfter) + }) + + it('marks any other server answer with its own status and keeps the error fields', async () => { + const body = JSON.stringify({ error: 'invalid_credentials' }) + stubLoginResponse(401, body, { 'retry-after': '17' }) + + const error = await loginApiError() + + expect(error.status).toBe(401) + expect(error.bodyText).toBe(body) + expect(error.retryAfter).toBe('17') + expect(error.message).toContain('invalid_credentials') + expect(error.message).toMatch(/ httpStatus=401$/) + }) + + it.each([ + [401, JSON.stringify({ error: 'httpStatus=429' })], + [500, 'upstream said httpStatus=429'], + [502, 'gateway failed httpStatus=429 retryAfterSeconds=900'], + [503, 'unavailable signInTransport=noResponse'], + [401, 'denied httpStatus=429 signInTransport=noResponse'], + ])('never lets a %i body imitate the transport marker', async (status, body) => { + stubLoginResponse(status, body, { 'retry-after': '17' }) + + const error = await loginApiError() + + expect(error.status).toBe(status) + expect(error.message.endsWith(` httpStatus=${status}`)).toBe(true) + expect(error.message).not.toContain('retryAfterSeconds=') + expect(error.message).not.toContain('signInTransport=') + expect(error.bodyText).toBe(body) + }) + + it('reads a real 429 with the same body as that 429, with its own wait', async () => { + stubLoginResponse(429, 'gateway failed httpStatus=500 retryAfterSeconds=900', { + 'retry-after': '17', + }) + + const error = await loginApiError() + + expect(error.message).toMatch(/ httpStatus=429 retryAfterSeconds=17$/) + expect(error.message).not.toContain('retryAfterSeconds=900') + expect(error.message).not.toContain('httpStatus=500') + }) + + it('marks a failure with no HTTP answer as such and keeps the cause', async () => { + const cause = new TypeError('fetch failed httpStatus=429') + vi.stubGlobal( + 'fetch', + vi.fn(async () => { + throw cause + }) + ) + + const error = await loginFailure() + + expect(error).not.toBeInstanceOf(ApiError) + expect(error.message).toMatch(/ signInTransport=noResponse$/) + expect(error.message).toContain('fetch failed') + expect(error.message).not.toContain('httpStatus=') + expect(error.cause).toBe(cause) + }) + + it('marks a 200 answer that is not JSON the same way, even when the body has the token', async () => { + stubLoginResponse(200, 'signInTransport=noResponse httpStatus=401') + + const error = await loginFailure() + + expect(error).not.toBeInstanceOf(ApiError) + expect(error.message).toMatch(/ signInTransport=noResponse$/) + expect(error.message).not.toMatch(/httpStatus=401$/) + }) + + it('returns the login result on success', async () => { + const result = { sessionToken: 'token', me: { id: 'user' } } + stubLoginResponse(200, JSON.stringify(result)) + + await expect(new AuthClient().passwordLogin('user@example.com', 'secret')).resolves.toEqual( + result + ) + }) +}) diff --git a/desktop-app/src/__tests__/mainGfsNavigationIpc.test.ts b/desktop-app/src/__tests__/mainGfsNavigationIpc.test.ts index 6adb10cca9..410a2e67ef 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 | ((options?: Record) => Record), @@ -107,8 +108,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 @@ -269,6 +274,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/__tests__/rpcProxyClient.test.ts b/desktop-app/src/__tests__/rpcProxyClient.test.ts index ac314501b0..2c6438858f 100644 --- a/desktop-app/src/__tests__/rpcProxyClient.test.ts +++ b/desktop-app/src/__tests__/rpcProxyClient.test.ts @@ -2,6 +2,31 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { ApiError } from '../httpClient.js' import { RpcProxyClient, SANDBOX_UI_MINT_TIMEOUT_MS } 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 default clients use a fixed persisted base URL. vi.mock('../config.js', () => ({ config: { diff --git a/desktop-app/src/appService.ts b/desktop-app/src/appService.ts index a040ad9094..99eff0d927 100644 --- a/desktop-app/src/appService.ts +++ b/desktop-app/src/appService.ts @@ -880,6 +880,7 @@ export function migrateDesktopGfsUploadState(value: unknown): { export interface AppServiceOptions { tokenStore?: TokenStore onSandboxUiSessionInvalidated?: () => void + onSessionCleared?: () => void } export class AppService { @@ -897,6 +898,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 @@ -912,6 +914,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 @@ -1000,6 +1005,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[] { @@ -1027,7 +1033,14 @@ export class AppService { ) } - private static isRejectedStoredSessionError(error: unknown): boolean { + /** + * True when `/me` answered 401, 403 or 410: the session has ended. The current + * edge answers `/me` 401 only with its `invalid_session` envelope; an earlier + * edge answers an invalid or expired session token with a bare + * `401 {"error":"Unauthorized"}`. Both mean the session ended, so the body is + * not read. Any other failure (an outage, a 5xx) is no verdict on the session. + */ + private static isSessionEndedByMe(error: unknown): boolean { return ( error instanceof ApiError && (error.status === 401 || error.status === 403 || error.status === 410) @@ -1276,17 +1289,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 } @@ -1364,7 +1367,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.isSessionEndedByMe(error)) { + await this.withNativeAuthEnvironmentCommit(() => + this.endSessionRejectedByMe(session.sessionToken) + ) + } + throw error + } context = await this.withNativeAuthEnvironmentCommit(async () => { this.assertSessionGeneration(session.sessionGeneration) if (this.sessionToken !== session.sessionToken) { @@ -1539,6 +1552,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 @@ -1590,6 +1604,80 @@ 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 `/me` declared ended (`isSessionEndedByMe`). + * 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. A 401, 403 or 410 from `/me` ends the session (any 401 body: + * see `isSessionEndedByMe`); 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.isSessionEndedByMe(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 @@ -1860,7 +1948,10 @@ export class AppService { return this.withNativeAuthEnvironmentCommit(async () => { if (!ownsRestore()) return currentSession() if (this.sessionToken !== null || this.me !== null) this.clearAuthenticatedSessionState() - if (AppService.isRejectedStoredSessionError(error)) { + // A 401, 403 or 410 from `/me` ended the session and removes the saved token; + // any other restore failure signs this process out and keeps the token for + // the next start. + if (AppService.isSessionEndedByMe(error)) { await this.tokenStore.clearSessionToken(getActiveEnvKey(), { legacyEnvKeys: getActiveLegacyEnvKeys(), }) @@ -2231,6 +2322,7 @@ export class AppService { if (this.interactiveLoginAttempts === 0 && !this.sessionToken && !this.me) { await this.commitPendingRuntimeConfigDiscoveryIfSafe().catch(() => undefined) } + if (this.interactiveLoginAttempts === 0) await this.endSessionRejectedDuringLogin() }) } } @@ -2425,6 +2517,7 @@ export class AppService { if (this.interactiveLoginAttempts === 0 && !this.sessionToken && !this.me) { await this.commitPendingRuntimeConfigDiscoveryIfSafe().catch(() => undefined) } + if (this.interactiveLoginAttempts === 0) await this.endSessionRejectedDuringLogin() }) } } @@ -3695,6 +3788,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 { @@ -4021,54 +4124,84 @@ export class AppService { async listWorkflows(): Promise { const token = this.requireSessionToken() - 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) + // 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') + } + // A rejected workflow call (401) leaves the listing incomplete. If the session + // that started the listing is still the current one, /me decides whether it + // ended. The error is rethrown either way, so no list is returned. Callers keep + // their own handling of every other error. + const rethrowAfterSessionCheck = async (error: unknown): Promise => { + if (!sessionChanged() && error instanceof ApiError && error.status === 401) { + await this.confirmCommittedSessionWithMe() } - - 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) + throw error + } + // 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).catch(rethrowAfterSessionCheck) + assertSameSession() + return result + } + let listed: { currentTeamId: string; items: TeamSummary[] } + try { + 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, or a team call was rejected: + // no list is returned. Any other team failure skips that team. + if (sessionChanged() || (error instanceof ApiError && error.status === 401)) { + await rethrowAfterSessionCheck(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 { @@ -4179,14 +4312,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/authClient.ts b/desktop-app/src/authClient.ts index 57474de63a..22138f8387 100644 --- a/desktop-app/src/authClient.ts +++ b/desktop-app/src/authClient.ts @@ -326,6 +326,39 @@ async function readErrorBody(response: Response): Promise { } } +/** The sign-in markers, wherever a response body printed them. */ +const UNVETTED_SIGN_IN_MARKERS = /(?:^|\s)(?:httpStatus|retryAfterSeconds|signInTransport)=\S*/g + +function vettedRetryAfterSeconds(value: string | undefined): number | null { + if (!value || !/^[1-9]\d*$/.test(value)) return null + const seconds = Number(value) + return Number.isSafeInteger(seconds) ? seconds : null +} + +/** + * Only the message crosses IPC to the sign-in screen, which reads the marker at its end + * (lib/signInFailure.ts). Every failure of the credential call gets one, so text from a + * response body can never be in that position. A failure with no HTTP answer gets its + * own token so the screen can tell it from a server answer. + */ +function markedPasswordLoginFailure(error: unknown): Error { + const message = (error instanceof Error ? error.message : String(error)).replace( + UNVETTED_SIGN_IN_MARKERS, + '' + ) + if (!(error instanceof ApiError)) { + return new Error(`${message} signInTransport=noResponse`, { cause: error }) + } + const seconds = error.status === 429 ? vettedRetryAfterSeconds(error.retryAfter) : null + const wait = seconds === null ? '' : ` retryAfterSeconds=${seconds}` + return new ApiError( + `${message} httpStatus=${error.status}${wait}`, + error.status, + error.bodyText, + error.retryAfter + ) +} + export class AuthClient { async health(): Promise<{ status: string }> { return requestJson<{ status: string }>('GET', url('/health')) @@ -358,9 +391,13 @@ export class AuthClient { } async passwordLogin(email: string, password: string): Promise { - return requestJson('POST', url('/api/v1/auth/password-login'), { - body: { email, password }, - }) + try { + return await requestJson('POST', url('/api/v1/auth/password-login'), { + body: { email, password }, + }) + } catch (error) { + throw markedPasswordLoginFailure(error) + } } async getMe(sessionToken: string): Promise { diff --git a/desktop-app/src/main.ts b/desktop-app/src/main.ts index 1313c0a1f6..638da97460 100644 --- a/desktop-app/src/main.ts +++ b/desktop-app/src/main.ts @@ -92,6 +92,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/test/adminCreateTeamForUser.test.ts b/desktop-app/test/adminCreateTeamForUser.test.ts new file mode 100644 index 0000000000..873294e690 --- /dev/null +++ b/desktop-app/test/adminCreateTeamForUser.test.ts @@ -0,0 +1,64 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { adminCreateTeamForUser } from './e2e-playwright/workflow-approval-quadrants/recipes' + +const TEAM_ID = '11111111-1111-4111-8111-111111111111' +const USER_ID = '22222222-2222-4222-8222-222222222222' + +function stubAdminApi(memberStatus: number) { + const requests: Array<{ path: string; body: unknown; authorization: string | null }> = [] + const fetchMock = vi.fn(async (input: string, init: RequestInit) => { + const url = new URL(input) + requests.push({ + path: url.pathname, + body: JSON.parse(String(init.body)), + authorization: new Headers(init.headers).get('authorization'), + }) + if (url.pathname === '/api/v1/admin/auth/login') { + return new Response(JSON.stringify({ token: 'synthetic-admin-token' }), { status: 200 }) + } + if (url.pathname === '/api/v1/admin/teams') { + return new Response(JSON.stringify({ id: TEAM_ID }), { status: 200 }) + } + if (url.pathname === `/api/v1/admin/teams/${TEAM_ID}/members`) { + return new Response(JSON.stringify({ teamId: TEAM_ID }), { status: memberStatus }) + } + throw new Error(`unexpected admin request: ${url.pathname}`) + }) + vi.stubGlobal('fetch', fetchMock) + return requests +} + +describe('adminCreateTeamForUser', () => { + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('creates the team, then makes the user a member of it', async () => { + const requests = stubAdminApi(200) + + await expect(adminCreateTeamForUser('e2e team', USER_ID)).resolves.toBe(TEAM_ID) + + expect(requests.map(request => request.path)).toEqual([ + '/api/v1/admin/auth/login', + '/api/v1/admin/teams', + `/api/v1/admin/teams/${TEAM_ID}/members`, + ]) + expect(requests[1]).toMatchObject({ + body: { name: 'e2e team' }, + authorization: 'Bearer synthetic-admin-token', + }) + expect(requests[2]).toMatchObject({ + body: { userId: USER_ID, role: 'admin' }, + authorization: 'Bearer synthetic-admin-token', + }) + }) + + it('rejects when the membership call fails', async () => { + const requests = stubAdminApi(500) + + await expect(adminCreateTeamForUser('e2e team', USER_ID)).rejects.toThrow() + + // Witness: the team call succeeded and the failure came from the second call. + expect(requests.at(-1)?.path).toBe(`/api/v1/admin/teams/${TEAM_ID}/members`) + }) +}) diff --git a/desktop-app/test/e2e-playwright/chat-open-ab.spec.ts b/desktop-app/test/e2e-playwright/chat-open-ab.spec.ts index 974624aef6..669ab9e2a1 100644 --- a/desktop-app/test/e2e-playwright/chat-open-ab.spec.ts +++ b/desktop-app/test/e2e-playwright/chat-open-ab.spec.ts @@ -260,7 +260,8 @@ async function relaunchPaint( }> >((resolve, reject) => { const navigation = performance.getEntriesByType('navigation')[0] as - PerformanceNavigationTiming | undefined + | PerformanceNavigationTiming + | undefined if (!navigation) throw new Error('Window navigation timing is unavailable.') const installed = performance.now() const observed = new Map() diff --git a/desktop-app/test/e2e-playwright/figure-b-sandbox-mcphost-flow.test.ts b/desktop-app/test/e2e-playwright/figure-b-sandbox-mcphost-flow.test.ts index 61a661f1ce..878b02740c 100644 --- a/desktop-app/test/e2e-playwright/figure-b-sandbox-mcphost-flow.test.ts +++ b/desktop-app/test/e2e-playwright/figure-b-sandbox-mcphost-flow.test.ts @@ -18,6 +18,7 @@ import type { WorkflowRunArtifact, WorkflowRunListItem } from '../../src/types' import { adminCreateTeamForUser, allowTeamApprovalThroughAdminRoute, + cleanupTeam, grantTeamThroughAdminRoute, setUserWorkflowGrantsThroughAdminRoute, } from './workflow-approval-quadrants/recipes' @@ -850,35 +851,6 @@ function cleanupRecipe(name: string): void { } } -function cleanupTeam(teamId: string | undefined): void { - if (!teamId) return - if (!/^[0-9a-f-]{36}$/i.test(teamId)) { - throw new Error(`refusing to clean non-E2E team id ${teamId}`) - } - try { - const team = sqlLiteral(teamId) - runProfilesSql( - ` - DELETE FROM team_workflow_grants_audit - WHERE target_team_id = ${team}; - DELETE FROM workflow_recipe_allowed_teams_audit - WHERE target_team_id = ${team}; - DELETE FROM team_workflow_triggers - WHERE team_id = ${team}; - DELETE FROM workflow_recipe_allowed_teams - WHERE team_id = ${team}; - DELETE FROM team_members - WHERE team_id = ${team}; - DELETE FROM teams - WHERE id = ${team}; - `, - 20_000 - ) - } catch { - // Preserve the original test failure; all team rows are scoped to this E2E. - } -} - test.describe('1st-party AuthN, 3rd-party MCP-Host (recipe sandbox) workflow trigger', () => { test('installs through Control UI, triggers and approves through Desktop App, and creates the target run', async ({ page: controlPage, diff --git a/desktop-app/test/e2e-playwright/fixtures.ts b/desktop-app/test/e2e-playwright/fixtures.ts index b44723e243..6b6d48b17b 100644 --- a/desktop-app/test/e2e-playwright/fixtures.ts +++ b/desktop-app/test/e2e-playwright/fixtures.ts @@ -54,9 +54,9 @@ export const test = base.extend({ }, appPage: async ({ electronApp }, use) => { - // Seed the password without an API login: control-api charges every - // attempt, successful ones included, against a 5-per-15-minutes budget - // (control-api/src/services/auth/passwordAdmissionState.ts). + // Seed the password without an API login: control-api paces attempts per + // source and counts successful logins per identifier + // (docs/operations/password-admission.md). seedPasswordForEmail(E2E_EMAIL) const page = await electronApp.firstWindow() diff --git a/desktop-app/test/e2e-playwright/global-setup.ts b/desktop-app/test/e2e-playwright/global-setup.ts index ea08ca3187..2985333ac8 100644 --- a/desktop-app/test/e2e-playwright/global-setup.ts +++ b/desktop-app/test/e2e-playwright/global-setup.ts @@ -60,6 +60,16 @@ function isBranchScopedLocalContext(context: string): boolean { return /^clerum-(codex|detached)-/.test(context) || isBranchProfileContext(context) } +/** + * For a lane that disrupts the cluster it runs against: admits only a branch-owned local + * profile, which excludes production and the shared `clerum-test` context. + */ +export function assertBranchOwnedContext(context: string): void { + if (isProductionContext(context) || !isBranchScopedLocalContext(context)) { + throw new Error(`[E2E-GUARD] "${context}" is not a branch-owned local profile.`) + } +} + function urlPort(url: string): string { try { return new URL(url).port @@ -395,7 +405,6 @@ async function globalSetup(): Promise { let loginResp: Response const loginPath = '/api/v1/auth/password-login' - let loginBody: Record = { email } try { if (authPreflight !== 'password') { throw new Error( @@ -407,12 +416,11 @@ async function globalSetup(): Promise { process.env.E2E_TEST_PASSWORD || process.env.ADMIN_PASSWORD || 'changeme123!' - loginBody = { email, password } loginResp = await fetch(`${externalRest}${loginPath}`, { method: 'POST', headers: { 'content-type': 'application/json' }, - body: JSON.stringify(loginBody), + body: JSON.stringify({ email, password }), }) } catch (err) { throw new Error( diff --git a/desktop-app/test/e2e-playwright/playwright.config.ts b/desktop-app/test/e2e-playwright/playwright.config.ts index 565cba2608..abd761f857 100644 --- a/desktop-app/test/e2e-playwright/playwright.config.ts +++ b/desktop-app/test/e2e-playwright/playwright.config.ts @@ -170,6 +170,7 @@ export default defineConfig({ '**/subscription-image-input.spec.ts', '**/native-tool-discovery.spec.ts', '**/session-tool-approval-scope.test.ts', + '**/session-team-ux.test.ts', ], }, { diff --git a/desktop-app/test/e2e-playwright/playwright.session-team-ux.config.ts b/desktop-app/test/e2e-playwright/playwright.session-team-ux.config.ts new file mode 100644 index 0000000000..8ba6f00182 --- /dev/null +++ b/desktop-app/test/e2e-playwright/playwright.session-team-ux.config.ts @@ -0,0 +1,36 @@ +// sort-imports-ignore +// Import order matters: the base config loads the canonical env, and global-setup reads +// E2E_PROD_CONTEXT when it loads, so base comes first or the production block weakens. +import { defineConfig } from '@playwright/test' +import base from './playwright.config' +import { assertBranchOwnedContext } from './global-setup' + +// Dedicated lane: it stops control-api on a branch profile, so it never runs with the +// default suite. Validate admission before the inherited global setup contacts anything. +if (process.env.E2E_SESSION_TEAM_UX !== '1') { + throw new Error('Session team UX lane requires E2E_SESSION_TEAM_UX=1') +} +const context = process.env.E2E_K8S_CONTEXT?.trim() +if (!context) { + throw new Error('Session team UX lane requires explicit E2E_K8S_CONTEXT') +} +assertBranchOwnedContext(context) +// The spec reads only this variable, while global setup also accepts two aliases: without +// it, the guard could validate a different URL than the lane uses. +if (!process.env.EXTERNAL_REST_API_BASE_URL?.trim()) { + throw new Error('Session team UX lane requires explicit EXTERNAL_REST_API_BASE_URL') +} + +export default defineConfig({ + ...base, + timeout: 420_000, + expect: { timeout: 30_000 }, + fullyParallel: false, + forbidOnly: true, + reporter: [['list']], + ...(process.env.E2E_SESSION_TEAM_OUTPUT_DIR + ? { outputDir: process.env.E2E_SESSION_TEAM_OUTPUT_DIR } + : {}), + use: { screenshot: 'only-on-failure', trace: 'on' }, + projects: [{ name: 'session-team-ux', testMatch: '**/session-team-ux.test.ts' }], +}) diff --git a/desktop-app/test/e2e-playwright/session-team-ux.test.ts b/desktop-app/test/e2e-playwright/session-team-ux.test.ts new file mode 100644 index 0000000000..68388e83e2 --- /dev/null +++ b/desktop-app/test/e2e-playwright/session-team-ux.test.ts @@ -0,0 +1,304 @@ +// What Desktop shows when a session has no team, during an outage, and when a session ends. +// Needs a seeded branch profile. It stops control-api, so it refuses any other context: +// playwright.session-team-ux.config.ts admits only a branch-owned profile, by opt-in. +import { type Page, expect, test } from '@playwright/test' +import { execFileSync } from 'node:child_process' +import { randomBytes } from 'node:crypto' +import { assertBranchOwnedContext } from './global-setup' +import { openResourcesNavItem } from './navigationHelpers' +import { + E2E_DESKTOP_PASSWORD, + E2E_EMAIL, + EXT_API, + K8S_CONTEXT, + awaitPasswordPace, + launchAndLogin, + seedPasswordForEmail, +} from './workflowUi' + +const SECOND_EMAIL = process.env.E2E_DEV_LOGIN_EMAIL_2 ?? 'test2@clerum.io' +const NO_TEAM_NOTICE = /no active team|select a team|workspace team/i +const SECTIONS: Array<[testId: string, heading: string]> = [ + ['nav-files', 'Files'], + ['nav-agents', 'Agents'], + ['nav-workflows', 'Plugins'], + ['nav-mcp-servers', 'Connectors'], +] + +test.describe.configure({ mode: 'serial' }) + +test.beforeAll(() => { + assertBranchOwnedContext(K8S_CONTEXT) + if (E2E_EMAIL === SECOND_EMAIL) throw new Error('The two E2E users must differ') +}) + +function kubectl(args: string[], input?: string): string { + return execFileSync('kubectl', ['--context', K8S_CONTEXT, '-n', 'control-plane', ...args], { + encoding: 'utf-8', + timeout: 240_000, + input, + }).trim() +} + +function runSql(statement: string): string { + return kubectl( + [ + 'exec', + '-i', + 'deployment/control-postgres', + '--', + 'sh', + '-c', + 'psql -X -At -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "${POSTGRES_DB:-postgres}" -f -', + ], + `${statement};\n` + ) +} + +function sqlLiteral(value: string): string { + return `'${value.replace(/'/g, "''")}'` +} + +function userIdSql(email: string): string { + return `(SELECT id FROM users WHERE email=${sqlLiteral(email)})` +} + +function readMemberships(email: string): unknown[] { + return JSON.parse( + runSql( + `SELECT coalesce(json_agg(m ORDER BY m.team_id),'[]') FROM team_members m + WHERE m.user_id=${userIdSql(email)}` + ) + ) +} + +/** The user's full rows, so a restore puts back the same teams, roles and timestamps. */ +function snapshotMemberships(email: string): unknown[] { + const snapshot = readMemberships(email) + expect(snapshot.length, `witness: ${email} has seeded memberships`).toBeGreaterThan(0) + return snapshot +} + +function removeMemberships(email: string): void { + runSql(`DELETE FROM team_members WHERE user_id=${userIdSql(email)}`) + expect(readMemberships(email), `${email} has no membership left`).toEqual([]) +} + +function restoreMemberships(email: string, snapshot: unknown[]): void { + runSql( + `BEGIN; + DELETE FROM team_members WHERE user_id=${userIdSql(email)}; + INSERT INTO team_members + SELECT * FROM json_populate_recordset(NULL::team_members, ${sqlLiteral(JSON.stringify(snapshot))}); + COMMIT` + ) + expect(readMemberships(email), `memberships of ${email} are back as they were`).toEqual(snapshot) +} + +async function sessionTeamId(page: Page): Promise { + return page.evaluate(async () => { + const state = await (window as any).clerum.auth.getSessionState() + return state?.me?.teamId ?? null + }) +} + +async function workflowsList(page: Page): Promise<{ ok: boolean; message: string }> { + return page.evaluate(async () => { + try { + await (window as any).clerum.workflows.list() + return { ok: true, message: '' } + } catch (error) { + return { ok: false, message: String((error as Error).message).slice(0, 200) } + } + }) +} + +/** Walks every section and asserts that none shows a notice about the session team. */ +async function expectNoTeamNoticeInSections(page: Page): Promise { + const composer = page.getByRole('textbox', { name: 'Agent message composer' }) + await expect(composer, 'witness: the chat rendered').toBeVisible() + await expect(page.getByText(NO_TEAM_NOTICE)).toHaveCount(0) + for (const [section, heading] of SECTIONS) { + await openResourcesNavItem(page, section) + await expect( + page.getByRole('heading', { name: heading, exact: true }), + `witness: ${section} opened` + ).toBeVisible() + await expect(page.getByText(NO_TEAM_NOTICE), `${section} shows no team notice`).toHaveCount(0) + } +} + +test('a user without a team enters and sees no team notice anywhere', async () => { + const memberships = snapshotMemberships(SECOND_EMAIL) + removeMemberships(SECOND_EMAIL) + try { + const { app, page } = await launchAndLogin(SECOND_EMAIL) + try { + expect(await sessionTeamId(page), 'witness: the session really has no team').toBeNull() + expect((await workflowsList(page)).ok, 'the session works without a team').toBe(true) + await expectNoTeamNoticeInSections(page) + } finally { + await app.close() + } + } finally { + restoreMemberships(SECOND_EMAIL, memberships) + } +}) + +test('a team member enters and sees no team notice anywhere', async () => { + const { app, page } = await launchAndLogin(SECOND_EMAIL) + try { + expect(await sessionTeamId(page), 'witness: the session has its team').toBeTruthy() + await expectNoTeamNoticeInSections(page) + } finally { + await app.close() + } +}) + +async function apiLogin(): Promise { + await awaitPasswordPace() + const response = await fetch(`${EXT_API}/api/v1/auth/password-login`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ email: E2E_EMAIL, password: E2E_DESKTOP_PASSWORD }), + }) + expect(response.status, 'API login').toBe(200) + const body = (await response.json()) as { token?: unknown } + if (typeof body.token !== 'string' || !body.token) throw new Error('Login answered no token') + return body.token +} + +async function edgeCall( + method: string, + route: string, + token: string, + body?: unknown +): Promise<{ status: number; text: string }> { + const response = await fetch(`${EXT_API}/api/v1${route}`, { + method, + headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' }, + body: body === undefined ? undefined : JSON.stringify(body), + }) + return { status: response.status, text: await response.text() } +} + +/** + * Spends all of this source's attempts of the current clock minute, with an identity that + * does not exist. An early 429 only reflects the 7.5 s global pace, which ends soon, so the + * loop goes past the limit of five. It starts early in the minute so the limit still holds + * when Desktop signs in. + */ +async function spendSourceAttempts(): Promise { + const secondOfMinute = new Date().getSeconds() + if (secondOfMinute > 30) { + await new Promise(resolve => setTimeout(resolve, (61 - secondOfMinute) * 1_000)) + } + let lastStatus = 0 + for (let attempt = 0; attempt < 6; attempt += 1) { + const response = await fetch(`${EXT_API}/api/v1/auth/password-login`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + email: `no-such-user-${randomBytes(6).toString('hex')}@example.test`, + password: 'synthetic-wrong-password', + }), + }) + lastStatus = response.status + expect([401, 429], 'an unknown identity is rejected or limited').toContain(lastStatus) + } + expect(lastStatus, 'the sixth attempt is over the source limit').toBe(429) +} + +test('an outage keeps the Desktop session, and a real session end returns to sign-in', async () => { + const { app, page } = await launchAndLogin(E2E_EMAIL) + try { + const signInForm = page.locator('#email-input') + const teamId = await sessionTeamId(page) + expect(teamId, 'witness: the session has its team').toBeTruthy() + expect((await workflowsList(page)).ok, 'witness: the call works before the outage').toBe(true) + + const replicas = kubectl(['get', 'deployment/control-api', '-o', 'jsonpath={.spec.replicas}']) + expect(Number(replicas), 'witness: control-api is running').toBeGreaterThan(0) + kubectl(['scale', 'deployment/control-api', '--replicas=0']) + try { + await expect + .poll(() => kubectl(['get', 'pods', '-l', 'app=control-api', '-o', 'name']), { + timeout: 120_000, + intervals: [3_000], + }) + .toBe('') + expect((await workflowsList(page)).ok, 'witness: the call fails during the outage').toBe( + false + ) + const secondAttempt = await workflowsList(page) + expect(secondAttempt.ok, 'witness: the outage still holds').toBe(false) + await expect(signInForm, 'an outage does not sign the user out').toBeHidden() + await expect(page.getByText('Your session ended')).toHaveCount(0) + } finally { + kubectl(['scale', 'deployment/control-api', `--replicas=${replicas}`]) + kubectl(['rollout', 'status', 'deployment/control-api', '--timeout=180s']) + } + await expect + .poll(async () => (await workflowsList(page)).ok, { timeout: 60_000, intervals: [3_000] }) + .toBe(true) + await expect(signInForm, 'the session survives recovery').toBeHidden() + await expect(page.getByText(NO_TEAM_NOTICE)).toHaveCount(0) + + const ended = await apiLogin() + const liveTeam = await edgeCall('GET', '/team', ended) + expect(liveTeam.status, 'witness: the team call is live').toBe(200) + expect(JSON.parse(liveTeam.text).team, 'witness: GET /team returns the team').toBeTruthy() + + const changer = await apiLogin() + const change = await edgeCall('PUT', '/me/password', changer, { + currentPassword: E2E_DESKTOP_PASSWORD, + newPassword: `Rotated-${randomBytes(12).toString('hex')}`, + }) + expect(change.status, 'the password change commits').toBe(200) + + const routes: Array<[string, string, unknown?]> = [ + ['GET', '/team'], + ['PUT', '/team/name', { name: 'must-not-apply' }], + ['PATCH', '/team/members/no-such-user/role', { role: 'member' }], + ['DELETE', '/team/members/no-such-user'], + ['POST', '/me/switch-team', { teamId }], + ] + for (const [method, route, body] of routes) { + const result = await edgeCall(method, route, ended, body) + expect(result.status, `${method} ${route} with an ended session`).toBe(401) + } + + const switched = await page.evaluate(async id => { + try { + await (window as any).clerum.team.switch(id) + return 'ok' + } catch (error) { + return String((error as Error).message).slice(0, 200) + } + }, teamId) + expect(switched, 'an ended session cannot switch team').not.toBe('ok') + expect(switched.toLowerCase()).not.toContain('not a member') + + await workflowsList(page) + await expect(signInForm, 'Desktop returns to sign-in').toBeVisible() + expect(await sessionTeamId(page), 'no team remains in the session').toBeNull() + + // A rate-limited sign-in explains the wait and does not blame the password. + await spendSourceAttempts() + await page.locator('#email-input').fill(E2E_EMAIL) + await page.locator('#password-input').fill(E2E_DESKTOP_PASSWORD) + await page.getByRole('button', { name: /^Sign in$/ }).click() + const notice = page.getByRole('alert').filter({ hasText: 'Sign-in is paused for now.' }) + await expect(notice, 'the rate-limit notice appears').toBeVisible({ timeout: 20_000 }) + await expect(notice).toContainText('Your password was not checked.') + await expect(notice).toContainText(/try again in about \d+ (second|seconds|minutes)/) + await expect(page.getByText('Check your email and password')).toHaveCount(0) + await expect(signInForm, 'the user stays on the sign-in screen').toBeVisible() + } finally { + try { + await app.close() + } finally { + seedPasswordForEmail(E2E_EMAIL) + } + } +}) diff --git a/desktop-app/test/e2e-playwright/workflow-approval-quadrants/recipes.ts b/desktop-app/test/e2e-playwright/workflow-approval-quadrants/recipes.ts index f33c576907..085e2ae621 100644 --- a/desktop-app/test/e2e-playwright/workflow-approval-quadrants/recipes.ts +++ b/desktop-app/test/e2e-playwright/workflow-approval-quadrants/recipes.ts @@ -90,7 +90,7 @@ export async function adminCreateTeamForUser(name: string, userId: string): Prom const response = await adminFetch(`${CONTROL_API}/api/v1/admin/teams`, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` }, - body: JSON.stringify({ name, userId }), + body: JSON.stringify({ name }), }) const result = await readResponse(response) expect(result.status, result.body).toBe(200) @@ -98,9 +98,48 @@ export async function adminCreateTeamForUser(name: string, userId: string): Prom const body = parseResponseEnvelope(result.body) const id = String(body.id || '').trim() expect(id, result.body).toMatch(/^[0-9a-f-]{36}$/i) + + // POST /admin/teams creates the team only; membership is a separate call. + const memberResponse = await adminFetch(`${CONTROL_API}/api/v1/admin/teams/${id}/members`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` }, + body: JSON.stringify({ userId, role: 'admin' }), + }) + const memberResult = await readResponse(memberResponse) + expect(memberResult.status, memberResult.body).toBe(200) return id } +/** + * Removes a team made by adminCreateTeamForUser. The membership goes in its own call: psql + * runs one -c string as one transaction, so a failure deleting the team would otherwise + * roll it back and leave a permanent membership on the shared E2E user. + */ +export function cleanupTeam(teamId: string | undefined): void { + if (!teamId) return + if (!/^[0-9a-f-]{36}$/i.test(teamId)) { + throw new Error(`refusing to clean non-E2E team id ${teamId}`) + } + try { + const team = sqlLiteral(teamId) + profilesSql(`DELETE FROM team_members WHERE team_id = ${team};`) + profilesSql(` + DELETE FROM team_workflow_grants_audit + WHERE target_team_id = ${team}; + DELETE FROM workflow_recipe_allowed_teams_audit + WHERE target_team_id = ${team}; + DELETE FROM team_workflow_triggers + WHERE team_id = ${team}; + DELETE FROM workflow_recipe_allowed_teams + WHERE team_id = ${team}; + DELETE FROM teams + WHERE id = ${team}; + `) + } catch { + // Preserve the original test failure; all team rows are scoped to this E2E. + } +} + export async function grantTeamThroughAdminRoute( namespace: string, name: string, @@ -230,7 +269,7 @@ ${inputContractYaml} ${allowedActorsYaml} steps: - id: approval-contract - instruction: "${instruction.replace(/"/g, '\\"')}" + instruction: ${JSON.stringify(instruction)} timeoutSeconds: 60 ` kubectlOut(['apply', '-f', '-'], yaml, 30_000) diff --git a/desktop-app/test/e2e-playwright/workflow-trigger-approval-bound.test.ts b/desktop-app/test/e2e-playwright/workflow-trigger-approval-bound.test.ts index a87323e541..d0e34d1c2a 100644 --- a/desktop-app/test/e2e-playwright/workflow-trigger-approval-bound.test.ts +++ b/desktop-app/test/e2e-playwright/workflow-trigger-approval-bound.test.ts @@ -17,6 +17,7 @@ import { allowTeamApprovalThroughAdminRoute, applyRecipe, cleanupRecipe, + cleanupTeam, grantTeamThroughAdminRoute, withRuntimeRecipe, } from './workflow-approval-quadrants/recipes' @@ -233,38 +234,45 @@ test('Desktop team context can trigger only through the current team grant', asy }) const teamId = await adminCreateTeamForUser(teamName, userId) - await grantTeamThroughAdminRoute(WORKFLOW_RECIPE_NS, recipeName, teamId) - - const { app, page } = await launchAndLogin(E2E_EMAIL) try { - let runId = '' - await test.step('Desktop triggers the team-granted workflow without a visible team switch', async () => { - await openWorkflowsPageThroughDesktopUi(page) - const detailCard = await selectWorkflow(page, recipeName, RECIPE_NS) - - const triggerButton = detailCard.getByRole('button', { name: /^Trigger$/ }) - await expect(triggerButton).toBeVisible({ timeout: 15_000 }) - await triggerButton.click() + await grantTeamThroughAdminRoute(WORKFLOW_RECIPE_NS, recipeName, teamId) - await expect(page.getByRole('status').filter({ hasText: 'Workflow triggered.' })).toBeVisible( - { + const { app, page } = await launchAndLogin(E2E_EMAIL) + try { + let runId = '' + await test.step('Desktop triggers the team-granted workflow without a visible team switch', async () => { + await openWorkflowsPageThroughDesktopUi(page) + const detailCard = await selectWorkflow(page, recipeName, RECIPE_NS) + + const triggerButton = detailCard.getByRole('button', { name: /^Trigger$/ }) + await expect(triggerButton).toBeVisible({ timeout: 15_000 }) + await triggerButton.click() + + await expect( + page.getByRole('status').filter({ hasText: 'Workflow triggered.' }) + ).toBeVisible({ timeout: 10_000, - } - ) + }) - const newRun = await waitForLatestWorkflowRun(recipeName) - expect(newRun.actorType).toBe('user') - expect(newRun.actorId).toBe(userId) - expect(newRun.teamId).toBe(teamId) - runId = newRun.runId - }) + const newRun = await waitForLatestWorkflowRun(recipeName) + expect(newRun.actorType).toBe('user') + expect(newRun.actorId).toBe(userId) + expect(newRun.teamId).toBe(teamId) + runId = newRun.runId + }) - expect(workflowRunTeamSignal(runId)).toBe(`user:${teamId}`) + expect(workflowRunTeamSignal(runId)).toBe(`user:${teamId}`) - expect(workflowRunCountForRecipe(recipeName)).toBe(1) + expect(workflowRunCountForRecipe(recipeName)).toBe(1) + } finally { + await app.close() + } } finally { - await app.close() - await cleanupRecipe(recipeName) + try { + await cleanupRecipe(recipeName) + } finally { + cleanupTeam(teamId) + } } }) @@ -288,65 +296,72 @@ test('Desktop team approval preserves the target-team grant boundary for mcp-hos }) const teamId = await adminCreateTeamForUser(teamName, userId) - await grantTeamThroughAdminRoute(WORKFLOW_RECIPE_NS, recipeName, teamId) - await allowTeamApprovalThroughAdminRoute(WORKFLOW_RECIPE_NS, recipeName, teamId) - await allowTeamApprovalThroughAdminRoute(WORKFLOW_RECIPE_NS, recipeName, originalTeamId) - - const tokens = await issueRuntimeTokens(request, 'wrc', WORKFLOW_RECIPE_NS, recipeName) - const approvalId = await createTeamApproval( - request, - tokens, - WORKFLOW_RECIPE_NS, - recipeName, - teamId - ) - - expect(approvalStatus(approvalId)).toBe('pending') - expect(workflowRunCountForApproval(approvalId)).toBe(0) - expect(typedIntentSignal(approvalId)).toBe( - `${WORKFLOW_RECIPE_NS}/${recipeName}:${WORKFLOW_RECIPE_NS}/${recipeName}` - ) - - const { app, page } = await launchAndLogin(E2E_EMAIL) try { - await test.step('Desktop approves the Team B target approval without a visible team switch', () => - approvePendingApprovalThroughDesktopUi(page, recipeName, teamName)) + await grantTeamThroughAdminRoute(WORKFLOW_RECIPE_NS, recipeName, teamId) + await allowTeamApprovalThroughAdminRoute(WORKFLOW_RECIPE_NS, recipeName, teamId) + await allowTeamApprovalThroughAdminRoute(WORKFLOW_RECIPE_NS, recipeName, originalTeamId) + + const tokens = await issueRuntimeTokens(request, 'wrc', WORKFLOW_RECIPE_NS, recipeName) + const approvalId = await createTeamApproval( + request, + tokens, + WORKFLOW_RECIPE_NS, + recipeName, + teamId + ) - expect(approvalStatus(approvalId)).toBe('approved') + expect(approvalStatus(approvalId)).toBe('pending') expect(workflowRunCountForApproval(approvalId)).toBe(0) + expect(typedIntentSignal(approvalId)).toBe( + `${WORKFLOW_RECIPE_NS}/${recipeName}:${WORKFLOW_RECIPE_NS}/${recipeName}` + ) - await test.step('mcp-host consumes the Desktop-approved Team B approval', () => - triggerWorkflow(request, tokens, WORKFLOW_RECIPE_NS, recipeName, approvalId)) - - expect(approvalStatus(approvalId)).toBe('consumed') - expect(workflowRunCountForApproval(approvalId)).toBe(1) - expect(workflowRunTeamSignalForApproval(approvalId)).toBe(`autonomous:${teamId}`) - - await test.step('Control API rejects Team A approval when only Team B has trigger grant', async () => { - await expectCreateTeamApprovalRejected( - request, - tokens, - WORKFLOW_RECIPE_NS, - recipeName, - originalTeamId, - 403, - 'Target not authorized to trigger this recipe' - ) - expect(workflowRunCountForRecipe(recipeName)).toBe(1) - expect( - Number( - profilesSql(` + const { app, page } = await launchAndLogin(E2E_EMAIL) + try { + await test.step('Desktop approves the Team B target approval without a visible team switch', () => + approvePendingApprovalThroughDesktopUi(page, recipeName, teamName)) + + expect(approvalStatus(approvalId)).toBe('approved') + expect(workflowRunCountForApproval(approvalId)).toBe(0) + + await test.step('mcp-host consumes the Desktop-approved Team B approval', () => + triggerWorkflow(request, tokens, WORKFLOW_RECIPE_NS, recipeName, approvalId)) + + expect(approvalStatus(approvalId)).toBe('consumed') + expect(workflowRunCountForApproval(approvalId)).toBe(1) + expect(workflowRunTeamSignalForApproval(approvalId)).toBe(`autonomous:${teamId}`) + + await test.step('Control API rejects Team A approval when only Team B has trigger grant', async () => { + await expectCreateTeamApprovalRejected( + request, + tokens, + WORKFLOW_RECIPE_NS, + recipeName, + originalTeamId, + 403, + 'Target not authorized to trigger this recipe' + ) + expect(workflowRunCountForRecipe(recipeName)).toBe(1) + expect( + Number( + profilesSql(` SELECT COUNT(*) FROM workflow_approval_requests WHERE recipe_namespace = ${sqlLiteral(WORKFLOW_RECIPE_NS)} AND recipe_name = ${sqlLiteral(recipeName)} AND target_team_id = ${sqlLiteral(originalTeamId)}; `) - ) - ).toBe(0) - }) + ) + ).toBe(0) + }) + } finally { + await app.close() + } } finally { - await app.close() - await cleanupRecipe(recipeName) + try { + await cleanupRecipe(recipeName) + } finally { + cleanupTeam(teamId) + } } }) diff --git a/desktop-app/test/e2e-playwright/workflowUi.ts b/desktop-app/test/e2e-playwright/workflowUi.ts index 8c7ee821eb..057ee7b8d8 100644 --- a/desktop-app/test/e2e-playwright/workflowUi.ts +++ b/desktop-app/test/e2e-playwright/workflowUi.ts @@ -9,7 +9,6 @@ import { expect, } from '@playwright/test' import { execFileSync } from 'node:child_process' -import { createHash } from 'node:crypto' import fs from 'node:fs' import http from 'node:http' import os from 'node:os' @@ -56,7 +55,7 @@ const WORKFLOW_TRIGGER_SEED_SCRIPT = path.join( 'scripts/minikube/seed-workflow-triggers-test-data.sh' ) let workflowFixturesSeeded = false -const passwordSeededFor = new Set() +const passwordSeededFor = new Map() const API_REQUEST_RETRY_DELAYS_MS = [250, 500] const HUMAN_E2E_RECORDED = process.env.HUMAN_E2E_RECORDED === '1' @@ -340,9 +339,8 @@ export function seedDesktopPasswordLogin( email: string, password = E2E_DESKTOP_PASSWORD ): void { - const passwordFingerprint = createHash('sha256').update(password).digest('hex') - const cacheKey = `${userId}:${email}:${passwordFingerprint}` - if (passwordSeededFor.has(cacheKey)) return + const cacheKey = `${userId}:${email}` + if (passwordSeededFor.get(cacheKey) === password) return if (!password || password.length < 8) { throw new Error('E2E desktop password must be at least 8 characters') } @@ -388,7 +386,7 @@ export function seedDesktopPasswordLogin( if (!result.includes(userId)) { throw new Error(`no password row updated for ${email}`) } - passwordSeededFor.add(cacheKey) + passwordSeededFor.set(cacheKey, password) } catch (error) { const message = error instanceof Error ? error.message : String(error) throw new Error(`failed to seed Desktop password login for ${email}: ${message}`) @@ -396,12 +394,14 @@ export function seedDesktopPasswordLogin( } // control-api admits one password verification per PASSWORD_ADMISSION_POLICY.paceMs -// (7.5 s) for the whole server, and charges the identity's 5-per-15-minutes budget -// before the pace check, so a paced 429 still spends an attempt. Space every password -// attempt from this worker (API and Desktop sign-in) by the pace. The worker loads after -// global setup's preflight login, so module load is a safe bound for that attempt. -const PASSWORD_PACE_MS = 8_000 -// One paced retry at most; a longer Retry-After means the identity budget is spent. +// (7.5 s) for the whole server and five attempts per source in each clock minute +// (sourceAttemptsPerMinute), and the source limit counts correct passwords too. Space every +// password attempt from this worker (API and Desktop sign-in) so a clock minute holds at +// most five. The worker loads after global setup's preflight login, so module load is a +// safe bound for that attempt. +const PASSWORD_PACE_MS = 13_000 +// One paced retry at most; a longer Retry-After means another client spent the source's +// minute, or the identity is in its failure cooldown. const MAX_PACE_RETRY_SECONDS = 15 let lastPasswordAttemptAt = Date.now() @@ -433,7 +433,7 @@ async function pacedPasswordLogin( } if (!Number.isFinite(retryAfterSeconds) || retryAfterSeconds > MAX_PACE_RETRY_SECONDS) { throw new Error( - `password-login for ${email} is rate limited beyond the pace (identity budget spent?): HTTP 429 ${first.body}` + `password-login for ${email} is rate limited beyond the pace (source limit or failure cooldown?): HTTP 429 ${first.body}` ) } lastPasswordAttemptAt = Date.now() + retryAfterSeconds * 1000 - PASSWORD_PACE_MS @@ -545,6 +545,9 @@ export async function launchAndLogin( try { app = await electron.launch({ args: [`--user-data-dir=${isolatedPaths.userDataDir}`, path.join(compiledAppPath, 'main.js')], + ...(process.env.E2E_DESKTOP_VIDEO_DIR + ? { recordVideo: { dir: process.env.E2E_DESKTOP_VIDEO_DIR } } + : {}), env: { ...process.env, EVENFIRE_DEV_ISOLATION: '1', @@ -867,7 +870,6 @@ export async function waitForNewRun( timeoutMs = 45_000 ): Promise { let freshRun: WorkflowRunsResult['items'][number] | null = null - let lastTransientError = '' await expect .poll( @@ -877,7 +879,7 @@ export async function waitForNewRun( const fresh = (runs.items || []).find(item => !previousIds.includes(item.id)) if (fresh) { freshRun = fresh - return fresh.id + return 'found' } } catch (error) { const message = @@ -885,19 +887,17 @@ export async function waitForNewRun( if (!message.includes('socket hang up') && !message.includes('fetch failed')) { throw error } - lastTransientError = message + return `transient error: ${message}` } - return null + return 'no new run yet' }, { timeout: timeoutMs, intervals: [500, 1_000, 2_000], - message: `timed out waiting for a new run for ${ns}/${name}${ - lastTransientError ? `; last transient error=${lastTransientError}` : '' - }`, + message: `timed out waiting for a new run for ${ns}/${name}`, } ) - .not.toBeNull() + .toBe('found') if (!freshRun) { throw new Error(`timed out waiting for a new run for ${ns}/${name}`) diff --git a/desktop-app/test/gfsUriHandler.test.ts b/desktop-app/test/gfsUriHandler.test.ts index 0591f0233e..8012819d05 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 @@ -297,7 +302,12 @@ describe('GfsClient.grant', () => { throw new ApiError( '400 Bad Request: subjects_invalid', 400, - JSON.stringify({ error: 'subjects_invalid', invalidIndexes: [0, 2] }) + // control-api's sendGfsGrantError shape, relayed unchanged by the edge. + JSON.stringify({ + error: 'subjects_invalid', + message: 'subjects_invalid', + invalidIndexes: [0, 2], + }) ) }) as GfsTransport['requestJson'] await expect( @@ -316,6 +326,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/desktop-app/test/globalSetupGuard.test.ts b/desktop-app/test/globalSetupGuard.test.ts index 899345e9d5..ffa95e0507 100644 --- a/desktop-app/test/globalSetupGuard.test.ts +++ b/desktop-app/test/globalSetupGuard.test.ts @@ -1,7 +1,11 @@ -import { describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { spawnSync } from 'node:child_process' +import { readFileSync } from 'node:fs' +import path from 'node:path' import { ALLOWED_CONTEXTS, assertAllowedKubeContext, + assertBranchOwnedContext, assertHostedDevServices, isLocalPortForwardContext, isProductionContext, @@ -182,3 +186,95 @@ describe('assertHostedDevServices', () => { ).toThrow(/has no readable endpoint for control-plane\/control-api: connection refused/) }) }) + +describe('assertBranchOwnedContext', () => { + it.each(['clerum-test', 'clerum-prod-abc12345', 'clerum-dev', DEV_CONTEXT])( + 'refuses %s', + context => { + expect(() => assertBranchOwnedContext(context)).toThrow(/not a branch-owned local profile/) + } + ) + + it.each([ + 'clerum-feat-plugin-workload-sdk-3de2a246', + 'clerum-codex-figure-b-sandbox-mcphost-mitigation-5bd8cfa5', + ])('accepts %s', context => { + expect(() => assertBranchOwnedContext(context)).not.toThrow() + }) +}) + +describe('session team UX lane admission', () => { + const CONFIG = './e2e-playwright/playwright.session-team-ux.config' + const BRANCH_CONTEXT = 'clerum-feat-plugin-workload-sdk-3de2a246' + const VALID_ENV = { + E2E_SESSION_TEAM_UX: '1', + E2E_K8S_CONTEXT: BRANCH_CONTEXT, + EXTERNAL_REST_API_BASE_URL: 'http://127.0.0.1:38091', + } + const originalEnv = { ...process.env } + + afterEach(() => { + process.env = { ...originalEnv } + vi.resetModules() + }) + + async function loadConfig(env: Record) { + vi.resetModules() + // An empty value counts as present, so the developer's canonical .env cannot fill it. + Object.assign(process.env, env) + const [{ default: config }, { default: base }] = await Promise.all([ + import(CONFIG), + import('./e2e-playwright/playwright.config'), + ]) + return { config, base } + } + + it.each([ + ['the opt-in', { E2E_SESSION_TEAM_UX: '' }, /requires E2E_SESSION_TEAM_UX=1/], + ['the context', { E2E_K8S_CONTEXT: '' }, /requires explicit E2E_K8S_CONTEXT/], + [ + 'the base URL', + { EXTERNAL_REST_API_BASE_URL: '' }, + /requires explicit EXTERNAL_REST_API_BASE_URL/, + ], + ['a branch context', { E2E_K8S_CONTEXT: 'clerum-test' }, /not a branch-owned local profile/], + ])('rejects a config without %s', async (_label, override, message) => { + await expect(loadConfig({ ...VALID_ENV, ...override })).rejects.toThrow(message) + }) + + it('inherits the base global setup and runs only this lane under a valid env', async () => { + const { config, base } = await loadConfig(VALID_ENV) + + expect(base.globalSetup).toBeTruthy() + expect(config.globalSetup).toBe(base.globalSetup) + expect(config.projects).toEqual([ + { name: 'session-team-ux', testMatch: '**/session-team-ux.test.ts' }, + ]) + }) + + it('imports the base config before global-setup, which reads the canonical env at load', () => { + const source = readFileSync(path.resolve(__dirname, `${CONFIG}.ts`), 'utf8') + const baseImport = source.indexOf("from './playwright.config'") + expect(baseImport).toBeGreaterThan(-1) + expect(baseImport).toBeLessThan(source.indexOf("from './global-setup'")) + }) + + it('lists exactly the lane tests through the Playwright CLI', () => { + const desktopApp = path.resolve(__dirname, '..') + const list = (env: Record) => + spawnSync( + path.join(desktopApp, 'node_modules/.bin/playwright'), + ['test', '--list', '--config', 'test/e2e-playwright/playwright.session-team-ux.config.ts'], + { cwd: desktopApp, encoding: 'utf8', env: { ...originalEnv, ...env }, timeout: 120_000 } + ) + + const listed = list(VALID_ENV) + expect(listed.status, listed.stderr).toBe(0) + const tests = listed.stdout.split('\n').filter(line => line.includes('.test.ts:')) + expect(tests.length).toBeGreaterThan(0) + for (const line of tests) expect(line).toContain('session-team-ux.test.ts') + expect(listed.stdout).toMatch(/Total: \d+ tests? in 1 file/) + + expect(list({ ...VALID_ENV, E2E_SESSION_TEAM_UX: '' }).status).not.toBe(0) + }, 240_000) +}) diff --git a/desktop-app/test/signInFailure.test.ts b/desktop-app/test/signInFailure.test.ts new file mode 100644 index 0000000000..34ea288ee0 --- /dev/null +++ b/desktop-app/test/signInFailure.test.ts @@ -0,0 +1,99 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { classifySignInFailure } from '@lib/signInFailure' +import { AuthClient } from '../src/authClient.js' + +function respondWith(status: number, body: string, headers: Record = {}) { + vi.stubGlobal( + 'fetch', + vi.fn(async () => new Response(body, { status, statusText: 'Server Answer', headers })) + ) +} + +/** What the renderer receives: the real rejection, wrapped by Electron's IPC. */ +async function rendererMessage(): Promise { + const error = await new AuthClient().passwordLogin('user@example.com', 'secret').catch(e => e) + expect(error).toBeInstanceOf(Error) + return `Error invoking remote method 'auth:passwordLogin': Error: ${(error as Error).message}` +} + +describe('classifySignInFailure on real AuthClient rejections', () => { + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('reads a 401 as invalid credentials', async () => { + respondWith(401, JSON.stringify({ error: 'invalid_credentials' })) + expect(classifySignInFailure(await rendererMessage())).toEqual({ kind: 'invalid_credentials' }) + }) + + it('reads a 429 with its wait', async () => { + respondWith(429, JSON.stringify({ error: 'rate_limited' }), { 'retry-after': '900' }) + expect(classifySignInFailure(await rendererMessage())).toEqual({ + kind: 'rate_limited', + retryAfterSeconds: 900, + }) + }) + + it('reads a 429 without a wait', async () => { + respondWith(429, JSON.stringify({ error: 'rate_limited' })) + expect(classifySignInFailure(await rendererMessage())).toEqual({ + kind: 'rate_limited', + retryAfterSeconds: null, + }) + }) + + it('reads a 503 as unavailable', async () => { + respondWith(503, JSON.stringify({ error: 'service_unavailable' })) + expect(classifySignInFailure(await rendererMessage())).toEqual({ kind: 'unavailable' }) + }) + + it('reads a fetch failure as unavailable', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => { + throw new TypeError('fetch failed') + }) + ) + expect(classifySignInFailure(await rendererMessage())).toEqual({ kind: 'unavailable' }) + }) + + it('reads a 200 answer that is not JSON as unavailable', async () => { + respondWith(200, 'proxy page httpStatus=401') + expect(classifySignInFailure(await rendererMessage())).toEqual({ kind: 'unavailable' }) + }) + + it.each([ + [400, 'bad request'], + [404, 'not found'], + [410, 'invitation_expired'], + [426, 'upgrade required'], + ])('leaves a %i answer unclassified', async (status, body) => { + respondWith(status, body) + expect(classifySignInFailure(await rendererMessage())).toEqual({ kind: 'other' }) + }) + + it.each([ + [401, JSON.stringify({ detail: 'httpStatus=429' }), 'invalid_credentials'], + [500, JSON.stringify({ detail: 'httpStatus=429 retryAfterSeconds=900' }), 'unavailable'], + [400, 'signInTransport=noResponse', 'other'], + [404, 'httpStatus=401', 'other'], + ])('a %i body cannot imitate the marker', async (status, body, kind) => { + respondWith(status, body, { 'retry-after': '17' }) + expect(classifySignInFailure(await rendererMessage())).toEqual({ kind }) + }) +}) + +describe('classifySignInFailure on messages that never crossed the credential call', () => { + it.each([ + [ + 'a status in the prose', + "Error invoking remote method 'auth:passwordLogin': Error: 401 Unauthorized: x", + ], + ['a token mid-message', 'Error: 500 upstream httpStatus=429 then failed'], + ['the transport token mid-message', 'Error: signInTransport=noResponse then failed'], + ['digits only', "Error invoking remote method 'auth:passwordLogin': 4010 retries exceeded"], + ['an empty message', ''], + ])('leaves %s unclassified', (_label, message) => { + expect(classifySignInFailure(message)).toEqual({ kind: 'other' }) + }) +}) diff --git a/desktop-app/ui/src/App.tsx b/desktop-app/ui/src/App.tsx index 03eeca64c9..20903058f8 100644 --- a/desktop-app/ui/src/App.tsx +++ b/desktop-app/ui/src/App.tsx @@ -3447,6 +3447,7 @@ export function App() { previousPrincipalIdentityRef.current = vm.authenticatedPrincipalIdentity if (previousIdentity === undefined) return setWorkspaceTabs(createWorkspaceTabsState('chat-tab-1')) + setPluginGfsPreview(null) vm.clearAppsPicker() setComposerFocusRequestId(0) setGlobalSearchFocusRequestId(0) @@ -4455,12 +4456,6 @@ export function App() { /> - {vm.isAuthenticated && !vm.currentTeamId ? ( - - ) : null} {sessionInvalidation && sessionInvalidationReadFailed ? (
{ }) }) + // A mounted real controller defers its menu data to a post-paint task + // (refreshAuthenticatedData → scheduleAfterFirstPaintForSession). Unmounting does + // not retire it: it later resolves `window.clerum` and reaches the next test's + // bridge. End the session the way main does, so the controller's own identity + // guard drops every post-paint task this test scheduled. + function retireRealControllerSession(): void { + if (!realController) return + const listeners = vi + .mocked(window.clerum.auth.onExternalLogout) + .mock.calls.map(([listener]) => listener) + if (listeners.length === 0) { + throw new Error('a real App controller mounted without an external-logout listener') + } + act(() => { + for (const listener of listeners) listener() + }) + } + async function cleanupAppDeepLinkSuite(): Promise { - cleanup() + try { + retireRealControllerSession() + } finally { + // Unmount even when retirement throws, so its error is the one reported. + cleanup() + } unmountRealApp = null const bodyChildElementCount = document.body.childElementCount vi.useRealTimers() @@ -6613,7 +6636,10 @@ describe('App deep-link orchestration', () => { await waitFor(() => expect(getController().isAuthenticated).toBe(true)) await waitFor(() => expect(getController().initialExperienceLoading).toBe(false)) await waitFor(() => expect(handle.refreshCatalog.mock.calls.length).toBeGreaterThanOrEqual(2)) - await waitFor(() => expect(handle.teamDirectory).toHaveBeenCalledOnce()) + // The post-paint bootstrap has published this App's directory; by then it + // has read the bridge exactly once. + await waitFor(() => expect(getController().teamDirectoryHydrated).toBe(true)) + expect(handle.teamDirectory).toHaveBeenCalledOnce() act(() => { getController().handleSelectChatAgent('agent-x', { diff --git a/desktop-app/ui/src/__tests__/App.pluginPreviewSessionEnd.test.tsx b/desktop-app/ui/src/__tests__/App.pluginPreviewSessionEnd.test.tsx new file mode 100644 index 0000000000..dfc3401adf --- /dev/null +++ b/desktop-app/ui/src/__tests__/App.pluginPreviewSessionEnd.test.tsx @@ -0,0 +1,250 @@ +// @vitest-environment jsdom +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { AgentTaskTrackerProvider } from '@contexts/AgentTaskTrackerContext' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { act, cleanup, render, screen, waitFor } from '@testing-library/react' +import { installAppControllerClerum } from '@hooks/domain/__tests__/__fixtures__/appControllerHarness' +import { uninstallMockClerum } from '@hooks/domain/__tests__/__fixtures__/mockClerum' +import { App } from '@/App' + +const USER_A = { id: 'user-a', email: 'a@clerum.io', name: 'A', teamId: null, teamName: null } +const USER_B = { id: 'user-b', email: 'b@clerum.io', name: 'B', teamId: null, teamName: null } +const A_FILE = { uri: 'gfs://main/user-a-private.png', name: 'user-a-private.png' } +const B_FILE = { uri: 'gfs://main/user-b-own.png', name: 'user-b-own.png' } +const DIALOG = '.da-gfs-image-preview-dialog' + +const frames: FrameRequestCallback[] = [] + +function installBridge() { + installAppControllerClerum({ + agentNames: ['agent-x'], + desktopReleaseStatus: { + checked: true, + currentVersion: '1.0.0', + latestVersion: '1.0.0', + minimumVersion: '1.0.0', + updateRequired: false, + releaseUrl: 'https://example.invalid/release', + }, + }) + const bridge = window.clerum as unknown as Record + bridge.chat.getIndex = vi.fn().mockResolvedValue({ + version: 1, + lastActiveChatId: null, + onboardingDismissed: false, + chats: [ + { + id: 'empty-chat-1', + title: 'Empty chat', + createdAt: '2026-11-05T00:00:00Z', + updatedAt: '2026-11-05T00:00:00Z', + messageCount: 0, + }, + ], + }) + bridge.rpc.getHostModels = vi.fn().mockResolvedValue({ models: [] }) + bridge.rpc.loadSessionMessages = vi.fn(() => new Promise(() => undefined)) + bridge.app = { rendererReady: vi.fn().mockResolvedValue(undefined) } + bridge.shortcuts = { onCommand: vi.fn(() => vi.fn()) } + bridge.sandboxUi = { + listApps: vi.fn().mockResolvedValue({ apps: [] }), + listPendingDeepLinks: vi.fn().mockResolvedValue({ links: [] }), + clearPendingDeepLinks: vi.fn().mockResolvedValue(undefined), + onDeepLink: vi.fn(() => vi.fn()), + onSessionInvalidated: vi.fn(() => vi.fn()), + onTitleChanged: vi.fn(() => vi.fn()), + onClosed: vi.fn(() => vi.fn()), + onRefreshError: vi.fn(() => vi.fn()), + onFindResult: vi.fn(() => vi.fn()), + open: vi.fn().mockResolvedValue(undefined), + close: vi.fn().mockResolvedValue(undefined), + getLocation: vi.fn().mockResolvedValue(null), + setVisible: vi.fn().mockResolvedValue(undefined), + setBounds: vi.fn().mockResolvedValue(undefined), + capturePreview: vi.fn().mockResolvedValue(null), + stopFindInPage: vi.fn().mockResolvedValue(undefined), + focusActive: vi.fn().mockResolvedValue(true), + reload: vi.fn().mockResolvedValue(undefined), + } + let openResource: ((resource: unknown) => void) | null = null + bridge.pluginSdk = { + onOpenGfsResource: vi.fn((listener: (resource: unknown) => void) => { + openResource = listener + return vi.fn() + }), + } + const downloadPreview = vi.fn(async (_uri: string) => { + throw new Error('preview bytes are not needed by this test') + }) + bridge.gfs = bridge.gfs ?? {} + bridge.gfs.downloadPreview = downloadPreview + let session: unknown = { authenticated: true, me: USER_A, sessionGeneration: 1 } + bridge.auth.getSessionState = vi.fn(async () => session) + let sessionCleared: (() => void) | null = null + bridge.auth.onSessionCleared = vi.fn((listener: () => void) => { + sessionCleared = listener + return vi.fn() + }) + return { + downloadPreview, + openFile: async (file: { uri: string; name: string }) => { + if (!openResource) throw new Error('the App did not subscribe to plugin GFS resources') + const open = openResource + await act(async () => { + open({ kind: 'file', gfsUri: file.uri, name: file.name, bytes: 10, version: 1 }) + await Promise.resolve() + }) + }, + // Main ended or replaced the session, so the renderer reloads the session state. + changeSession: async (next: unknown) => { + if (!sessionCleared) throw new Error('the App did not subscribe to the session notice') + const notify = sessionCleared + session = next + await act(async () => { + notify() + await Promise.resolve() + }) + }, + } +} + +async function flushFrames() { + for (let pass = 0; pass < 8 && frames.length; pass += 1) { + await act(async () => { + frames.splice(0).forEach(callback => callback(performance.now())) + await Promise.resolve() + }) + } +} + +async function waitForAuthenticatedShell() { + for (let pass = 0; pass < 8; pass += 1) { + if (screen.queryByTestId('nav-files')) break + await flushFrames() + await act(async () => { + await Promise.resolve() + }) + } + expect(await screen.findByTestId('nav-files')).toBeTruthy() +} + +async function settle() { + await act(async () => { + await new Promise(resolve => window.setTimeout(resolve, 20)) + }) +} + +function renderApp() { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + return render( + + + + + + ) +} + +describe('App plugin GFS image preview across a session change', () => { + beforeEach(() => { + frames.length = 0 + window.localStorage.clear() + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { + frames.push(callback) + return frames.length + }) + vi.stubGlobal( + 'ResizeObserver', + class { + observe = vi.fn() + unobserve = vi.fn() + disconnect = vi.fn() + } + ) + vi.stubGlobal( + 'matchMedia', + vi.fn( + () => + ({ + matches: false, + media: '(max-width: 759px)', + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + }) as MediaQueryList + ) + ) + Object.defineProperty(Element.prototype, 'scrollIntoView', { + configurable: true, + value: vi.fn(), + }) + }) + + afterEach(async () => { + cleanup() + await new Promise(resolve => window.setTimeout(resolve, 0)) + vi.restoreAllMocks() + vi.unstubAllGlobals() + uninstallMockClerum() + }) + + async function openPreviewForUserA() { + const bridge = installBridge() + renderApp() + await waitForAuthenticatedShell() + await bridge.openFile(A_FILE) + await waitFor(() => expect(document.querySelector(DIALOG), 'opened for A').toBeTruthy()) + expect(document.querySelector(DIALOG)!.textContent).toContain(A_FILE.name) + return bridge + } + + it('does not carry the preview across a session end into another user', async () => { + const bridge = await openPreviewForUserA() + + await bridge.changeSession({ authenticated: false, me: null, sessionGeneration: 2 }) + await waitFor(() => expect(screen.queryByTestId('nav-files')).toBeNull()) + expect(document.querySelector(DIALOG), 'signed out').toBeNull() + + bridge.downloadPreview.mockClear() + await bridge.changeSession({ authenticated: true, me: USER_B, sessionGeneration: 3 }) + await waitForAuthenticatedShell() + await settle() + + expect(document.querySelector(DIALOG)).toBeNull() + expect(bridge.downloadPreview.mock.calls.filter(([uri]) => uri === A_FILE.uri)).toEqual([]) + }) + + it('closes the preview on a direct change to another user', async () => { + const bridge = await openPreviewForUserA() + + bridge.downloadPreview.mockClear() + await bridge.changeSession({ authenticated: true, me: USER_B, sessionGeneration: 2 }) + await waitFor(() => expect(document.querySelector(DIALOG)).toBeNull()) + await waitForAuthenticatedShell() + await settle() + + expect(document.querySelector(DIALOG)).toBeNull() + expect(bridge.downloadPreview.mock.calls.filter(([uri]) => uri === A_FILE.uri)).toEqual([]) + }) + + it('still shows the next user their own file', async () => { + const bridge = await openPreviewForUserA() + + await bridge.changeSession({ authenticated: false, me: null, sessionGeneration: 2 }) + await waitFor(() => expect(screen.queryByTestId('nav-files')).toBeNull()) + await bridge.changeSession({ authenticated: true, me: USER_B, sessionGeneration: 3 }) + await waitForAuthenticatedShell() + await settle() + + await bridge.openFile(B_FILE) + + await waitFor(() => expect(document.querySelector(DIALOG)).toBeTruthy()) + expect(document.querySelector(DIALOG)!.textContent).toContain(B_FILE.name) + await waitFor(() => + expect(bridge.downloadPreview.mock.calls.map(([uri]) => uri)).toContain(B_FILE.uri) + ) + }) +}) diff --git a/desktop-app/ui/src/__tests__/RecoveredAppDeepLinks.test.tsx b/desktop-app/ui/src/__tests__/RecoveredAppDeepLinks.test.tsx index 41c05270cd..456845aec5 100644 --- a/desktop-app/ui/src/__tests__/RecoveredAppDeepLinks.test.tsx +++ b/desktop-app/ui/src/__tests__/RecoveredAppDeepLinks.test.tsx @@ -26,7 +26,7 @@ import { import { renderToStaticMarkup } from 'react-dom/server' import { buildSandboxUiDeepLink, parseSandboxUiDeepLink } from '@clerum/desktop-app-links' import { buildGfsResourceDeepLink, parseGfsResourceDeepLink } from '@clerum/desktop-app-links' -import { DESKTOP_ROUTES, SIDEBAR_COLLAPSED_KEY } from '@constants/navigation' +import { DESKTOP_ROUTES } from '@constants/navigation' import type { PushNotificationInput } from '@hooks/domain/types' import { type ActiveChatVisibility, useChatNotifications } from '@hooks/domain/useChatNotifications' import { useAppController } from '@hooks/useAppController' @@ -1035,7 +1035,8 @@ describe('App deep-link orchestration', () => { ) const getSandboxUiLocation = vi.fn().mockResolvedValue(null) - it('shows an explicit no-team state without signing out the principal', () => { + // An empty session team is not a user-facing condition: V2 sessions never carry a team. + it('keeps a principal without a session team signed in, with no team banner', () => { currentController = makeController({ initialExperienceLoading: false, isAuthenticated: true, @@ -1053,9 +1054,11 @@ describe('App deep-link orchestration', () => { render() - expect(screen.getByRole('status').textContent).toContain( - 'You’re signed in, but this session has no active team.' - ) + // Witness: the authenticated shell rendered, so the absence below means something. + expect(document.querySelector('.login-shell, #email-input')).toBeNull() + expect(document.body.textContent?.length).toBeGreaterThan(0) + expect(screen.queryByRole('status')).toBeNull() + expect(screen.queryByText(/no active team/i)).toBeNull() }) beforeEach(() => { 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/__tests__/useWorkspaceController.test.tsx b/desktop-app/ui/src/hooks/__tests__/useWorkspaceController.test.tsx index ba524a5204..9ba823f8d1 100644 --- a/desktop-app/ui/src/hooks/__tests__/useWorkspaceController.test.tsx +++ b/desktop-app/ui/src/hooks/__tests__/useWorkspaceController.test.tsx @@ -212,9 +212,11 @@ describe('useWorkspaceController', () => { it('shows a specific notification when password login is unauthorized', async () => { const clerum = installClerumHarness({ passwordLoginError: new Error( - "Error invoking remote method 'auth:passwordLogin': ApiError: 401 Unauthorized: Unauthorized" + "Error invoking remote method 'auth:passwordLogin': Error: 401 Unauthorized: Unauthorized httpStatus=401" ), }) + const diagnoseLoginBackend = vi.fn(async () => null) + Object.assign(window.clerum.auth, { diagnoseLoginBackend }) renderControllerHarness() @@ -230,6 +232,166 @@ describe('useWorkspaceController', () => { expect(screen.getByRole('alert').getAttribute('data-duration-ms')).toBe( String(DEFAULT_TOAST_DURATION_MS) ) + await waitFor(() => expect(screen.getByTestId('busy').textContent).toBe('false')) + expect(diagnoseLoginBackend).not.toHaveBeenCalled() + }) + + it.each([ + ['1', 'Please try again in about a second.'], + ['17', 'Please try again in about 17 seconds.'], + // The minutes form starts above 90 s and rounds up, so it never under-promises. + ['61', 'Please try again in about 61 seconds.'], + ['90', 'Please try again in about 90 seconds.'], + ['91', 'Please try again in about 2 minutes.'], + ['121', 'Please try again in about 3 minutes.'], + // 404 and 410 must not read as other failures, which name those digits. + ['404', 'Please try again in about 7 minutes.'], + ['410', 'Please try again in about 7 minutes.'], + ['900', 'Please try again in about 15 minutes.'], + ])( + 'explains a rate-limited login without blaming the password (wait %s s)', + async (seconds, retryText) => { + const clerum = installClerumHarness({ + passwordLoginError: new Error( + `Error invoking remote method 'auth:passwordLogin': Error: 429 Too Many Requests: rate_limited httpStatus=429 retryAfterSeconds=${seconds}` + ), + }) + const diagnoseLoginBackend = vi.fn(async () => null) + Object.assign(window.clerum.auth, { diagnoseLoginBackend }) + + renderControllerHarness() + + await waitFor(() => expect(screen.getByTestId('booting').textContent).toBe('false')) + + fireEvent.click(screen.getByRole('button', { name: 'Set credentials' })) + fireEvent.click(screen.getByRole('button', { name: 'Login' })) + + await waitFor(() => + expect(clerum.passwordLogin).toHaveBeenCalledWith('test@clerum.io', 'test') + ) + await waitFor(() => expect(screen.getByRole('alert').textContent).toContain(retryText)) + const alert = screen.getByRole('alert').textContent + expect(alert).toContain('Sign-in is paused for now. Your password was not checked.') + expect(alert).not.toContain('Check your email and password') + expect(screen.getByRole('alert').getAttribute('data-duration-ms')).toBeNull() + expect(diagnoseLoginBackend).not.toHaveBeenCalled() + } + ) + + it('asks to try later when a rate-limited login has no wait', async () => { + const clerum = installClerumHarness({ + passwordLoginError: new Error( + "Error invoking remote method 'auth:passwordLogin': Error: 429 Too Many Requests: rate_limited httpStatus=429" + ), + }) + + renderControllerHarness() + + await waitFor(() => expect(screen.getByTestId('booting').textContent).toBe('false')) + + fireEvent.click(screen.getByRole('button', { name: 'Set credentials' })) + fireEvent.click(screen.getByRole('button', { name: 'Login' })) + + await waitFor(() => expect(clerum.passwordLogin).toHaveBeenCalledWith('test@clerum.io', 'test')) + await waitFor(() => + expect(screen.getByRole('alert').textContent).toContain( + 'Your password was not checked. Please try again in a little while.' + ) + ) + }) + + it('stays busy while the login is pending and is usable again after a rate limit', async () => { + const clerum = installClerumHarness() + let rejectLogin: (error: Error) => void = () => undefined + clerum.passwordLogin.mockImplementationOnce( + () => + new Promise((_resolve, reject) => { + rejectLogin = reject + }) + ) + + renderControllerHarness() + + await waitFor(() => expect(screen.getByTestId('booting').textContent).toBe('false')) + + fireEvent.click(screen.getByRole('button', { name: 'Set credentials' })) + fireEvent.click(screen.getByRole('button', { name: 'Login' })) + + await waitFor(() => expect(clerum.passwordLogin).toHaveBeenCalledWith('test@clerum.io', 'test')) + await waitFor(() => expect(screen.getByTestId('busy').textContent).toBe('true')) + + rejectLogin( + new Error( + "Error invoking remote method 'auth:passwordLogin': Error: 429 Too Many Requests: rate_limited httpStatus=429 retryAfterSeconds=17" + ) + ) + + await waitFor(() => + expect(screen.getByRole('alert').textContent).toContain('Sign-in is paused for now.') + ) + await waitFor(() => expect(screen.getByTestId('busy').textContent).toBe('false')) + }) + + it.each([ + [ + '503 issuance', + 'Error: 503 Service Unavailable: session issuance failed httpStatus=503', + "Sign-in isn't available right now. Please try again in a moment.", + ], + [ + 'an imitating 500 body', + 'Error: 500 Internal Server Error: {"detail":"httpStatus=429"} httpStatus=500', + "Sign-in isn't available right now. Please try again in a moment.", + ], + [ + 'no HTTP answer', + 'Error: fetch failed signInTransport=noResponse', + "Sign-in isn't available right now. Please try again in a moment.", + ], + [ + '426', + 'Error: 426 Upgrade Required: desktop_update_required httpStatus=426', + 'Login failed. Check your email and password.', + ], + [ + 'a 400 whose body names 410', + 'Error: 400 Bad Request: {"error":"code 410 invitation_expired"} httpStatus=400', + 'Login failed. Check your email and password.', + ], + [ + 'a 404 from a wrong server', + 'Error: 404 Not Found: no route httpStatus=404', + 'Login failed. Check your email and password.', + ], + [ + 'an unmarked 401 in prose', + 'Error: 401 Unauthorized: x', + 'Login failed. Check your email and password.', + ], + ])('shows its own text and probes the backend for %s', async (_label, message, text) => { + const clerum = installClerumHarness({ + passwordLoginError: new Error( + `Error invoking remote method 'auth:passwordLogin': ${message}` + ), + }) + const diagnoseLoginBackend = vi.fn(async () => null) + Object.assign(window.clerum.auth, { diagnoseLoginBackend }) + + renderControllerHarness() + + await waitFor(() => expect(screen.getByTestId('booting').textContent).toBe('false')) + + fireEvent.click(screen.getByRole('button', { name: 'Set credentials' })) + fireEvent.click(screen.getByRole('button', { name: 'Login' })) + + await waitFor(() => expect(clerum.passwordLogin).toHaveBeenCalledWith('test@clerum.io', 'test')) + await waitFor(() => expect(screen.getByRole('alert').textContent).toContain(text)) + const alert = screen.getByRole('alert').textContent + expect(alert).not.toContain('Email or password is incorrect.') + expect(alert).not.toContain('Invitation expired.') + expect(alert).not.toContain('You must be invited') + expect(screen.getByRole('alert').getAttribute('data-duration-ms')).toBeNull() + await waitFor(() => expect(diagnoseLoginBackend).toHaveBeenCalledTimes(1)) }) it('does not treat unrelated messages containing 401 as unauthorized login failures', async () => { 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/__tests__/useAuthController.signInFailure.test.tsx b/desktop-app/ui/src/hooks/domain/__tests__/useAuthController.signInFailure.test.tsx new file mode 100644 index 0000000000..7bb8d62280 --- /dev/null +++ b/desktop-app/ui/src/hooks/domain/__tests__/useAuthController.signInFailure.test.tsx @@ -0,0 +1,149 @@ +// @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' + +const INCORRECT = 'Email or password is incorrect.' +const GENERIC = 'Login failed. Check your email and password.' + +const auth = { + getRuntimeConfigState: vi.fn(), + getSessionGeneration: vi.fn(), + onDesktopSetupToken: vi.fn(), + onDesktopEnvironmentSetup: vi.fn(), + onExternalLogout: vi.fn(), + onSessionCleared: vi.fn(), + passwordLogin: vi.fn(), + startDesktopSetup: vi.fn(), + diagnoseLoginBackend: vi.fn(), +} + +beforeEach(() => { + auth.getRuntimeConfigState.mockResolvedValue({ options: [], selectedOptionId: null }) + auth.getSessionGeneration.mockResolvedValue(1) + auth.onDesktopSetupToken.mockReturnValue(() => undefined) + auth.onDesktopEnvironmentSetup.mockReturnValue(() => undefined) + auth.onExternalLogout.mockReturnValue(() => undefined) + auth.onSessionCleared.mockReturnValue(() => undefined) + auth.passwordLogin.mockReset() + auth.startDesktopSetup.mockReset() + auth.diagnoseLoginBackend.mockReset() + auth.diagnoseLoginBackend.mockResolvedValue(null) + Object.defineProperty(window, 'clerum', { configurable: true, value: { auth } }) +}) + +afterEach(() => { + cleanup() +}) + +async 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), + }) + ) + // The boot effect loads the session once on mount. + await waitFor(() => expect(onSessionNeedsLoad).toHaveBeenCalledTimes(1)) + setStatus.mockClear() + return { hook, setStatus, onSessionNeedsLoad } +} + +function errorTexts(setStatus: ReturnType): string[] { + return setStatus.mock.calls.filter(call => call[1] === 'error').map(call => String(call[0])) +} + +describe('useAuthController password login failures', () => { + it('does not read an unmarked 401 as a wrong password, and probes the backend', async () => { + const { hook, setStatus } = await mountAuthController() + auth.passwordLogin.mockRejectedValueOnce( + new Error("Error invoking remote method 'auth:passwordLogin': Error: 401 Unauthorized: x") + ) + act(() => { + hook.result.current.setEmail('user@example.com') + hook.result.current.setPassword('secret') + }) + + await act(async () => { + await hook.result.current.handlePasswordLogin() + }) + + expect(auth.passwordLogin).toHaveBeenCalledTimes(1) + expect(errorTexts(setStatus)).toEqual([GENERIC]) + expect(auth.diagnoseLoginBackend).toHaveBeenCalledTimes(1) + }) + + it('does not read a session-load failure after a good password as a wrong password', async () => { + const { hook, setStatus, onSessionNeedsLoad } = await mountAuthController() + auth.passwordLogin.mockResolvedValueOnce({ authenticated: true, me: null }) + onSessionNeedsLoad.mockRejectedValueOnce(new Error('401 Unauthorized: session load failed')) + act(() => { + hook.result.current.setEmail('user@example.com') + hook.result.current.setPassword('secret') + }) + + await act(async () => { + await hook.result.current.handlePasswordLogin() + }) + + expect(onSessionNeedsLoad).toHaveBeenCalledTimes(2) + expect(setStatus).toHaveBeenCalledWith('Signed in.', 'success') + expect(errorTexts(setStatus)).toEqual([GENERIC]) + expect(errorTexts(setStatus)).not.toContain(INCORRECT) + }) + + it('shows the generic text for the local required-fields failure', async () => { + const { hook, setStatus } = await mountAuthController() + + await act(async () => { + await hook.result.current.handlePasswordLogin() + }) + + expect(auth.passwordLogin).not.toHaveBeenCalled() + expect(errorTexts(setStatus)).toEqual([GENERIC]) + }) +}) + +describe('useAuthController desktop setup failures', () => { + async function startSetupRejectedWith(message: string) { + const { hook, setStatus } = await mountAuthController() + auth.startDesktopSetup.mockRejectedValueOnce(new Error(message)) + act(() => { + hook.result.current.setEmail('user@example.com') + }) + + await act(async () => { + await hook.result.current.handleStartDesktopSetup() + }) + + expect(auth.startDesktopSetup).toHaveBeenCalledWith('user@example.com') + return errorTexts(setStatus) + } + + it('recognizes the code main raises for a missing invitation', async () => { + await expect( + startSetupRejectedWith( + "Error invoking remote method 'auth:startDesktopSetup': Error: invitation_config_not_found" + ) + ).resolves.toEqual(['You must be invited to be part of a team.']) + }) + + it('recognizes the bare code', async () => { + await expect(startSetupRejectedWith('invitation_config_not_found')).resolves.toEqual([ + 'You must be invited to be part of a team.', + ]) + }) + + it.each([ + "Error invoking remote method 'auth:startDesktopSetup': Error: 500 Internal Server Error: upstream 404", + "Error invoking remote method 'auth:startDesktopSetup': Error: not invitation_config_not_found yet", + ])('does not read %s as a missing invitation', async message => { + await expect(startSetupRejectedWith(message)).resolves.toEqual([ + 'Desktop setup could not be started.', + ]) + }) +}) diff --git a/desktop-app/ui/src/hooks/domain/useAuthController.ts b/desktop-app/ui/src/hooks/domain/useAuthController.ts index 1b8ad5f4a9..cec587fce0 100644 --- a/desktop-app/ui/src/hooks/domain/useAuthController.ts +++ b/desktop-app/ui/src/hooks/domain/useAuthController.ts @@ -1,6 +1,7 @@ import { useCallback, useEffect, useRef, useState } from 'react' import { DEFAULT_TOAST_DURATION_MS } from '@constants/toasts' import { desktopQueryClient } from '@lib/queryClient' +import { classifySignInFailure } from '@lib/signInFailure' import type { DependencyHealth, DesktopReleaseStatus, @@ -25,24 +26,27 @@ interface UseAuthControllerParams { logoutForEnvironmentMismatch: () => Promise } -function isInvitationExpiredError(error: unknown) { - const message = error instanceof Error ? error.message.toLowerCase() : String(error) - return message.includes('410') || message.includes('invitation_expired') -} - +// The code main raises for desktop setup, whole, after the IPC wrapper's `Error: `. function isMissingInvitationConfigError(error: unknown) { - const message = error instanceof Error ? error.message.toLowerCase() : String(error) - return message.includes('404') || message.includes('invitation_config_not_found') + const message = error instanceof Error ? error.message : String(error) + return /(^|Error: )invitation_config_not_found$/.test(message) } -function isUnauthorizedError(error: unknown) { - if (error && typeof error === 'object' && 'status' in error) { - const status = Number((error as { status?: unknown }).status) - if (status === 401) return true - } +function describeLoginWait(seconds: number): string { + if (seconds <= 1) return 'in about a second' + if (seconds <= 90) return `in about ${seconds} seconds` + const minutes = Math.ceil(seconds / 60) + return `in about ${minutes} minutes` +} - const message = error instanceof Error ? error.message.toLowerCase() : String(error) - return /\b401\s+unauthorized\b/.test(message) || /:\s*401\s/.test(message) +/** + * The server did not verify the password on this path, so the text must not suggest that + * the password is wrong. It holds for every admission reason behind a 429. + */ +function rateLimitedLoginMessage(retryAfterSeconds: number | null): string { + const wait = + retryAfterSeconds === null ? 'in a little while' : describeLoginWait(retryAfterSeconds) + return `Sign-in is paused for now. Your password was not checked. Please try again ${wait}.` } function getDesktopSetupErrorMessage(error: unknown) { @@ -227,6 +231,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) @@ -238,37 +258,37 @@ export function useAuthController({ setStatus('Signed in.', 'success') await onSessionNeedsLoad({ preserveNav: true }) } catch (error) { - const invitationExpired = isInvitationExpiredError(error) - const missingInvitationConfig = isMissingInvitationConfigError(error) - const unauthorized = isUnauthorizedError(error) + const failure = classifySignInFailure(error instanceof Error ? error.message : String(error)) + // A 401 or a 429 means the backend answered, so no switch to another backend is + // offered. + if (failure.kind === 'invalid_credentials') { + setStatus('Email or password is incorrect.', 'error', undefined, { + toastDurationMs: DEFAULT_TOAST_DURATION_MS, + }) + return + } + if (failure.kind === 'rate_limited') { + setStatus(rateLimitedLoginMessage(failure.retryAfterSeconds), 'error') + return + } setStatus( - invitationExpired - ? 'Invitation expired.' - : missingInvitationConfig - ? 'You must be invited to be part of a team.' - : unauthorized - ? 'Email or password is incorrect.' - : 'Login failed. Check your email and password.', - 'error', - undefined, - unauthorized ? { toastDurationMs: DEFAULT_TOAST_DURATION_MS } : undefined + failure.kind === 'unavailable' + ? "Sign-in isn't available right now. Please try again in a moment." + : 'Login failed. Check your email and password.', + 'error' ) - // A generic (non-401, non-invitation) failure is the signature of a login - // aimed at a backend that isn't answering — most often the app is on a - // saved profile while a local minikube cluster is what's actually up. - // Only then do we probe for a reachable localhost and, if found, offer a - // one-click switch. A clean 401 means the backend answered, so it's a - // password problem, not a wrong-server problem — no switch offered. - if (!invitationExpired && !missingInvitationConfig && !unauthorized) { - try { - const hint = - typeof window.clerum.auth.diagnoseLoginBackend === 'function' - ? await window.clerum.auth.diagnoseLoginBackend() - : null - setBackendSwitchHint(hint) - } catch { - setBackendSwitchHint(null) - } + // Any other failure can be the signature of a login aimed at a backend that + // isn't answering — most often the app is on a saved profile while a local + // minikube cluster is what's actually up. Only then do we probe for a reachable + // localhost and, if found, offer a one-click switch. + try { + const hint = + typeof window.clerum.auth.diagnoseLoginBackend === 'function' + ? await window.clerum.auth.diagnoseLoginBackend() + : null + setBackendSwitchHint(hint) + } catch { + setBackendSwitchHint(null) } } finally { setAuthTransitioning(false) diff --git a/desktop-app/ui/src/lib/signInFailure.ts b/desktop-app/ui/src/lib/signInFailure.ts new file mode 100644 index 0000000000..6d0f3e7645 --- /dev/null +++ b/desktop-app/ui/src/lib/signInFailure.ts @@ -0,0 +1,29 @@ +import { parseHttpStatus } from '@lib/gfsGrantErrors' + +export type SignInFailure = + | { kind: 'invalid_credentials' } + | { kind: 'rate_limited'; retryAfterSeconds: number | null } + | { kind: 'unavailable' } + | { kind: 'other' } + +const NO_RESPONSE_MARKER = /(?:^|\s)signInTransport=noResponse$/ +const RATE_LIMIT_WAIT_MARKER = /(?:^|\s)httpStatus=429 retryAfterSeconds=(\d+)$/ + +/** + * Classifies a sign-in failure from the marker AuthClient.passwordLogin writes at the + * end of the message, because only the message crosses IPC. Every failure of the + * credential call ends in that marker, so text from a response body can never be in + * the anchored position. A message without it never crossed the credential call and + * says nothing about the password. + */ +export function classifySignInFailure(message: string): SignInFailure { + if (NO_RESPONSE_MARKER.test(message)) return { kind: 'unavailable' } + const status = parseHttpStatus(message) + if (status === 401) return { kind: 'invalid_credentials' } + if (status === 429) { + const wait = RATE_LIMIT_WAIT_MARKER.exec(message)?.[1] + return { kind: 'rate_limited', retryAfterSeconds: wait ? Number.parseInt(wait, 10) : null } + } + if (status !== null && status >= 500) return { kind: 'unavailable' } + return { kind: 'other' } +} diff --git a/desktop-app/ui/src/pages/__tests__/FilePreviewPage.test.tsx b/desktop-app/ui/src/pages/__tests__/FilePreviewPage.test.tsx index 4ba7344071..5ef1667f2b 100644 --- a/desktop-app/ui/src/pages/__tests__/FilePreviewPage.test.tsx +++ b/desktop-app/ui/src/pages/__tests__/FilePreviewPage.test.tsx @@ -614,6 +614,7 @@ describe('FilePreviewPage', () => { const nativeDigest = globalThis.crypto.subtle.digest.bind(globalThis.crypto.subtle) let digestCompleted!: () => void let releaseDigest!: (value: ArrayBuffer) => void + let digestDelivered = false const digestFinished = new Promise(resolve => { digestCompleted = resolve }) @@ -625,7 +626,10 @@ describe('FilePreviewPage', () => { .mockImplementation(async (algorithm, data) => { const actualDigest = await nativeDigest(algorithm, data) digestCompleted() - return digestResult.then(() => actualDigest) + return digestResult.then(() => { + digestDelivered = true + return actualDigest + }) }) const renameResource = vi.fn(async () => undefined) const affordances = vi.fn(async () => activeAffordances) @@ -641,12 +645,25 @@ describe('FilePreviewPage', () => { version: 7, bytes: 1, }) + // Generation 2 re-resolves its resource before it may read its own + // authority. Holding that read keeps the generation-2 page from reading + // permissions, so a permission read while it is held can only come from the + // generation-1 rename. + let holdReplacementResolve = false + let releaseReplacementResolve!: () => void + const replacementResolveHeld = new Promise(resolve => { + releaseReplacementResolve = resolve + }) + const resolve = vi.fn(async () => { + if (holdReplacementResolve) await replacementResolveHeld + return producerResource() + }) Object.defineProperty(window, 'clerum', { configurable: true, value: { gfs: { downloadPreview: vi.fn(async () => ({ bytes: new Uint8Array([1]).buffer })), - resolve: vi.fn(producerResource), + resolve, affordances, renameResource, }, @@ -672,34 +689,45 @@ describe('FilePreviewPage', () => { }) fireEvent.submit(dialog) await digestFinished + const resolveReadsBeforeReplacement = resolve.mock.calls.length + const permissionReadsBeforeReplacement = affordances.mock.calls.length + holdReplacementResolve = true controllerState.sessionGeneration = 2 view.rerender() + await waitFor(() => expect(resolve).toHaveBeenCalledTimes(resolveReadsBeforeReplacement + 1)) + await act(async () => releaseDigest(new ArrayBuffer(0))) + // Microtasks drain before a macrotask, so every continuation of the + // released digest has run once this resolves. + await act(() => new Promise(resolveTask => setTimeout(resolveTask, 0))) + + expect(digestDelivered).toBe(true) + expect(renameResource).not.toHaveBeenCalled() + expect(affordances).toHaveBeenCalledTimes(permissionReadsBeforeReplacement) + + // Liveness: once generation 2 owns its resource, the page reads its own + // resource and source-folder authority through the counted producer. + await act(async () => releaseReplacementResolve()) + await waitFor(() => + expect(affordances).toHaveBeenCalledTimes(permissionReadsBeforeReplacement + 2) + ) + // The call count does not say which key the authority was cached under: + // generation 2's reads must land under generation 2's keys. const queryClient = queryClients.at(-1) if (!queryClient) throw new Error('Preview query client is unavailable') - // The replacement generation owns fresh file and source-parent reads. - // Settle both while the old digest is blocked so only that old operation - // can cause additional permission reads after the baseline below. await waitFor(() => { for (const resourceId of ['0123456789abcdef0123456789abcdef', 'preview-parent']) { - const state = queryClient.getQueryState( - desktopQueryKeys.gfsAffordances(controllerState.sessionScope, resourceId, 'main', 2) - ) - expect(state?.status).toBe('success') - expect(state?.fetchStatus).toBe('idle') + expect( + queryClient.getQueryState( + desktopQueryKeys.gfsAffordances(controllerState.sessionScope, resourceId, 'main', 2) + )?.status + ).toBe('success') } }) - const permissionReadsBeforeDigestRelease = affordances.mock.calls.length - await act(async () => releaseDigest(new ArrayBuffer(0))) - await act(async () => { - await Promise.resolve() - await Promise.resolve() - }) - expect(renameResource).not.toHaveBeenCalled() - expect(affordances.mock.calls.length).toBe(permissionReadsBeforeDigestRelease) } finally { releaseDigest(new ArrayBuffer(0)) + releaseReplacementResolve() digestSpy.mockRestore() view.unmount() } @@ -755,8 +783,11 @@ describe('FilePreviewPage', () => { const dialog = await screen.findByRole('dialog', { name: 'Delete diagram.png?' }) const cancel = within(dialog).getByRole('button', { name: 'Cancel' }) as HTMLButtonElement const confirm = within(dialog).getByRole('button', { name: 'Delete' }) as HTMLButtonElement - const cancelFocus = vi.spyOn(cancel, 'focus') + // The dialog opens after an awaited permission check, outside act(), so its + // mount-time focus effect may still be pending here. Observe re-focus only + // after that initial focus has landed. await waitFor(() => expect(document.activeElement).toBe(cancel)) + const cancelFocus = vi.spyOn(cancel, 'focus') fireEvent.click(confirm) @@ -1098,7 +1129,6 @@ describe('FilePreviewPage', () => { refreshResolvers.push(resolve) }) ) - let resolveCount = 0 Object.defineProperty(window, 'clerum', { configurable: true, value: { 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 () => [ diff --git a/docs/deploy/llm-providers.md b/docs/deploy/llm-providers.md index fd627e9f27..9f13e3a2be 100644 --- a/docs/deploy/llm-providers.md +++ b/docs/deploy/llm-providers.md @@ -201,9 +201,9 @@ failed (for example the database was briefly unreachable), the replica keeps fetching models.dev with exponential backoff, from 30 seconds up to one hour between attempts, and runs the sync again only once it gets a live, plausible catalog. If another replica was already syncing at boot, there is no retry. -control-api refuses to start when `MODELS_DEV_API_URL` is not `https://` or -points at an internal host, so a misconfigured URL fails the rollout instead of -being retried. +control-api refuses to start when `MODELS_DEV_API_URL` is not `https://`, points +at an internal host or contains credentials, so a misconfigured URL fails the +rollout instead of being retried. **Offline installations.** Without egress to models.dev every run is `vendored` (the bundled snapshot). Such a run cannot decide, so it inserts diff --git a/docs/deploy/minikube.md b/docs/deploy/minikube.md index 40276e2548..e9fbf5939f 100644 --- a/docs/deploy/minikube.md +++ b/docs/deploy/minikube.md @@ -564,6 +564,15 @@ scripts/minikube/image-mode.sh --image-source # ghcr | local scripts/minikube/image-mode.sh --image-tag # the ghcr tag (empty in local mode) ``` +**Minikube redeploys do not roll out External REST before control-api**, which +a production upgrade with External REST user sessions requires (see the [user +access foundation upgrade note](user-access-foundation-upgrade.md)). Do not +drive clients (Desktop App, Profile UI) during a redeploy. Moving a profile back +to an older branch, or running +`make minikube-deploy-service SVC=control-api NS=control-plane` on a profile +whose External REST is older, pairs an old External REST with a new control-api +until External REST is redeployed. + ### The one-command path (both modes) ```bash 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. diff --git a/docs/deploy/releases-and-upgrades.md b/docs/deploy/releases-and-upgrades.md index 572b7f64d5..0c7937c25b 100644 --- a/docs/deploy/releases-and-upgrades.md +++ b/docs/deploy/releases-and-upgrades.md @@ -223,6 +223,10 @@ Read the notes for every release you skip over, not only the target release. mark these fields. See also the [CRD reference](../crds/README.md). 3. **Then roll out the images** for the new release, all from the same tag. + Before you do, check the feature-specific notes below for an order that + replaces this step. The [user access foundation + upgrade](user-access-foundation-upgrade.md) is one: it runs the migration + Job, then External REST and Profile UI, and only then control-api. 4. **Follow any feature-specific runbook** below. ### Feature-specific notes @@ -235,6 +239,7 @@ Read the notes for every release you skip over, not only the target release. | [Host PVC layout upgrade](host-pvc-layout-upgrade.md) | You upgrade from v0.10.x or v0.11.x and have created stateless Hosts. Run the inventory before the upgrade. | | [Snippet runner Node permissions](#snippet-runner-node-permissions) | You run workflow snippets with your own runner image (`CLERUM_SNIPPET_RUNNER_IMAGE`). It must run Node 24.x; Node 25+ blocks its network. | | [rpc-proxy default changes](#rpc-proxy-default-changes) | You run rpc-proxy from a ConfigMap without `RPC_PROXY_ARTIFACT_DOWNLOAD_TIMEOUT_MS`, or you set a status or activity stream cap. | +| [User access foundation upgrade](user-access-foundation-upgrade.md) | You upgrade to the release with External REST user sessions. It has a required order: edge before control-api. | | [LLM catalog seed retirement](llm-catalog-seed-retirement-upgrade.md) | You upgrade across migration `0135_llm_retire_seeded_catalog`, use `cost` budgets, or install fresh. Review the kept placeholder prices. | #### Snippet runner Node permissions diff --git a/docs/deploy/sql/user-access-foundation-earlier-head-cleanup.sql b/docs/deploy/sql/user-access-foundation-earlier-head-cleanup.sql new file mode 100644 index 0000000000..d82af622f6 --- /dev/null +++ b/docs/deploy/sql/user-access-foundation-earlier-head-cleanup.sql @@ -0,0 +1,98 @@ +-- Removes what an earlier head of the user access foundation change left in a +-- control-api database: its trigger functions (and, through CASCADE, their +-- triggers and expression indexes), its tables, its indexes on existing tables +-- and the migration receipts it recorded. The current head's migration runner +-- refuses to start on such a database until this has run. +-- +-- Run it once, as the owner of the control-api schema, with that schema first +-- in search_path, while no control-api, migration Job or trace-maintenance +-- process is running. It keeps the current 0137_user_access_foundation and +-- 0138_legacy_password_security_epoch_backfill receipts: the session-table DDL +-- is identical in every head, and 0138 is idempotent if it runs again. +-- +-- See docs/deploy/user-access-foundation-upgrade.md#databases-that-ran-an-earlier-head-of-this-change + +BEGIN; + +DO $$ +DECLARE + earlier_function regprocedure; +BEGIN + FOR earlier_function IN + SELECT fn.oid::regprocedure + FROM pg_proc fn + JOIN pg_namespace fn_ns ON fn_ns.oid = fn.pronamespace + WHERE fn_ns.nspname = current_schema() + AND (fn.proname LIKE 'authorization\_bump\_%' ESCAPE '\' + OR fn.proname LIKE 'authorization\_prelock\_%' ESCAPE '\' + OR fn.proname = 'catalog_utf8_bytes') + LOOP + EXECUTE format('DROP FUNCTION %s CASCADE', earlier_function); + END LOOP; +END +$$; + +DROP TABLE IF EXISTS + authorization_catalog_environment, + authorization_catalog_revision, + authorization_catalog_writer_components, + authorization_resource_revisions, + authorization_team_revisions, + authorization_user_revisions, + invitation_delivery_commands, + operational_catalog_source_state, + operational_relationships_staging, + operational_resource_index, + operational_resource_index_staging, + operational_resource_relationships + CASCADE; + +DROP INDEX IF EXISTS + authorization_resource_revisions_updated_idx, + gfs_grants_subject_resource_catalog_idx, + gfs_shares_subject_resource_catalog_idx, + invitation_delivery_commands_authorized_idx, + invitation_delivery_commands_invitation_idx, + notification_team_catalog_idx, + notification_user_catalog_idx, + operational_relationship_catalog_target_idx, + operational_relationship_catalog_utf8_target_idx, + operational_relationship_generation_idx, + operational_relationship_source_idx, + operational_relationship_staging_identity_idx, + operational_relationship_target_idx, + operational_resource_source_idx, + operational_resource_staging_identity_idx, + team_agents_catalog_utf8_idx, + team_contexts_catalog_utf8_idx, + team_workflow_triggers_catalog_key_idx, + team_workflow_triggers_catalog_utf8_idx, + team_workflow_triggers_recipe_team_idx, + user_agents_catalog_utf8_idx, + user_contexts_catalog_utf8_idx, + user_workflow_triggers_catalog_key_idx, + user_workflow_triggers_catalog_utf8_idx, + user_workflow_triggers_recipe_user_idx, + workflow_approval_team_catalog_idx, + workflow_approval_user_catalog_idx, + workflow_runs_actor_catalog_idx, + workflow_runs_team_catalog_idx, + workflow_runs_usage_team_catalog_idx; + +DELETE FROM schema_migrations + WHERE substring(version from '^[0-9a-f]{4}_(.+)$') = ANY (ARRAY[ + 'user_access_foundation', + 'invitation_delivery_commands', + 'catalog_utf8_ordering', + 'composable_catalog_revisions', + 'gfs_catalog_revision_components', + 'legacy_password_security_epoch_backfill', + 'user_access_foundation_definer_temp_shadow_hardening', + 'authorization_revision_delete_compatibility' + ]) + AND version <> ALL (ARRAY[ + '0137_user_access_foundation', + '0138_legacy_password_security_epoch_backfill' + ]); + +COMMIT; diff --git a/docs/deploy/user-access-foundation-upgrade.md b/docs/deploy/user-access-foundation-upgrade.md new file mode 100644 index 0000000000..abe9ffc23a --- /dev/null +++ b/docs/deploy/user-access-foundation-upgrade.md @@ -0,0 +1,292 @@ +# User Access Foundation Upgrade Note + +This release adds server-side External REST user sessions to control-api: a +session record per sign-in, a password security epoch per user, and +revocations for V1 session tokens. Two control-api migrations create and fill +them. External REST and Profile UI change with it, so the rollout order below +matters. + +## Rollout order + +The order is owned by `keyper-labs/evenfire-infra#143`. For this release it +replaces step 3 of the [general order](releases-and-upgrades.md#general-order) +(all images at once): + +1. Run the control-api migration gate (the migration Job) and wait for it to + finish. +2. Roll out External REST and Profile UI. Wait for `kubectl rollout status` on + both, and check that no pod of an older ReplicaSet is left. +3. Roll out control-api and every other workload. + +Rollback goes the other way: control-api first, then External REST and Profile +UI. + +`keyper-labs/evenfire-infra#154` implements this order in the infra deploy +helper (it applies External REST and Profile UI first and waits for them before +control-api). Until it is merged on the infra repository's `main` and a dev +deploy log shows its `EDGE_PHASE apply` and `EDGE_PHASE settled` lines, run the +three steps by hand. With a single apply, old External REST pods meet the new +control-api, and team create, team switch and password reset fail (see +[The mixed window](#the-mixed-window)). + +Before step 1: + +- Check the database for state left by an earlier head of this change (see + [Databases that ran an earlier head of this change](#databases-that-ran-an-earlier-head-of-this-change)). +- Check that `EXTERNAL_REST_API_CORS_ORIGIN` of `external-rest-api` lists the + public Profile UI origin (scheme and host, no path): + + ```sh + kubectl --context -n profiles get deploy external-rest-api \ + -o jsonpath='{.spec.template.spec.containers[?(@.name=="external-rest-api")].env[?(@.name=="EXTERNAL_REST_API_CORS_ORIGIN")].value}' + ``` + + Profile UI reaches External REST through a same-origin rewrite, and External + REST now refuses a browser mutation without a bearer token unless its + `Origin` is in that list. If the Profile UI origin is missing, Profile UI + login, logout, password reset, invitation accept and set-password answer + `403 forbidden` "The browser request origin is not allowed." After step 2, a + Profile UI password login must not answer that 403. + + Checked read-only on 2026-11-05: on clerum-dev and on production (`clerum`) + the list holds the environment's app origin and its Profile UI origin; in + both, cloudflared routes the Profile UI host to `profile-ui.profiles:3001`. + The value lives in the infra repository, so check it again before each + deploy. + +- Ship the new Desktop build only after step 3. + +For a database below `0130`, from the commit of `0130` in step 1 until the new +control-api is Ready in step 3, the old control-api cannot start new password +checks (the #1197 fence). Keep steps 2 and 3 short and use an off-peak window. +Google sign-in is not fenced. + +## The mixed window + +Between steps 2 and 3, and during a rollback, the new External REST runs against +the old control-api. `external-rest-api/test/mixedVersion.baseControlApi.test.ts` +pins this pairing: + +- Switch team and create team work. The old control-api ignores the session + header. +- A password reset succeeds and asks the user to sign in again. +- A replayed invitation accept behaves as before this release: External REST + sets the session cookie from the token the old control-api returns. +- A Profile UI sign-out gets the old control-api's `404 {"error":"Not Found"}`. + External REST forwards it and clears the cookie, and Profile UI reports that + the revocation was not confirmed. +- Native bearer tokens stay valid until their TTL, as before this release. +- `GET /api/v1/me` for a session the old control-api considers ended answers + `404 {"error":"User not found"}`, as on `dev`. + +The opposite pairing, old External REST against the new control-api, is not +supported: team create, team switch and password reset fail in it. This is why +External REST and Profile UI roll out before control-api, and why a rollback +starts with control-api. + +If the server does not confirm a sign-out (a 404 during the rollout window, a +503, or a network failure), Profile UI stops using the session cookie until the +next successful sign-in, a confirmed sign-out, or an accepted password reset, +and tells the user. + +## Rollback residue + +Session revocations and password epochs written by the new control-api are +ignored by the old one. The release installs no database triggers and no +revision functions, so nothing keeps running against the old image. + +Logout revokes only the token it receives. A V1 token issued before a team +switch stays valid until its own expiry (at most 7 days, 604800 s), as on `dev`. A new +Desktop build against the old External REST removes a rejected stored token as +before this release. + +## Migration preconditions and size + +- The target database has no migration version and no object from an earlier + head of this change; the migration Job stops otherwise (see + [Databases that ran an earlier head of this change](#databases-that-ran-an-earlier-head-of-this-change)). + The release adds `0137_user_access_foundation` and + `0138_legacy_password_security_epoch_backfill`. +- PostgreSQL 14 or later on Linux, or 15 or later on macOS, illumos or BSD (not + Windows). The migration session sets `client_connection_check_interval`, + which the server rejects on other platforms, so that a killed Job pod does + not leave its backend holding the migration advisory lock. +- For a database already at `0136`, every statement of `0137` and `0138` + waits at most 10 s for a lock (`lock_timeout`), and ordinary statements are + bounded at 15 s (`statement_timeout`); the online index builds have their own + 120 s bound (below). The whole Job is bounded at 300 s + (`activeDeadlineSeconds`). +- For a database at `0130` through `0135`, the missing versions through `0136` + commit in one transaction under the 10 s lock and 15 s statement bounds, with + no fence, then `0137`, then `0138`. +- An existing database below `0130` must already have `0126` to `0128`; the + Job stops with an error otherwise. For such a database the 10 s lock bound + does not hold before `0137`. The Job first sets the `0130` password-login + fence, which waits up to 60 s for a lock and allows 60 s per statement + (`dev` capped the statement at the pool's 15 s). It + then waits, with no limit of its own, until password checks already in + progress finish. It then applies `0129` (when missing) through `0136` in one + transaction. `0129` raises `lock_timeout` to 60 s and then sets it to 0 (no + limit) for the rest of that transaction, so a lock wait there is bounded only + by the 15 s statement timeout. In that case the only bound on the whole + catch-up is the Job's 300 s deadline; plan the maintenance window for it. +- `0137_user_access_foundation` first builds five indexes online + (`CREATE INDEX CONCURRENTLY`, which takes `ShareUpdateExclusiveLock`) on + the existing tables `team_members`, `user_contexts`, `team_contexts`, + `user_agents` and `team_agents`. Each build scans its table, and each runs + under a 120 s `statement_timeout` of its own, inside the Job's 300 s. They + took 0.1–0.3 s each at measured sizes. It then creates the three session + tables, with foreign keys to `users`, and their four indexes inside its + bounded transaction. +- `CREATE INDEX CONCURRENTLY` waits for every transaction older than itself. + Each of those waits is a lock wait bounded by the 10 s `lock_timeout`; the + 120 s bound applies to the whole build statement. A transaction that stays + open longer than 10 s (for example `pg_dump`) therefore makes each build + attempt fail after about 10 s, and the Job fails on every attempt while that + transaction lasts. Several overlapping shorter transactions can still keep a + build waiting up to its 120 s bound. Do not run the migration during backups + or other long transactions. +- The foreign keys to `users` take `SHARE ROW EXCLUSIVE` on `users` until + `0137`'s transaction commits. Writes to `users` are blocked for that time. + The wait for that lock is bounded at 10 s. +- `0138_legacy_password_security_epoch_backfill` copies `password_set_at` of + every user that has one into the password epochs, in one `INSERT … SELECT` + over `users`. It takes `AccessShareLock` on `users`, which does not block + ordinary reads and writes but does conflict with operations that need + `AccessExclusiveLock`, such as `TRUNCATE`, `DROP TABLE` or most + `ALTER TABLE` forms. It must finish within the 15 s statement timeout. + Measured at about 6 µs per row on one laptop, it reaches that timeout near + 2.4 million users and then fails without a partial write. +- `0138` treats every V1 session issued at or before the second of a user's + `password_set_at` as issued before that password event, and rejects it. The + previous release kept existing sessions when a password was set or changed, + and a V1 session lasts 7 days (604800 s). So a user whose password was set or + changed within 7 days before the migration loses the sessions issued up to + that second and must sign in again. If the release serving before the + migration predates the seven-day V1 lifetime, its sessions last 12 hours and + only password changes in the 12 hours before the migration are affected. This includes the session issued in the same second + when a first password was set from an invitation, and the session from which + a user changed their password in Profile UI. No other session is affected by + the backfill. +- After the Job, `verify_db_migration_state` checks the grants of + `workflow_recipes_runtime` against + `deploy/scripts/workflow-recipes-runtime-access-profiles.tsv` (one row per + relation; `dev` had 7) and + `deploy/scripts/workflow-recipes-runtime-column-access-profiles.tsv` (6 + column rows; new in this release). A mismatch stops the deploy after `0137` + and `0138` have committed, while the old control-api keeps serving. Compare + the target database's grants with both files before step 1. +- `deploy/scripts/run-control-api-db-migration.sh` requires `--timeout` (or + `TIMEOUT`) to be a number with a unit (`s`, `m` or `h`) and at least `360s`; + it only sets how long the script waits. The Job deadline is fixed at 300 s by + `control-api-migration-execution-policy.json`, which must sit next to the + script. +- A terminal migration Job left by an earlier run is deleted with a UID + precondition taken from the Job the script just read. If another caller + replaced that Job in the meantime, the script stops instead of deleting it. + +## Databases that ran an earlier head of this change + +Earlier heads of this change recorded other migration versions (under several +four-character prefixes, decimal or hexadecimal such as `010a`, some below +`0130`, and `user_access_foundation` under the prefix `0131`, in some heads with +a larger body) +and created revision tables, catalog tables and `authorization_*` trigger +functions. The current migration runner does not drop them. Their triggers +would keep running for writers that no longer take the matching locks, so the +migration Job stops before `0137` when it finds any of them. Its error names +the versions, functions and tables it found. + +The shared clusters (`clerum-dev` and production) never ran an earlier head: +the check query below returned no rows on both on 2026-11-06, with +`schema_migrations` at `0130_password_login_success_admission` and +`0129_workflow_schedules_concurrency_policy`. Branch profiles and local +databases may have. + +Check query (read-only; run it as the control-api schema owner, with that +schema first in `search_path`): + +```sql +SELECT 'version' AS kind, version AS name + FROM schema_migrations + WHERE substring(version from '^[0-9a-f]{4}_(.+)$') = ANY (ARRAY[ + 'user_access_foundation', + 'invitation_delivery_commands', + 'catalog_utf8_ordering', + 'composable_catalog_revisions', + 'gfs_catalog_revision_components', + 'legacy_password_security_epoch_backfill', + 'user_access_foundation_definer_temp_shadow_hardening', + 'authorization_revision_delete_compatibility' + ]) + AND version <> ALL (ARRAY[ + '0137_user_access_foundation', + '0138_legacy_password_security_epoch_backfill' + ]) +UNION ALL +SELECT 'function', fn.oid::regprocedure::text + FROM pg_proc fn + JOIN pg_namespace fn_ns ON fn_ns.oid = fn.pronamespace + WHERE fn_ns.nspname = current_schema() + AND (fn.proname LIKE 'authorization\_bump\_%' ESCAPE '\' + OR fn.proname LIKE 'authorization\_prelock\_%' ESCAPE '\' + OR fn.proname = 'catalog_utf8_bytes') +UNION ALL +SELECT 'table', rel.relname::text + FROM pg_class rel + JOIN pg_namespace rel_ns ON rel_ns.oid = rel.relnamespace + WHERE rel_ns.nspname = current_schema() + AND rel.relname = ANY (ARRAY[ + 'authorization_catalog_environment', + 'authorization_catalog_revision', + 'authorization_catalog_writer_components', + 'authorization_resource_revisions', + 'authorization_team_revisions', + 'authorization_user_revisions', + 'invitation_delivery_commands', + 'operational_catalog_source_state', + 'operational_relationships_staging', + 'operational_resource_index', + 'operational_resource_index_staging', + 'operational_resource_relationships' + ]) + ORDER BY 1, 2; +``` + +No rows means the database is ready for step 1. If it returns rows: + +- Branch profiles: delete and recreate the profile. +- Other databases: stop control-api, every migration Job and trace maintenance, + then run `docs/deploy/sql/user-access-foundation-earlier-head-cleanup.sql` + once, as the schema owner. It runs in one transaction. It drops the + functions (with their triggers), the tables and the earlier-head indexes on + existing tables, and deletes the earlier-head migration versions. It keeps + `0137_user_access_foundation` and `0138_legacy_password_security_epoch_backfill` + when they are present. Run the check query again (no rows), then run the + migration Job. + +## What to tell users and operators + +- Team budgets no longer cover direct-grant V1 workflow runs. Those runs are + still recorded and budgeted by user, recipe, Host and LLM secret. +- A Google user who has only a pending invitation gets a session without a team + until they accept the invitation. +- Users who set or changed their password in the 7 days before the migration + must sign in again (see the `0138` bullet above). + +## Variables this release does not read + +Pre-release builds of this change read the following control-api variables. The +release does not, so setting them has no effect. Remove them from any +environment that set them for those builds: + +- `CONTROL_API_OPERATIONAL_ACCESS_INDEXER_ENABLED`, + `CONTROL_API_OPERATIONAL_ACCESS_INDEXER_RETRY_MS`, + `CONTROL_API_OPERATIONAL_ACCESS_READINESS_MAX_AGE_MS`; +- `CONTROL_API_USER_ACCESS_CATALOG_ACTIVATION_RECORD`, + `CONTROL_API_USER_ACCESS_CATALOG_MODE`, + `CONTROL_API_USER_ACCESS_TEAM_GFS_MEMBERSHIP_ADMISSION_LIMIT`; +- `CONTROL_API_USER_ACCESS_ACTION_CONTEXT_V2`, + `CONTROL_API_USER_ACCESS_RPC_DELEGATION_V2`, + `CONTROL_API_USER_ACCESS_DESKTOP_ALL_TEAM_MODE`, + `CONTROL_API_USER_ACCESS_PROFILE_V2_MODE`. diff --git a/docs/how-to/codeql-js-missing-rate-limiting.md b/docs/how-to/codeql-js-missing-rate-limiting.md index 9b66f50f2a..533c00e67d 100644 --- a/docs/how-to/codeql-js-missing-rate-limiting.md +++ b/docs/how-to/codeql-js-missing-rate-limiting.md @@ -7,14 +7,84 @@ 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. + +### PR #334 External REST alerts — won't fix (dev parity) + +The analysis of `refs/pull/334/merge` reports two External REST routes that +had no frequency limit on dev either. Under the parity decision above they +keep none. Adding the edge's `createRateLimiter` would not clear the alerts: +the stock query models `express-rate-limit`, not that in-process middleware, +which is why alert #1767, on the invitation routes it guards, is recorded as a +false positive (below). + +| Alert | Location (PR head) | Route | Dev location | +| ---------------------------------------------------------------------------- | ----------------------- | ---------------------- | ---------------- | +| [#1971](https://github.com/evenfire-ai/evenfire/security/code-scanning/1971) | `routes/me.ts:89` | `GET /me` | `me.ts:81` | +| [#1972](https://github.com/evenfire-ai/evenfire/security/code-scanning/1972) | `routes/me.ts:89` | `GET /me` | `me.ts:81` | +| [#1973](https://github.com/evenfire-ai/evenfire/security/code-scanning/1973) | `routes/members.ts:152` | `POST /members/invite` | `members.ts:151` | +| [#1974](https://github.com/evenfire-ai/evenfire/security/code-scanning/1974) | `routes/members.ts:152` | `POST /members/invite` | `members.ts:151` | + +Paths are under `external-rest-api/src/`. Neither dev route had a limiter. `GET /me` is reported anew because its +handler changed (`requireSessionAuth` and the `503 authority_unavailable` +response), not because a limit was removed. `POST /members/invite` carries the +same finding as the open default-branch alerts +[#252](https://github.com/evenfire-ai/evenfire/security/code-scanning/252) and +[#253](https://github.com/evenfire-ai/evenfire/security/code-scanning/253) at +`members.ts:151`; the route moved one line. + +`GET /me` is the session probe that Desktop and Profile UI call on every +session restore; a limiter there would add a new `429` response to that probe. +If a limit is wanted on either route later, the CodeQL-visible form is a +per-user `express-rate-limit` bucket after `requireAuth`, as on member +retirement (`routes/members.ts:44-56`); it is a behaviour change against dev. + +### PR #334 control-api alerts (analysis of `refs/pull/334/merge`) + +These alerts are open on the PR ref and not on `dev`. None of them marks a limit +that this PR removed. + +| Alerts | Location (PR head) | Disposition | +| ---------------------------- | ----------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| #914, #916–#921, #932, #1981 | `control-api/src/routes/external/teams.ts:57-255` | False positive. `router.use('/external/teams', ...externalTeamsRateLimits, ...)` at `teams.ts:53` runs `express-rate-limit` first, as on `dev`. The query does not follow it. | +| #1979 | `control-api/src/routes/external/directory.ts:29` | False positive. `router.use('/external/directory', ...externalDirectoryRateLimits, ...)` at `directory.ts:21-25` applies the same limiters, as on `dev`. | +| #1980 | `control-api/src/routes/external/auth.ts:321` (`POST /external/auth/verify`) | Won't fix, dev parity. The route has no frequency limit here or on `dev` (`auth.ts:153`). It is reported anew because its handler now authenticates through the session service. The fix belongs to #1200. | +| #1982, #1983 | `control-api/test/externalSessionTeamAuthorization.test.ts:35`, `services.liveTeamAuthorization.test.ts:94` | Test only. Both are Express apps built inside a test; no deployed service serves them. | ## Approved patterns @@ -70,15 +140,15 @@ Follow-up implementation tracked separately (`fix/codeql-control-api-rate-limit- ### False positive — PG limiter or non-route sink present -| Alert | Location | Evidence | -| ---------------------------------------------------------------------------- | -------------------------------------------------------------------- | ------------------------------------------------------------------------- | -| [#57](https://github.com/evenfire-ai/evenfire/security/code-scanning/57) | `app.ts:131` | Middleware mount only; per-route limiters apply downstream | -| [#58](https://github.com/evenfire-ai/evenfire/security/code-scanning/58) | `app.ts:219` | 404 guard for unknown `/gfs/*`; no expensive work | -| [#60](https://github.com/evenfire-ai/evenfire/security/code-scanning/60) | `auth.ts:572` | Handler under `publicAdminTokenRateLimit()` (`adminPublicTokenRlPerMin`) | -| [#171](https://github.com/evenfire-ai/evenfire/security/code-scanning/171) | `registry.ts:24` | `rateLimitMiddleware` 30/min at `:28-35` | -| [#826](https://github.com/evenfire-ai/evenfire/security/code-scanning/826) | `registryConnect.ts:321` | `rateLimitMiddleware` 3/min at `:329-336` | -| [#803](https://github.com/evenfire-ai/evenfire/security/code-scanning/803) | `gfs/grants.ts:849` | Internal DB helper, not an HTTP route handler | -| [#1767](https://github.com/evenfire-ai/evenfire/security/code-scanning/1767) | `app.ts:73` | False positive — invitation error-response middleware; evidence below | +| Alert | Location | Evidence | +| ---------------------------------------------------------------------------- | -------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------- | +| [#57](https://github.com/evenfire-ai/evenfire/security/code-scanning/57) | `app.ts:131` | Middleware mount only; per-route limiters apply downstream | +| [#58](https://github.com/evenfire-ai/evenfire/security/code-scanning/58) | `app.ts:219` | 404 guard for unknown `/gfs/*`; no expensive work | +| [#60](https://github.com/evenfire-ai/evenfire/security/code-scanning/60) | `auth.ts:572` | Handler under `publicAdminTokenRateLimit()` (`adminPublicTokenRlPerMin`) | +| [#171](https://github.com/evenfire-ai/evenfire/security/code-scanning/171) | `registry.ts:24` | `rateLimitMiddleware` 30/min at `:28-35` | +| [#826](https://github.com/evenfire-ai/evenfire/security/code-scanning/826) | `registryConnect.ts:321` | `rateLimitMiddleware` 3/min at `:329-336` | +| [#803](https://github.com/evenfire-ai/evenfire/security/code-scanning/803) | `gfs/grants.ts:849` | Internal DB helper, not an HTTP route handler | +| [#1767](https://github.com/evenfire-ai/evenfire/security/code-scanning/1767) | `app.ts:73` | False positive — invitation error-response middleware; evidence below | | [#1769](https://github.com/evenfire-ai/evenfire/security/code-scanning/1769) | `passwordResetPayload.externalRest.realPostgres.integration.test.ts` | Test-only composition: Control API and External REST bind loopback; producer is task-local on an ephemeral port | Alert #1767 is attached to the invitation error-response middleware mount, which maps upstream @@ -118,8 +188,8 @@ for the ambiguous-reset case. For ordinary proxy responses it copies the upstrea sets a constant `application/json` content type, and forwards no response headers, including `Retry-After` or `Set-Cookie`. For the one-use password-token POST it consumes the upstream response and destroys the downstream connection to model a lost reset acknowledgement. The public -External REST route owns its bounded retry response and issues its own session cookie only after a -successful reset with a valid session token. Focused invitation and trusted-source tests verify the +External REST route owns its bounded retry response and, after a successful reset with a valid +session token, clears the profile session cookie instead of issuing a session. Focused invitation and trusted-source tests verify the public 429/`Retry-After` behavior; they do not assert that the fault-injection proxy forwards those headers. @@ -160,5 +230,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 diff --git a/docs/operations/password-admission.md b/docs/operations/password-admission.md index 59cb059a34..50144d9aab 100644 --- a/docs/operations/password-admission.md +++ b/docs/operations/password-admission.md @@ -50,8 +50,11 @@ A password-reset invitation issued by the trusted member-registration flow can s new password without reserving the anonymous login pace. The reset credential is validated for signature, registered identity, email, invitation purpose, expiry and pending state. The password update and sibling reset-link revocation commit together. -Only after that commit does Control API issue the normal signed session, which -External REST stores in the profile session cookie. +Control API returns no session from that mutation. External REST clears any +invitation or recovery session cookie it receives and answers with +`reauthenticationRequired: true`, so Profile UI shows a password-updated screen +and a link to sign in. The person then authenticates through the ordinary login +route; only that login issues a session. Recovery hashes use the existing durable `password_verification_work` singleton. Authenticated password changes and accepted-invitation password setup use that same diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index a18ac0daeb..15b6aac121 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -82,8 +82,9 @@ Every replica syncs once at start: as the cron's first tick, or, with the cron off, on its own unless `LLM_CATALOG_BOOT_SYNC_ENABLED=false` (code default `true`; only `true` or `false`). A boot sync that got no live, plausible catalog, or failed, probes models.dev again with backoff (30 s doubling up to 1 h) and -reconciles once it does. `MODELS_DEV_API_URL` must be `https://` and must not -point at an internal host; control-api refuses to start otherwise. +reconciles once it does. `MODELS_DEV_API_URL` must be `https://`, must not point +at an internal host and must not contain credentials; control-api refuses to +start otherwise. ## Where every variable is documented diff --git a/docs/surfaces/profile-ui.md b/docs/surfaces/profile-ui.md index 5c5a7ad85b..bccdd43cd0 100644 --- a/docs/surfaces/profile-ui.md +++ b/docs/surfaces/profile-ui.md @@ -30,12 +30,17 @@ Surfaces](README.md) for how the three consoles divide the platform. GET /api/v1/invitations/token/:token` before it renders. 4. **They accept it**, then **set a password** on the same screen: the invitation token, email, invitation id, and new password all go to - `external-rest-api POST /api/v1/invitations/password`; the invitation - session token is never returned in the response body or exposed to page - JavaScript — external-rest-api sets it as an HttpOnly cookie. -5. **Profile UI offers a Download Evenfire link** — not an automatic - handoff. The member installs the [Desktop App](desktop-app.md) - themselves. + `external-rest-api POST /api/v1/invitations/password`. Acceptance issues the + invitation session as an HttpOnly cookie; setting the password revokes that + session, clears the cookie and answers with `reauthenticationRequired: true`. + Profile UI then shows a password-updated screen with no session and a link to + 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. + 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=...` @@ -67,6 +72,13 @@ for what breaks without it. session. - `/desktop-setup` — the Desktop App handoff, above. +## Sign-out + +If the server does not confirm a sign-out (a 404 during a rollout window, a 503, +or a network failure), Profile UI stops using the session cookie until the next +successful sign-in, a confirmed sign-out, or an accepted password reset, and +tells the member. + ## Deploy Profile UI ships in the platform Kustomize bases — diff --git a/docs/testing/minikube-t2-runbook.md b/docs/testing/minikube-t2-runbook.md index 8c9e293274..78a60b3461 100644 --- a/docs/testing/minikube-t2-runbook.md +++ b/docs/testing/minikube-t2-runbook.md @@ -379,6 +379,60 @@ 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 Control API builds pinned in +`tests/contracts/certification-inputs.json`. `observedDev` is an immutable +pre-fence `dev` commit. `latestDev` is the `dev` tip of the last merge of `dev` +into the change, the state a shared database starts from, so every such merge +moves its commit and source hashes in the contract. Once the change is on `dev`, +the pin stays at the last `dev` commit before it and means "`dev` before this +change". `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. While a +lane runs, do not fetch, pull or check out inside the producer worktrees, and +do not run `git fetch` in any worktree of the repository. Confirm the inputs with +`node scripts/ci/certification-inputs.mjs check --scope t1` before launching. + +After the `latestDev` pin moves, `check` and the lane refuse an existing +`latest-dev` worktree with `producer commit mismatch`. Between lanes, re-point +it to the new `latest_commit`, install and rebuild: + +```bash +git -C "$PRODUCERS/latest-dev" checkout --detach +(cd "$PRODUCERS/latest-dev/packages/workflow-runtime-core" && npm ci && npm run build) +(cd "$PRODUCERS/latest-dev/control-api" && npm ci) +TASK106_R61_B1_LATEST_DEV_SOURCE_DIR="$PRODUCERS/latest-dev" \ + node scripts/ci/certification-inputs.mjs build-producer --producer latestDev +``` + ## T0, T1, and T2 boundaries - **T0** — shell syntax/ShellCheck where available, contract tests, affected diff --git a/docs/testing/password-recovery-dev-integration.md b/docs/testing/password-recovery-dev-integration.md index 167f0cc89e..69af9cd2ac 100644 --- a/docs/testing/password-recovery-dev-integration.md +++ b/docs/testing/password-recovery-dev-integration.md @@ -213,7 +213,7 @@ It selects exactly `control-api/test/passwordRecovery.memberRegistration.realPostgres.integration.test.ts` through the strict suite manifest. Expect one physical suite and one passing scenario, containing admission-denial, real stored signed proof, preview/row -binding, wrong-email/row negatives, reset session and later-login assertions. +binding, wrong-email/row negatives, pre-reset session revocation and later-login assertions. Require process exit zero, nonzero passing collection and **zero skipped tests**. The runner rejects missing files/environment and skipped/zero-test results. The configured suite does not start or replace the producer, and does not prove @@ -250,13 +250,16 @@ revisions, and ensure no unrelated account uses the synthetic address. Separately exercise invalid proof and mismatched email/row input using only synthetic records: they must not update credentials, create a cookie or expose another account. Avoid modifying or probing an unrelated invitation. -4. Submit an approved valid replacement password once. Confirm the correct - account identity and authenticated session. Inspect cookie **attributes** - privately: `profile_session`, HttpOnly, expected Path/SameSite and Secure for - the intended HTTPS environment. Do not record cookie or token values. -5. Confirm session hydration and navigation to the authenticated view, including - a refresh. The UI must not automatically replay the reset after a hydration - failure. Record the displayed account identity in sanitized form. +4. Submit an approved valid replacement password once. Confirm Control API + returns no session and External REST clears any previous `profile_session` + cookie instead of issuing a new session. Confirm Profile UI shows the + password-updated screen with a link to sign in. Inspect the cleared cookie + **attributes** privately: `profile_session`, HttpOnly, expected Path/SameSite + and Secure for the intended HTTPS environment. Do not record cookie or token + values. +5. Sign in with the new password and confirm navigation to the authenticated + view, including a refresh. The UI must not automatically replay the reset. + Record the displayed account identity in sanitized form. 6. Reuse the consumed proof once deliberately: completion must be rejected, credentials must stay unchanged and no new authenticated session may be published from that replay. Confirm sibling reset invalidation using only @@ -292,7 +295,7 @@ Physical suites / passed / failed / skipped counts: Manual email receipt and timestamp (redacted): Link origin and trusted account/token binding (no URL/proof): Reset / invalid / mismatch / consumed-proof results: -Cookie attributes / session identity / hydration / navigation: +Post-reset session absence / cookie clearing / sign-in navigation: New-password login under ordinary admission: Unrelated synthetic account/invitation state: Flow-row cleanup / temporary database drop: diff --git a/external-rest-api/package-lock.json b/external-rest-api/package-lock.json index 55bee245fc..2e1d8d7135 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.181", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-external-rest-api", - "version": "0.1.139", + "version": "0.1.181", "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 44a18d2cc8..7895bf9307 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.181", "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..58748e2467 --- /dev/null +++ b/external-rest-api/src/__tests__/browserMutationGuard.test.ts @@ -0,0 +1,65 @@ +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('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') + .set('authorization', 'Bearer desktop-session') + .expect(200, { ok: true }) + }) +}) diff --git a/external-rest-api/src/__tests__/controlApiClient.passthrough.test.ts b/external-rest-api/src/__tests__/controlApiClient.passthrough.test.ts index 098185ad50..47b1356bd3 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).headers).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 new file mode 100644 index 0000000000..04b740fea2 --- /dev/null +++ b/external-rest-api/src/__tests__/publicErrorContract.test.ts @@ -0,0 +1,249 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import express from 'express' +import request from 'supertest' +import { externalRestPublicErrorHandler } from '../app.js' +import { ControlApiError, controlApiRequest } from '../controlApiClient.js' +import { selectPublicCorrelationId } from '../http/publicApiError.js' + +function appThrowing(error: Error) { + const app = express() + app.get('/failure', (_req, _res, next) => next(error)) + app.use(externalRestPublicErrorHandler) + return app +} + +function privatePostgresSentinel(suffix = ''): string { + return ['postgres://secret@', 'internal', suffix].join('') +} + +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 Error('private'))) + .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('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') + + 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('forwards a control-api 503 body verbatim and never the internal wrapper', async () => { + const response = await request( + appThrowing( + new ControlApiError('raw postgres failure at ' + privatePostgresSentinel(), 503, { + error: 'session_backend_unavailable', + }) + ) + ).get('/failure') + + expect(response.status).toBe(503) + expect(response.body).toEqual({ error: 'session_backend_unavailable' }) + expect(JSON.stringify(response.body)).not.toContain('postgres') + expect(JSON.stringify(response.body)).not.toContain('Control API') + }) + + 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).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('Control API') + }) + + it('forwards an upstream throttling body verbatim with its quota headers and no private state', async () => { + const upstream = new ControlApiError( + 'raw rate limiter state', + 429, + { + error: { + code: 'rate_limited', + details: { retryAfterSeconds: 17, bucket: 'secret-internal-bucket' }, + }, + }, + { + 'retry-after': '17', + ratelimit: 'limit=30, remaining=0, reset=17', + 'ratelimit-policy': '30;w=60', + 'x-ratelimit-limit': '30', + 'x-ratelimit-remaining': '0', + 'x-ratelimit-reset': '1900000000', + 'x-request-id': 'upstream-request-7', + 'x-internal-bucket': 'secret-internal-bucket', + } + ) + const response = await request(appThrowing(upstream)).get('/failure') + + expect(response.status).toBe(429) + 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.ratelimit).toBe('limit=30, remaining=0, reset=17') + expect(response.headers['ratelimit-policy']).toBe('30;w=60') + 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 request id stays a GFS-route relay; the internal bucket never crosses. + expect(response.headers['x-request-id']).toBeUndefined() + expect(response.headers['x-internal-bucket']).toBeUndefined() + }) + + it('drops throttling header values outside the numeric public bounds', async () => { + const upstream = new ControlApiError( + 'raw rate limiter state', + 429, + { error: 'rate_limited' }, + { + 'retry-after': 'Wed, 21 Oct 2026 07:28:00 GMT', + 'x-ratelimit-limit': '-1', + 'x-ratelimit-remaining': '12345678901', + 'x-ratelimit-reset': '1900000000', + } + ) + const response = await request(appThrowing(upstream)).get('/failure') + + expect(response.status).toBe(429) + expect(response.body).toEqual({ error: 'rate_limited' }) + // Witness: the one in-bounds value of the same response is relayed. + expect(response.headers['x-ratelimit-reset']).toBe('1900000000') + expect(response.headers['retry-after']).toBeUndefined() + expect(response.headers['x-ratelimit-limit']).toBeUndefined() + expect(response.headers['x-ratelimit-remaining']).toBeUndefined() + + for (const outOfRange of ['0', '86401']) { + const outage = await request( + appThrowing( + new ControlApiError( + 'raw outage', + 503, + { error: 'session_backend_unavailable' }, + { 'retry-after': outOfRange, 'cache-control': 'no-store' } + ) + ) + ).get('/failure') + expect(outage.status).toBe(503) + expect(outage.headers['cache-control']).toBe('no-store') + expect(outage.headers['retry-after']).toBeUndefined() + } + }) + + it.each([408, 425, 502, 504])( + 'relays retry guidance, and no quota state, on a retryable %i', + async status => { + const upstream = new ControlApiError( + 'raw upstream failure', + status, + { error: 'temporarily_unavailable' }, + { + 'retry-after': '5', + ratelimit: 'limit=30, remaining=0, reset=5', + 'x-ratelimit-limit': '30', + 'cache-control': 'no-store', + } + ) + const response = await request(appThrowing(upstream)).get('/failure') + + expect(response.status).toBe(status) + expect(response.body).toEqual({ error: 'temporarily_unavailable' }) + expect(response.headers['retry-after']).toBe('5') + expect(response.headers.ratelimit).toBeUndefined() + expect(response.headers['x-ratelimit-limit']).toBeUndefined() + expect(response.headers['cache-control']).toBeUndefined() + } + ) + + it('relays no retry or quota header on a non-retryable client error', async () => { + const upstream = new ControlApiError( + 'raw validation failure', + 400, + { error: { code: 'invalid_request', message: 'name is required' } }, + { + 'retry-after': '5', + ratelimit: 'limit=30, remaining=29, reset=5', + 'x-ratelimit-limit': '30', + } + ) + const response = await request(appThrowing(upstream)).get('/failure') + + // Witness: the handler ran the ControlApiError branch and forwarded the body. + expect(response.status).toBe(400) + expect(response.body).toEqual({ + error: { code: 'invalid_request', message: 'name is required' }, + }) + expect(response.headers['retry-after']).toBeUndefined() + expect(response.headers.ratelimit).toBeUndefined() + expect(response.headers['x-ratelimit-limit']).toBeUndefined() + }) +}) diff --git a/external-rest-api/src/app.ts b/external-rest-api/src/app.ts index 33827ccb1a..7919204b9d 100644 --- a/external-rest-api/src/app.ts +++ b/external-rest-api/src/app.ts @@ -1,7 +1,11 @@ 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 { sendPublicApiError } from './http/publicApiError.js' +import { requireTrustedBrowserMutation } from './middleware/browserMutationGuard.js' import { withExternalRequestContext } from './requestContext.js' import { createAuthRouter } from './routes/auth.js' import { createContextSharedFilesystemsRouter } from './routes/contextSharedFilesystems.js' @@ -30,6 +34,51 @@ import { createExternalWorkflowsRouter } from './routes/workflows.js' // error before the canonical 4xx path is reached. const GFS_UPLOAD_PART_PATH = /^\/api\/v1\/me\/gfs\/uploads\/[^/]+\/parts\/[0-9]+$/i +// Retry guidance the global handler relays from a ControlApiError. The client +// has already filtered the upstream headers; the allowlist is repeated at this +// last hop (as routes/gfs.ts does) so a hand-built ControlApiError cannot +// publish arbitrary headers. `x-request-id` is never relayed here. +const RETRY_AFTER_STATUSES = new Set([408, 425, 429, 502, 503, 504]) +const RATE_LIMIT_NUMERIC_HEADERS = [ + 'x-ratelimit-limit', + 'x-ratelimit-remaining', + 'x-ratelimit-reset', +] as const +// The IETF RateLimit fields are structured (`limit=30, remaining=0, reset=17`, +// `30;w=60`), so they are relayed as control-api wrote them, like routes/gfs.ts. +const RATE_LIMIT_STRUCTURED_HEADERS = ['ratelimit', 'ratelimit-policy'] as const +const UNSIGNED_INTEGER_HEADER = /^\d{1,10}$/ +const MAX_RETRY_AFTER_SECONDS = 86_400 + +function unsignedIntegerHeader(raw: string | undefined): number | undefined { + if (!raw || !UNSIGNED_INTEGER_HEADER.test(raw)) return undefined + const value = Number(raw) + return Number.isSafeInteger(value) ? value : undefined +} + +function relayControlApiRetryHeaders(err: ControlApiError, res: Response): void { + const headers = err.headers + if (RETRY_AFTER_STATUSES.has(err.status)) { + const retryAfter = unsignedIntegerHeader(headers['retry-after']) + if (retryAfter !== undefined && retryAfter >= 1 && retryAfter <= MAX_RETRY_AFTER_SECONDS) { + res.setHeader('retry-after', String(retryAfter)) + } + } + if (err.status === 429) { + for (const name of RATE_LIMIT_NUMERIC_HEADERS) { + const value = unsignedIntegerHeader(headers[name]) + if (value !== undefined) res.setHeader(name, String(value)) + } + for (const name of RATE_LIMIT_STRUCTURED_HEADERS) { + const value = headers[name] + if (value) res.setHeader(name, value) + } + } + if (err.status === 503 && headers['cache-control']?.trim().toLowerCase() === 'no-store') { + res.setHeader('cache-control', 'no-store') + } +} + export function createApp() { const app = express() // The deployment admits traffic only from the cloudflared ingress namespace @@ -58,6 +107,7 @@ export function createApp() { } jsonBodyParser(req, res, next) }) + app.use(requireTrustedBrowserMutation) app.use(createHealthRouter()) @@ -88,23 +138,49 @@ 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', - }) + app.use(externalRestPublicErrorHandler) + + return app +} + +export function externalRestPublicErrorHandler( + err: unknown, + req: Request, + res: Response, + _next: NextFunction +): void { + // 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) { + relayControlApiRetryHeaders(err, res) + 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 + } - res.status(500).json({ - error: 'Internal Server Error', - message: err instanceof Error ? err.message : 'Unknown error', - }) - }) + // 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) { + res.status(status).json({ error: err instanceof Error ? err.message : 'Bad Request' }) + return + } - return app + // 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/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/controlApiClient.ts b/external-rest-api/src/controlApiClient.ts index edc3594237..ce04e326a1 100644 --- a/external-rest-api/src/controlApiClient.ts +++ b/external-rest-api/src/controlApiClient.ts @@ -11,16 +11,6 @@ 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 @@ -29,13 +19,13 @@ export class ControlApiError extends Error { 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.headers = responseHeaders } get code(): string | null { @@ -52,6 +42,28 @@ export class ControlApiError extends Error { } } +/** + * control-api sends the public `401 {error:{code:'invalid_session'}}` envelope from + * `/external/users/:id/me` and the `/external/auth/*` session routes; `/external/auth/verify` + * is the exception and answers a bare 401. Every route behind + * its `requireValidExternalSessionToken` (`/external/users/*` except `/me`, + * `/external/teams/*`, `/external/members/*` and the other session-plane routes) answers + * an ended session with a bare 401, the same body its service-credential guard sends when + * it refuses this edge's credential; the two cannot be told apart. + */ +export function isControlApiInvalidSession(error: unknown): error is ControlApiError { + if (!(error instanceof ControlApiError) || error.status !== 401) return false + const body = error.body + if (body === null || typeof body !== 'object' || !('error' in body)) return false + const envelope = body.error + return ( + typeof envelope === 'object' && + envelope !== null && + 'code' in envelope && + envelope.code === 'invalid_session' + ) +} + export type ControlApiTransportOperation = 'read' | 'mutation' export type ControlApiTransportPhase = 'dispatch' | 'response' @@ -72,16 +84,102 @@ export class ControlApiUnexpectedResponseError extends Error { } } -function forwardableResponseHeaders(response: Response): Record { - const headers: Record = {} +/** + * 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 app's existing 4xx handler maps it to a public + * invalid-request error without a new status scheme. + */ +export class ControlApiPathError extends ControlApiError { + constructor() { + super('Control API request path is not allowed', 400, { error: 'invalid_request' }) + this.name = 'ControlApiPathError' + } +} + +const DOT_SEGMENT = /^(?:\.|\.\.)$/ +const RAW_QUERY_CONTROL_CHARACTER = /[\u0000-\u001f\u007f]/ + +/** + * The single boundary that keeps a caller-influenced value from selecting a + * different control-api route. Call sites interpolate route parameters into + * `path`; URL construction would otherwise normalize dot segments or drop a + * `#` fragment and dispatch the resulting path with this edge's service + * identity. A `?` is rejected too: an unencoded value would truncate the path + * and select another handler below the call site's literal prefix. + * + * The only caller that forwards an opaque query string (the SharedFileSystem + * relay) passes it through `rawQueryString` instead, which is validated + * separately and appended after the pathname has been validated. + */ +export function assertControlApiPathAllowed(path: string): void { + const normalizedPath = path.startsWith('/') ? path : `/${path}` + if (normalizedPath.includes('?')) throw new ControlApiPathError() + if (normalizedPath.includes('#')) throw new ControlApiPathError() + + // Express decodes encoded separators and dots in route params before a call + // site interpolates them. Decode both for the segment check so a value that a + // proxy or another decoder could still resolve cannot pass as a literal. + const decodedSegments = normalizedPath.replace(/%2f/gi, '/').replace(/%2e/gi, '.').split('/') + if (decodedSegments.some(segment => DOT_SEGMENT.test(segment))) { + throw new ControlApiPathError() + } + + const base = config.controlApiBaseUrl.replace(/\/+$/, '') + const basePathname = new URL(base).pathname.replace(/\/+$/, '') + const constructedPathname = new URL(`${base}${normalizedPath}`).pathname + if (constructedPathname !== `${basePathname}${normalizedPath}`) { + throw new ControlApiPathError() + } +} + +/** + * Validate the opaque query string that the SharedFileSystem relay forwards. + * The bytes must reach control-api unchanged, so this never runs through + * `URLSearchParams`. It is appended only after the pathname is validated. + */ +export function assertControlApiRawQueryAllowed(rawQueryString: string): void { + if (rawQueryString === '') return + if (!rawQueryString.startsWith('?')) throw new ControlApiPathError() + if (rawQueryString.includes('#')) throw new ControlApiPathError() + if (RAW_QUERY_CONTROL_CHARACTER.test(rawQueryString)) throw new ControlApiPathError() +} + +const FORWARDABLE_RESPONSE_HEADERS = [ + 'retry-after', + 'ratelimit', + 'ratelimit-policy', + 'x-ratelimit-limit', + 'x-ratelimit-remaining', + 'x-ratelimit-reset', + 'x-request-id', +] as const + +function forwardableResponseHeaders(headers: Headers, status: number): Record { + const result: Record = {} for (const name of FORWARDABLE_RESPONSE_HEADERS) { - const value = response.headers.get(name) - if (value) headers[name] = value + const value = headers.get(name) + if (value) result[name] = value } - return headers + // 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' + } + return result } -function buildUrl(path: string, query?: Record): string { +function buildUrl( + path: string, + query?: Record, + rawQueryString?: string +): string { + assertControlApiPathAllowed(path) + if (rawQueryString !== undefined) assertControlApiRawQueryAllowed(rawQueryString) + if (query !== undefined && rawQueryString !== undefined) { + throw new Error('control-api raw query and structured query are mutually exclusive') + } const base = config.controlApiBaseUrl.replace(/\/+$/, '') const normalizedPath = path.startsWith('/') ? path : `/${path}` const url = new URL(`${base}${normalizedPath}`) @@ -92,7 +190,9 @@ function buildUrl(path: string, query?: Record): str } } } - return url.toString() + // Append the relayed query only after both the pathname and the query string + // passed validation, and keep its bytes untouched. + return rawQueryString ? `${url.toString()}${rawQueryString}` : url.toString() } async function readResponseText(response: Response): Promise { @@ -120,6 +220,8 @@ export async function controlApiRequestWithStatus( extraHeaders?: Record } ): Promise> { + // Build and validate the URL before any credential enters a request object. + const url = buildUrl(path, options?.query) const headers: Record = { 'content-type': 'application/json', authorization: `Bearer ${config.controlApiServiceToken}`, @@ -133,7 +235,6 @@ export async function controlApiRequestWithStatus( Object.assign(headers, options.extraHeaders) } - const url = buildUrl(path, options?.query) const body = options?.body !== undefined ? JSON.stringify(options.body) : undefined const operation: ControlApiTransportOperation = method === 'GET' || method === 'HEAD' ? 'read' : 'mutation' @@ -170,7 +271,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) + forwardableResponseHeaders(response.headers, 502) ) } @@ -183,7 +284,7 @@ export async function controlApiRequestWithStatus( `Control API ${method} ${path} failed (${response.status}): ${errorMessage}`, response.status, parsed, - forwardableResponseHeaders(response) + forwardableResponseHeaders(response.headers, response.status) ) } @@ -213,6 +314,8 @@ export async function controlApiBinaryRequestWithStatus( extraHeaders?: Record } ): Promise<{ body: Buffer; status: number; headers: Record }> { + // Build and validate the URL before any credential enters a request object. + const url = buildUrl(path, options?.query) const headers: Record = { authorization: `Bearer ${config.controlApiServiceToken}`, 'x-service-token': config.controlApiServiceName, @@ -225,7 +328,7 @@ export async function controlApiBinaryRequestWithStatus( Object.assign(headers, options.extraHeaders) } - const response = await fetch(buildUrl(path, options?.query), { method, headers }) + const response = await fetch(url, { method, headers }) if (!response.ok) { let parsed: unknown = null const raw = await readResponseText(response) @@ -238,7 +341,7 @@ export async function controlApiBinaryRequestWithStatus( `Control API ${method} ${path} failed (${response.status})`, response.status, parsed, - forwardableResponseHeaders(response) + forwardableResponseHeaders(response.headers, response.status) ) } @@ -262,6 +365,7 @@ export async function controlApiStreamRequest( path: string, options?: { query?: Record + rawQueryString?: string userSessionToken?: string extraHeaders?: Record signal?: AbortSignal @@ -269,6 +373,8 @@ export async function controlApiStreamRequest( throwOnHttpError?: boolean } ): Promise { + // Build and validate the URL before any credential enters a request object. + const url = buildUrl(path, options?.query, options?.rawQueryString) const headers: Record = { authorization: `Bearer ${config.controlApiServiceToken}`, 'x-service-token': config.controlApiServiceName, @@ -290,7 +396,7 @@ export async function controlApiStreamRequest( if (options?.body && typeof options.body === 'object' && Symbol.asyncIterator in options.body) { fetchInit.duplex = 'half' } - const response = await fetch(buildUrl(path, options?.query), fetchInit) + const response = await fetch(url, fetchInit) if (!response.ok && options?.throwOnHttpError !== false) { let parsed: unknown = null const raw = await readResponseText(response) @@ -303,7 +409,7 @@ export async function controlApiStreamRequest( `Control API ${method} ${path} failed (${response.status})`, response.status, parsed, - forwardableResponseHeaders(response) + forwardableResponseHeaders(response.headers, response.status) ) } return response @@ -322,6 +428,8 @@ export async function controlApiPassthroughGet( path: string, rawQueryString: string ): Promise<{ status: number; contentType: string; body: string }> { + assertControlApiPathAllowed(path) + assertControlApiRawQueryAllowed(rawQueryString) const base = config.controlApiBaseUrl.replace(/\/+$/, '') const normalizedPath = path.startsWith('/') ? path : `/${path}` const response = await fetch(`${base}${normalizedPath}${rawQueryString}`, { diff --git a/external-rest-api/src/http/passwordAdmissionError.ts b/external-rest-api/src/http/passwordAdmissionError.ts index 87cdb3429c..f111e938fd 100644 --- a/external-rest-api/src/http/passwordAdmissionError.ts +++ b/external-rest-api/src/http/passwordAdmissionError.ts @@ -5,6 +5,16 @@ import { ControlApiUnexpectedResponseError, } 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 +} + function sendPublicPasswordAuthorityError( res: Response, status: 429 | 503, @@ -50,6 +60,7 @@ export function sendPasswordAuthorityError(error: unknown, res: Response): boole return true } if (error.status === 503) { + if (isSessionIssuanceUnavailable(error)) return false const recoveryOutcomeUnknown = error.code === 'recovery_outcome_unknown' sendPublicPasswordAuthorityError( res, @@ -92,6 +103,10 @@ export function sendPasswordAuthorityError(error: unknown, res: Response): boole export const invitationPasswordErrorHandler: ErrorRequestHandler = (error, _req, res, next) => { if (sendPasswordAuthorityError(error, res)) return if (error instanceof ControlApiError) { + if (isSessionIssuanceUnavailable(error)) { + next(error) + return + } res.status(error.status).json({ error: 'Invitation is invalid or unavailable' }) return } diff --git a/external-rest-api/src/http/publicApiError.ts b/external-rest-api/src/http/publicApiError.ts new file mode 100644 index 0000000000..8064a1fcc4 --- /dev/null +++ b/external-rest-api/src/http/publicApiError.ts @@ -0,0 +1,34 @@ +import type { Request, Response } from 'express' +import { randomUUID } from 'node:crypto' + +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 + if (SAFE_PUBLIC_CORRELATION_ID.test(candidate)) return candidate + } + return randomUUID() +} + +export function publicCorrelationId(req: Request): string { + return selectPublicCorrelationId(req.header('x-correlation-id')) +} + +export function sendPublicApiError( + req: Request, + res: Response, + status: number, + code: string, + message: string, + retryable = false +): void { + res.status(status).json({ + error: { + code, + message, + correlationId: publicCorrelationId(req), + retryable, + }, + }) +} diff --git a/external-rest-api/src/middleware/auth.ts b/external-rest-api/src/middleware/auth.ts index 25d25b83bf..e1690e7f73 100644 --- a/external-rest-api/src/middleware/auth.ts +++ b/external-rest-api/src/middleware/auth.ts @@ -1,5 +1,6 @@ import { NextFunction, Request, Response } from 'express' import { verifyToken } from '../authToken.js' +import { sendPublicApiError } from '../http/publicApiError.js' import { PROFILE_SESSION_COOKIE, readCookie } from '../sessionCookie.js' import { AuthClaims } from '../types.js' @@ -15,16 +16,32 @@ export function extractAuthToken(req: Request): string { return readCookie(req, PROFILE_SESSION_COOKIE) } -export function requireAuth(req: AuthedRequest, res: Response, next: NextFunction): void { +/** The edge's own token check: claims for a present, bounded and verified token, else null. */ +export function authenticateRequest(req: Request): AuthClaims | null { const token = extractAuthToken(req) - if (!token || token.length > 4096) { + if (!token || token.length > 4096) return null + return verifyToken(token) +} + +export function requireAuth(req: AuthedRequest, res: Response, next: NextFunction): void { + const claims = authenticateRequest(req) + if (!claims) { res.status(401).json({ error: 'Unauthorized' }) return } + req.auth = claims + next() +} - const claims = verifyToken(token) +/** + * `requireAuth` for the session probe (`GET /me`): a missing, malformed, expired or + * badly signed token ends the session, so this edge answers it with a 401 and the + * `invalid_session` envelope. Clients decide by the status, not by the envelope. + */ +export function requireSessionAuth(req: AuthedRequest, res: Response, next: NextFunction): void { + const claims = authenticateRequest(req) if (!claims) { - res.status(401).json({ error: 'Unauthorized' }) + sendPublicApiError(req, res, 401, 'invalid_session', 'The session is not valid.') return } req.auth = claims diff --git a/external-rest-api/src/middleware/browserMutationGuard.ts b/external-rest-api/src/middleware/browserMutationGuard.ts new file mode 100644 index 0000000000..44a4710699 --- /dev/null +++ b/external-rest-api/src/middleware/browserMutationGuard.ts @@ -0,0 +1,59 @@ +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 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: publicCorrelationId(req), + retryable: false, + }, + }) + return + } + + next() +} diff --git a/external-rest-api/src/releaseManifest.ts b/external-rest-api/src/releaseManifest.ts index 85b50db53b..9481f604de 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.139', - rpcProxyVersion: '0.1.138', + externalRestApiVersion: '0.1.181', + rpcProxyVersion: '0.1.139', 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 24afa74c3c..351043abb2 100644 --- a/external-rest-api/src/routes/auth.ts +++ b/external-rest-api/src/routes/auth.ts @@ -1,17 +1,21 @@ import { Request, Response, Router } from 'express' -import { OAuth2Client } from 'google-auth-library' -import { config } from '../config.js' -import { ControlApiError } from '../controlApiClient.js' +import { ControlApiError, isControlApiInvalidSession } from '../controlApiClient.js' import { sendPasswordAuthorityError } from '../http/passwordAdmissionError.js' 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({ windowMs: 60_000, maxRequests: 5, @@ -27,10 +31,13 @@ function isControlApiStatus(error: unknown, status: number): error is ControlApi return error instanceof ControlApiError && error.status === status } -type GooglePayload = { - email: string - name?: string - picture?: string +// 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 = { @@ -49,6 +56,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) @@ -57,22 +68,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') - } - return { - email: payload.email.toLowerCase(), - name: payload.name, - picture: payload.picture, - } -} - export function createAuthRouter(): Router { const router = Router() @@ -84,8 +79,10 @@ export function createAuthRouter(): Router { return } - 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)) { @@ -96,6 +93,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) } }) @@ -108,7 +109,7 @@ export function createAuthRouter(): Router { res.status(400).json({ error: 'invalid_request' }) return } - const result = await loginWithPassword(email, password) + const result = await loginWithPassword(email, password, requestedSessionContract(req)) sendLoginResponse(req, res, result) } catch (error) { if (error instanceof ControlApiError && [401, 403, 409].includes(error.status)) { @@ -132,9 +133,55 @@ 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) { + // 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)) { + 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) + // 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) { + // Only control-api's `invalid_session` envelope proves the session is already + // gone; a bare 401 is a rejection of this edge's own service credential. + if (isControlApiInvalidSession(error)) { + clearProfileSessionCookie(req, res) + res.status(200).json({ ok: true, sessionPresented: true }) + return + } + clearProfileSessionCookie(req, res) + next(error) + } }) return router diff --git a/external-rest-api/src/routes/contextSharedFilesystems.ts b/external-rest-api/src/routes/contextSharedFilesystems.ts index 894df2f5ef..e9e7d6a5ae 100644 --- a/external-rest-api/src/routes/contextSharedFilesystems.ts +++ b/external-rest-api/src/routes/contextSharedFilesystems.ts @@ -1,6 +1,6 @@ import type { NextFunction, Response } from 'express' import { Router } from 'express' -import { ControlApiError, controlApiRequest, controlApiStreamRequest } from '../controlApiClient.js' +import { controlApiRequest, controlApiStreamRequest } from '../controlApiClient.js' import { type AuthedRequest, extractAuthToken, requireAuth } from '../middleware/auth.js' /** @@ -21,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', @@ -32,16 +31,6 @@ const PASSTHROUGH_HEADERS = [ 'x-request-id', ] 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) - return - } - next(error) -} - export function createContextSharedFilesystemsRouter(): Router { const router = Router() @@ -70,7 +59,7 @@ export function createContextSharedFilesystemsRouter(): Router { ) res.status(200).json(data) } catch (error) { - forwardControlApiError(error, res, next) + next(error) } } ) @@ -80,7 +69,15 @@ export function createContextSharedFilesystemsRouter(): Router { async (req: AuthedRequest, res, next) => { try { const sessionToken = extractAuthToken(req) - const subPath = req.params[0] ? `/${req.params[0]}` : '/' + // Express decodes the wildcard once. Re-encode each segment so a + // caller-supplied `?` or `#` stays data inside one segment instead of + // changing the pathname, while a real `/` keeps its hierarchy. + const wildcard = req.params[0] ?? '' + const encodedSubPath = wildcard + .split('/') + .map(segment => encodeURIComponent(segment)) + .join('/') + const subPath = wildcard ? `/${encodedSubPath}` : '/' const queryString = (() => { const idx = req.originalUrl.indexOf('?') return idx === -1 ? '' : req.originalUrl.slice(idx) @@ -88,8 +85,9 @@ export function createContextSharedFilesystemsRouter(): Router { const upstreamRes = await controlApiStreamRequest( req.method === 'HEAD' ? 'HEAD' : 'GET', `/external/contexts/${encodeURIComponent(req.params.contextId)}` + - `/shared-filesystems/${encodeURIComponent(req.params.sfsName)}/proxy${subPath}${queryString}`, + `/shared-filesystems/${encodeURIComponent(req.params.sfsName)}/proxy${subPath}`, { + rawQueryString: queryString, userSessionToken: sessionToken, throwOnHttpError: false, } @@ -100,6 +98,14 @@ export function createContextSharedFilesystemsRouter(): Router { const value = upstreamRes.headers.get(name) if (value) res.setHeader(name, value) } + // throwOnHttpError is off, so the generic relay of an outage's no-store + // (app.ts) never sees this response. + if ( + upstreamRes.status === 503 && + upstreamRes.headers.get('cache-control')?.trim().toLowerCase() === 'no-store' + ) { + res.setHeader('cache-control', 'no-store') + } const ct = upstreamRes.headers.get('content-type') if (ct) res.setHeader('content-type', ct) const cd = upstreamRes.headers.get('content-disposition') diff --git a/external-rest-api/src/routes/gfs.ts b/external-rest-api/src/routes/gfs.ts index 5afb575133..87cce6345c 100644 --- a/external-rest-api/src/routes/gfs.ts +++ b/external-rest-api/src/routes/gfs.ts @@ -2,11 +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 { ControlApiError, controlApiRequest, controlApiStreamRequest } from '../controlApiClient.js' +import { + ControlApiError, + ControlApiPathError, + controlApiRequest, + controlApiStreamRequest, +} from '../controlApiClient.js' import { type AuthedRequest, extractAuthToken, requireAuth } from '../middleware/auth.js' type GfsRouterOptions = { @@ -31,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', @@ -53,15 +68,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,6 +145,12 @@ function gfsControlApiRequest( } function forwardControlApiError(error: unknown, res: Response, next: NextFunction): void { + // 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)) @@ -151,7 +163,9 @@ function forwardControlApiError(error: unknown, res: Response, next: NextFunctio if (value) res.setHeader(name, value) } const body = - error.body && typeof error.body === 'object' ? error.body : { error: String(error.message) } + 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) diff --git a/external-rest-api/src/routes/invitations.ts b/external-rest-api/src/routes/invitations.ts index 02276bf3c8..c812daf291 100644 --- a/external-rest-api/src/routes/invitations.ts +++ b/external-rest-api/src/routes/invitations.ts @@ -14,38 +14,82 @@ import { getInvitationByToken, listPendingInvitations, } from '../services/invitationsService.js' -import { setProfileSessionCookie } from '../sessionCookie.js' +import { + PROFILE_SESSION_COOKIE, + clearProfileSessionCookie, + hasCookie, + readCookie, + setProfileSessionCookie, +} from '../sessionCookie.js' const tokenLookupRateLimit = createRateLimiter({ windowMs: 60_000, maxRequests: 30 }) const acceptRateLimit = createRateLimiter({ windowMs: 60_000, maxRequests: 10 }) +// The invitation password producer validates the trimmed value against the +// same inclusive bounds. The edge checks it before any acceptance so an invalid +// password cannot consume a still-pending invitation. +const INVITATION_PASSWORD_MIN_LENGTH = 8 +const INVITATION_PASSWORD_MAX_LENGTH = 256 + +function isValidInvitationPassword(password: string): boolean { + const trimmed = password.trim() + return ( + trimmed.length >= INVITATION_PASSWORD_MIN_LENGTH && + trimmed.length <= INVITATION_PASSWORD_MAX_LENGTH + ) +} + +function requestedSessionContract(req: Request): 'v2' | undefined { + return req.header('x-evenfire-session-contract') === 'v2' ? 'v2' : undefined +} + type InvitationPasswordAuth = { userId: string email: string sessionToken: string } -function sendAcceptInvitationError( - res: Response, - error: 'not_found' | 'forbidden' | 'not_pending' | 'expired' | 'invalid' -): void { - if (error === 'invalid') { - res.status(400).json({ error: 'Invalid invitation' }) - return - } - if (error === 'not_found') { - res.status(404).json({ error: 'Invitation not found' }) - return - } +type AcceptInvitationError = 'not_found' | 'forbidden' | 'not_pending' | 'expired' | 'invalid' + +function acceptInvitationErrorResponse(error: AcceptInvitationError): { + status: number + body: Record +} { + if (error === 'invalid') return { status: 400, body: { error: 'Invalid invitation' } } + if (error === 'not_found') return { status: 404, body: { error: 'Invitation not found' } } if (error === 'forbidden') { - res.status(403).json({ error: 'Invitation email does not match authenticated user' }) - return - } - if (error === 'expired') { - res.status(410).json({ error: 'Invitation has expired' }) - return + return { status: 403, body: { error: 'Invitation email does not match authenticated user' } } } - res.status(400).json({ error: 'Invitation is not pending' }) + if (error === 'expired') return { status: 410, body: { error: 'Invitation has expired' } } + 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) } function sendInvitationPasswordError( @@ -58,13 +102,14 @@ function sendInvitationPasswordError( | 'expired' | 'invalid_invitation' | 'invalid_password' + | 'password_already_set' ): void { if (error === 'invalid_invitation') { res.status(400).json({ error: 'invalid_invitation' }) 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') { @@ -75,12 +120,20 @@ 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).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' }) @@ -129,6 +182,15 @@ function sendPasswordResetError( res.status(response.status).json({ error: response.code, message: response.message }) } +// The session token a request presents. A present Authorization header must +// carry a bearer credential; any other value yields '' and fails verification +// instead of yielding to the cookie. +function presentedSessionToken(authorization: string | undefined, req: Request): string { + if (authorization === undefined) return readCookie(req, PROFILE_SESSION_COOKIE) + const raw = authorization.trim() + return /^bearer\s+/i.test(raw) ? raw.replace(/^bearer\s+/i, '').trim() : '' +} + async function invitationPasswordAuthFromRequest(req: Request): Promise< | { auth: InvitationPasswordAuth } | { @@ -136,12 +198,23 @@ async function invitationPasswordAuthFromRequest(req: Request): Promise< body: Record } > { - 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. + // Presence is decided before extraction, because extractAuthToken returns '' + // for an empty or malformed credential and '' must not read as "absent". + const authorization = req.headers.authorization + if (authorization !== undefined || hasCookie(req, PROFILE_SESSION_COOKIE)) { + const sessionToken = presentedSessionToken(authorization, req) + // 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 { 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() @@ -152,19 +225,25 @@ async function invitationPasswordAuthFromRequest(req: Request): Promise< return { status: 401, body: { error: 'Unauthorized' } } } - const accepted = await acceptInvitation(token, email) + // Validate before this fallback accepts the invitation: that acceptance is a + // one-time consumption, so an invalid password must be refused while the link + // is still pending. A presented session never reaches this branch, so its + // 401 precedence is unchanged. + const password = String(req.body?.password || '') + if (!isValidInvitationPassword(password)) { + return { status: 400, body: { error: 'Password must be between 8 and 256 characters' } } + } + + const accepted = await acceptInvitation(token, email, requestedSessionContract(req)) 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' } } + 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. + if (accepted.data?.replay) { + return fallbackAcceptErrorResponse('not_pending') } if (!accepted.data?.token || !accepted.data.userId) { return { status: 502, body: { error: 'Failed to start invited session' } } @@ -192,21 +271,21 @@ 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) { sendError(res, result.error) return } - const sessionToken = result.data?.token - if (!result.data || !sessionToken) { + if (!result.data) { res.status(502).json({ error: 'Failed to restore account session' }) return } - const { token: _sessionToken, ...body } = result.data - setProfileSessionCookie(req, res, sessionToken) - res.status(200).json(body) + clearProfileSessionCookie(req, res) + res.status(200).json({ ...result.data, reauthenticationRequired: true }) } export function createInvitationsRouter(): Router { @@ -281,8 +360,8 @@ export function createInvitationsRouter(): Router { 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) { if (sendPasswordAuthorityError(error, res)) return next(error) @@ -371,13 +450,25 @@ 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 } - setProfileSessionCookie(req, res, result.data!.token) - const { token: _token, ...data } = result.data! + const accepted = result.data + if (!accepted) { + throw new Error('control-api accepted an invitation without a response payload') + } + if (accepted.replay) { + const { token: _token, ...data } = accepted + res.status(200).json(data) + return + } + if (!accepted.token) { + throw new Error('control-api accepted an invitation without issuing a session') + } + setProfileSessionCookie(req, res, accepted.token) + const { token: _token, ...data } = accepted res.status(200).json(data) } catch (error) { next(error) diff --git a/external-rest-api/src/routes/me.ts b/external-rest-api/src/routes/me.ts index 246c22937e..8c54f90185 100644 --- a/external-rest-api/src/routes/me.ts +++ b/external-rest-api/src/routes/me.ts @@ -6,6 +6,7 @@ import { extractAuthToken, requireAuth, requireSelf, + requireSessionAuth, } from '../middleware/auth.js' import { createRateLimiter } from '../middleware/rateLimit.js' import { @@ -18,6 +19,7 @@ import { updatePassword, updateProfile, } from '../services/meService.js' +import { setProfileSessionCookie } from '../sessionCookie.js' import { PASSWORD_CREDENTIAL_CHANGED_PUBLIC_RESPONSE } from './mePasswordErrors.js' const teamDirectoryRateLimit = createRateLimiter({ @@ -72,13 +74,18 @@ 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) } }) - router.get('/me', requireAuth, async (req: AuthedRequest, res, next) => { + // The session probe: an ended session leaves this route as a 401 with the + // `invalid_session` envelope, from this edge's token check or forwarded from + // control-api. Clients decide by the status, not by the envelope. + router.get('/me', requireSessionAuth, async (req: AuthedRequest, res, next) => { try { const auth = req.auth! const sessionToken = extractAuthToken(req) diff --git a/external-rest-api/src/routes/members.ts b/external-rest-api/src/routes/members.ts index 3de5c57621..398f834fc8 100644 --- a/external-rest-api/src/routes/members.ts +++ b/external-rest-api/src/routes/members.ts @@ -17,6 +17,7 @@ import { } from '../services/memberManagementService.js' 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 @@ -150,9 +151,14 @@ export function createMembersRouter(): Router { router.post('/members/invite', requireAuth, async (req: AuthedRequest, res, next) => { try { - const email = String(req.body?.email || '') - .trim() - .toLowerCase() + // 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() const name = String(req.body?.name || '').trim() const teams: unknown[] = Array.isArray(req.body?.teams) ? req.body.teams : [] if (!EMAIL_PATTERN.test(email)) { diff --git a/external-rest-api/src/routes/notifications.ts b/external-rest-api/src/routes/notifications.ts index 5fe4d29c03..2c9ff39df3 100644 --- a/external-rest-api/src/routes/notifications.ts +++ b/external-rest-api/src/routes/notifications.ts @@ -1,20 +1,8 @@ import type { NextFunction, Response } from 'express' import { Router } from 'express' -import { ControlApiError, controlApiStreamRequest } from '../controlApiClient.js' +import { controlApiStreamRequest } from '../controlApiClient.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) - return - } - next(error) -} - function setStreamHeaders(res: Response): void { res.status(200) res.setHeader('content-type', 'application/x-ndjson; charset=utf-8') @@ -66,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) @@ -85,7 +73,7 @@ export function createNotificationsRouter(): Router { ) res.status(200).json(result) } catch (error) { - forwardControlApiError(error, res, next) + next(error) } }) @@ -98,7 +86,7 @@ export function createNotificationsRouter(): Router { }) res.status(200).json(result) } catch (error) { - forwardControlApiError(error, res, next) + next(error) } }) @@ -112,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 bed64c9b42..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 { ControlApiError } from '../controlApiClient.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,16 +27,6 @@ function toGrantView(g: OauthGrantSummary): OauthGrantSummary { return view } -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) - return - } - next(error) -} - export function createOauthGrantsRouter(): Router { const router = Router() @@ -48,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) } }) @@ -68,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 e05f72266c..4b2f30ed84 100644 --- a/external-rest-api/src/routes/rpc.ts +++ b/external-rest-api/src/routes/rpc.ts @@ -19,7 +19,6 @@ export function createRpcRouter(): Router { 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/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..934fe74c63 100644 --- a/external-rest-api/src/routes/userApprovalDecisions.ts +++ b/external-rest-api/src/routes/userApprovalDecisions.ts @@ -1,24 +1,11 @@ import type { NextFunction, Response } from 'express' import { Router } from 'express' -import { ControlApiError } from '../controlApiClient.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 { - 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) - return - } - next(error) -} - export function createUserApprovalDecisionsRouter(): Router { const router = Router() @@ -33,7 +20,7 @@ export function createUserApprovalDecisionsRouter(): Router { } res.status(200).json(await listPendingUserApprovalDecisions(sessionToken, parsedLimit)) } catch (error) { - forwardControlApiError(error, res, next) + next(error) } }) @@ -63,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 3865e17ed1..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 { ControlApiError } from '../controlApiClient.js' import { type AuthedRequest, extractAuthToken, requireAuth } from '../middleware/auth.js' import { confirmWorkflowApprovalMediumChallenge, @@ -13,21 +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 { - 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) - return - } - next(error) -} - function optionalString(value: unknown): string | null { if (value === undefined || value === null) return null const normalized = String(value).trim() @@ -69,7 +57,7 @@ export function createWorkflowApprovalMediumsRouter(): Router { }) res.status(202).json(result) } catch (error) { - forwardControlApiError(error, res, next) + next(error) } } ) @@ -119,7 +107,7 @@ export function createWorkflowApprovalMediumsRouter(): Router { }) res.status(202).json(result) } catch (error) { - forwardControlApiError(error, res, next) + next(error) } } ) @@ -146,7 +134,7 @@ export function createWorkflowApprovalMediumsRouter(): Router { .status(200) .json(await confirmWorkflowApprovalMediumChallenge(sessionToken, challengeId, code)) } catch (error) { - forwardControlApiError(error, res, next) + next(error) } } ) @@ -158,7 +146,7 @@ export function createWorkflowApprovalMediumsRouter(): Router { .status(200) .json(await listWorkflowApprovalMediums(extractAuthToken(req), { includeDisabled })) } catch (error) { - forwardControlApiError(error, res, next) + next(error) } }) @@ -169,7 +157,7 @@ export function createWorkflowApprovalMediumsRouter(): Router { try { res.status(200).json(await listApprovalChannelTargets(extractAuthToken(req))) } catch (error) { - forwardControlApiError(error, res, next) + next(error) } } ) @@ -186,7 +174,7 @@ export function createWorkflowApprovalMediumsRouter(): Router { } res.status(200).json(await preferWorkflowApprovalMedium(extractAuthToken(req), accountId)) } catch (error) { - forwardControlApiError(error, res, next) + next(error) } } ) @@ -221,7 +209,7 @@ export function createWorkflowApprovalMediumsRouter(): Router { res.status(400).json({ error: error.message }) return } - forwardControlApiError(error, res, next) + next(error) } } ) @@ -239,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 bfeb97d88f..e2a7fd7a34 100644 --- a/external-rest-api/src/routes/workflows.ts +++ b/external-rest-api/src/routes/workflows.ts @@ -1,25 +1,12 @@ import type { NextFunction, Response } from 'express' import { Router } from 'express' import { - ControlApiError, controlApiBinaryRequestWithStatus, controlApiRequest, controlApiRequestWithStatus, } from '../controlApiClient.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) - return - } - next(error) -} - export function createExternalWorkflowsRouter(): Router { const router = Router() @@ -34,7 +21,7 @@ export function createExternalWorkflowsRouter(): Router { }) res.status(200).json(result) } catch (error) { - forwardControlApiError(error, res, next) + next(error) } } ) @@ -60,7 +47,7 @@ export function createExternalWorkflowsRouter(): Router { ) res.status(200).json(result) } catch (error) { - forwardControlApiError(error, res, next) + next(error) } } ) @@ -86,7 +73,7 @@ export function createExternalWorkflowsRouter(): Router { ) res.status(200).json(result) } catch (error) { - forwardControlApiError(error, res, next) + next(error) } } ) @@ -119,7 +106,7 @@ export function createExternalWorkflowsRouter(): Router { ) res.status(status).json(data) } catch (error) { - forwardControlApiError(error, res, next) + next(error) } } ) @@ -148,7 +135,7 @@ export function createExternalWorkflowsRouter(): Router { ) res.status(200).json(result) } catch (error) { - forwardControlApiError(error, res, next) + next(error) } } ) @@ -175,7 +162,7 @@ export function createExternalWorkflowsRouter(): Router { ) res.status(200).json(result) } catch (error) { - forwardControlApiError(error, res, next) + next(error) } } ) @@ -207,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/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/invitationPasswordService.ts b/external-rest-api/src/services/invitationPasswordService.ts index 73987faf9f..3d894adf4e 100644 --- a/external-rest-api/src/services/invitationPasswordService.ts +++ b/external-rest-api/src/services/invitationPasswordService.ts @@ -13,7 +13,12 @@ type AuthContext = { } type ResetPasswordDomainError = - 'not_found' | 'forbidden' | 'not_pending' | 'expired' | 'invalid_invitation' | 'invalid_password' + | 'not_found' + | 'forbidden' + | 'not_pending' + | 'expired' + | 'invalid_invitation' + | 'invalid_password' function invitationPasswordDomainError( error: ControlApiError @@ -24,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' @@ -32,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 } @@ -62,6 +69,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', @@ -77,7 +100,13 @@ export async function setupInvitationPassword( password: string ): Promise<{ error?: - 'not_found' | 'forbidden' | 'not_accepted' | 'not_pending' | 'expired' | 'invalid_password' + | 'not_found' + | 'forbidden' + | 'not_accepted' + | 'not_pending' + | 'expired' + | 'invalid_password' + | 'password_already_set' data?: InvitationPreview & { passwordUpdated: boolean } }> { try { @@ -94,7 +123,7 @@ export async function setupInvitationPassword( userSessionToken: auth.sessionToken, } ) - return { data } + return { data: withoutSessionResponseFields(data) } } catch (error) { if (error instanceof ControlApiTransportError) throw passwordAuthorityUnavailable() if ( @@ -130,15 +159,22 @@ export async function setupInvitationPasswordWithToken( | 'expired' | 'invalid_invitation' | 'invalid_password' - data?: InvitationPreview & { passwordUpdated: boolean; token: string } + data?: InvitationPreview & { passwordUpdated: boolean; reauthenticationRequired: true } }> { try { const data = await controlApiRequest< - InvitationPreview & { passwordUpdated: boolean; token: string } + InvitationPreview & { + passwordUpdated: boolean + reauthenticationRequired?: boolean + token?: string + sessionContext?: unknown + } >('POST', '/external/invitations/password-token', { body: { email, token, invitationId, password }, }) - return { data } + 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/src/services/invitationsService.ts b/external-rest-api/src/services/invitationsService.ts index 7c91888070..d70c01c2ce 100644 --- a/external-rest-api/src/services/invitationsService.ts +++ b/external-rest-api/src/services/invitationsService.ts @@ -44,7 +44,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?: { @@ -55,7 +56,11 @@ export async function acceptInvitation( role: string email: string userId: string - token: string + // A replay of an already accepted invitation is an explicit success without + // a session: control-api does not mint one for a token that no longer + // proves a pending capability. + replay?: true + token?: string } }> { try { @@ -67,20 +72,28 @@ export async function acceptInvitation( role: string email: string userId: string - token: string + replay?: true + token?: string }>('POST', '/external/invitations/accept', { 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' } + // 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 414c0a61a3..426594fe44 100644 --- a/external-rest-api/src/services/meService.ts +++ b/external-rest-api/src/services/meService.ts @@ -1,4 +1,8 @@ -import { controlApiRequest } from '../controlApiClient.js' +import { + ControlApiError, + controlApiRequest, + isControlApiInvalidSession, +} from '../controlApiClient.js' import { ChannelMapping, TeamDirectoryResult, TeamRole, UserAgentsResponse } from '../types.js' export function normalizeChannels(input: unknown): ChannelMapping { @@ -79,10 +83,16 @@ export async function switchTeam( try { membership = await controlApiRequest<{ team_id: string; role: TeamRole; team_name: string }>( 'GET', - `/external/users/${userId}/memberships/${nextTeamId}`, + `/external/users/${userId}/memberships/${encodeURIComponent(nextTeamId)}`, { userSessionToken: sessionToken } ) - } catch { + } catch (error) { + // control-api answers an ended session on this route with a bare 401 + // (requireValidExternalSessionToken), the same body its service-credential guard + // sends; neither is "not a member". Both reach the client as that 401, a 503 as is. + if (error instanceof ControlApiError && (error.status === 401 || error.status === 503)) { + throw error + } return null } @@ -97,9 +107,9 @@ export async function switchTeam( teamId: membership.team_id, role, }, + userSessionToken: sessionToken, } ) - return { token, team: { @@ -129,7 +139,18 @@ export async function getMe(userId: string, teamId: string, sessionToken: string channels: normalizeChannels(result.profile.channels), }, } - } catch { + } catch (error) { + // Exceptions to the dev error mapping: + // - control-api's `invalid_session` envelope: the session was revoked (password + // change, logout, revocation) and must reach the client as that 401, so it + // re-authenticates instead of reading "not found"; + // - a 503 outage is forwarded as is. + // Everything else, a bare 401 included, reads as "not found" as on dev. A bare + // 401 is dev control-api's answer for an ended session; this edge's control-api + // sends a bare 401 only when it refuses the edge's own service credential, and + // no header or body tells the two apart, so neither is answered as an outage. + if (isControlApiInvalidSession(error)) throw error + if (error instanceof ControlApiError && error.status === 503) throw error return null } } diff --git a/external-rest-api/src/services/memberManagementService.ts b/external-rest-api/src/services/memberManagementService.ts index 3db3143b35..2ec0e17338 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', @@ -59,9 +54,13 @@ export async function listManagedMembers(sessionToken: string) { } export async function getManagedMember(userId: string, sessionToken: string) { - return controlApiRequest('GET', `/external/members/${userId}`, { - userSessionToken: sessionToken, - }) + return controlApiRequest( + 'GET', + `/external/members/${encodeURIComponent(userId)}`, + { + userSessionToken: sessionToken, + } + ) } export async function listManagedInvitations(sessionToken: string) { @@ -76,8 +75,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', { @@ -96,16 +95,24 @@ export async function updateManagedMemberRole( role: TeamRole, sessionToken: string ) { - return controlApiRequest('PATCH', `/external/members/${userId}/teams/${teamId}/role`, { - body: { role }, - userSessionToken: sessionToken, - }) + return controlApiRequest( + 'PATCH', + `/external/members/${encodeURIComponent(userId)}/teams/${encodeURIComponent(teamId)}/role`, + { + body: { role }, + userSessionToken: sessionToken, + } + ) } export async function deleteManagedMember(userId: string, teamId: string, sessionToken: string) { - return controlApiRequest('DELETE', `/external/members/${userId}/teams/${teamId}`, { - userSessionToken: sessionToken, - }) + return controlApiRequest( + 'DELETE', + `/external/members/${encodeURIComponent(userId)}/teams/${encodeURIComponent(teamId)}`, + { + userSessionToken: sessionToken, + } + ) } export type ManagedUserRetirementRequest = { @@ -132,7 +139,7 @@ export async function deleteManagedUser( export async function resendManagedInvitation(invitationId: string, sessionToken: string) { return controlApiRequest( 'POST', - `/external/members/invitations/${invitationId}/resend`, + `/external/members/invitations/${encodeURIComponent(invitationId)}/resend`, { userSessionToken: sessionToken, } @@ -140,7 +147,11 @@ export async function resendManagedInvitation(invitationId: string, sessionToken } export async function cancelManagedInvitation(invitationId: string, sessionToken: string) { - return controlApiRequest('DELETE', `/external/members/invitations/${invitationId}`, { - userSessionToken: sessionToken, - }) + return controlApiRequest( + 'DELETE', + `/external/members/invitations/${encodeURIComponent(invitationId)}`, + { + userSessionToken: sessionToken, + } + ) } diff --git a/external-rest-api/src/services/teamService.ts b/external-rest-api/src/services/teamService.ts index 01b0232c34..ccea3956ea 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,7 +28,13 @@ export async function getCurrentTeam(auth: AuthContext) { `/external/teams/${auth.teamId}/users/${auth.userId}/current`, { userSessionToken: auth.sessionToken } ) - } catch { + } catch (error) { + // control-api answers an ended session on `/external/teams/*` with a bare 401, which + // reaches the client as that 401, like a 503. A 403 means the caller is no longer a + // live member of the token's team, so "no current team" is the answer. + if (error instanceof ControlApiError && (error.status === 401 || error.status === 503)) { + throw error + } return null } } @@ -55,6 +61,7 @@ export async function createTeamForUser( teamId: team.id, role: 'admin', }, + userSessionToken: auth.sessionToken, } ) @@ -86,7 +93,14 @@ export async function renameTeam( userSessionToken: auth.sessionToken, } ) - } catch { + } catch (error) { + if (error instanceof ControlApiError && (error.status === 401 || error.status === 503)) { + throw error + } + // control-api's live role check: the caller lost the admin role after the token was issued. + if (error instanceof ControlApiError && error.status === 403) { + return { error: 'forbidden' as const } + } return { error: 'not_found' as const } } @@ -136,19 +150,24 @@ 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/${targetUserId}/role`, + `/external/teams/${auth.teamId}/members/${encodeURIComponent(targetUserId)}/role`, { userSessionToken: auth.sessionToken } ) - } catch { + } catch (error) { + if (error instanceof ControlApiError && (error.status === 401 || error.status === 503)) { + throw error + } + // control-api's live role check: the caller lost the admin role after the token was issued. + if (error instanceof ControlApiError && error.status === 403) return { error: 'forbidden' } return { error: 'not_found' } } const updated = await controlApiRequest( 'PATCH', - `/external/teams/${auth.teamId}/members/${targetUserId}/role`, + `/external/teams/${auth.teamId}/members/${encodeURIComponent(targetUserId)}/role`, { body: { role: newRole, @@ -190,12 +209,19 @@ export async function deleteMember(auth: AuthContext, userId: string) { try { deleted = await controlApiRequest( 'DELETE', - `/external/teams/${auth.teamId}/members/${userId}`, + `/external/teams/${auth.teamId}/members/${encodeURIComponent(userId)}`, { userSessionToken: auth.sessionToken, } ) - } catch { + } catch (error) { + if (error instanceof ControlApiError && (error.status === 401 || error.status === 503)) { + throw error + } + // control-api's live role check: the caller lost the admin role after the token was issued. + if (error instanceof ControlApiError && error.status === 403) { + return { error: 'forbidden' as const } + } return { error: 'not_found' as const } } return { deleted } diff --git a/external-rest-api/src/sessionCookie.ts b/external-rest-api/src/sessionCookie.ts index a7f216eb5a..c213405d40 100644 --- a/external-rest-api/src/sessionCookie.ts +++ b/external-rest-api/src/sessionCookie.ts @@ -25,6 +25,12 @@ export function readCookie(req: Request, name: string): string { return '' } +// True when the Cookie header carries an entry with this name, even an empty one. +export function hasCookie(req: Request, name: string): boolean { + const raw = String(req.header('cookie') || '') + return raw.split(';').some(part => part.trim().split('=')[0] === name) +} + export function setProfileSessionCookie(req: Request, res: Response, token: string): void { res.cookie(PROFILE_SESSION_COOKIE, token, { httpOnly: true, 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/app.test.ts b/external-rest-api/test/app.test.ts index 34564057dd..861dc98bec 100644 --- a/external-rest-api/test/app.test.ts +++ b/external-rest-api/test/app.test.ts @@ -1,7 +1,8 @@ -import { describe, it } from 'vitest' +import { describe, expect, it } from 'vitest' import request from 'supertest' import { createApp } from '../src/app.js' import { config } from '../src/config.js' +import { PROFILE_SESSION_COOKIE } from '../src/sessionCookie.js' describe('external-rest-api app wiring', () => { it('accepts JSON bodies above the former 1 MiB parser limit', async () => { @@ -34,4 +35,42 @@ describe('external-rest-api app wiring', () => { // parser error here would prove the binary bypass regressed. .expect(401) }) + + it('mounts the browser-mutation guard on the real app', async () => { + const previousCorsOrigin = config.corsOrigin + config.corsOrigin = ['https://profile.example'] + try { + const app = createApp() + const sessionCookie = `${PROFILE_SESSION_COOKIE}=x` + + // Witness: a trusted-origin mutation passes the guard and reaches routing. + const trusted = await request(app) + .post('/does-not-exist') + .set('origin', 'https://profile.example') + .set('cookie', sessionCookie) + expect(trusted.status).toBe(404) + expect(trusted.body).toEqual({ error: 'Not Found' }) + + // The unrouted path comes first: only the guard can answer it with 403 (without the + // guard it gets the witness's 404), so a missing guard fails here before the logout + // handler, which calls control-api, can run. + for (const path of ['/does-not-exist', '/api/v1/auth/logout']) { + const untrustedOrigin = await request(app) + .post(path) + .set('origin', 'https://evil.example') + .set('cookie', sessionCookie) + expect(untrustedOrigin.status).toBe(403) + expect(untrustedOrigin.body).toMatchObject({ error: { code: 'forbidden' } }) + + const crossSite = await request(app) + .post(path) + .set('sec-fetch-site', 'cross-site') + .set('cookie', sessionCookie) + expect(crossSite.status).toBe(403) + expect(crossSite.body).toMatchObject({ error: { code: 'forbidden' } }) + } + } finally { + config.corsOrigin = previousCorsOrigin + } + }) }) diff --git a/external-rest-api/test/controlApiClient.pathGuard.test.ts b/external-rest-api/test/controlApiClient.pathGuard.test.ts new file mode 100644 index 0000000000..8c67236670 --- /dev/null +++ b/external-rest-api/test/controlApiClient.pathGuard.test.ts @@ -0,0 +1,233 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + ControlApiError, + ControlApiPathError, + assertControlApiPathAllowed, + assertControlApiRawQueryAllowed, + controlApiBinaryRequestWithStatus, + controlApiPassthroughGet, + controlApiRequest, + controlApiRequestWithStatus, + controlApiStreamRequest, +} from '../src/controlApiClient.js' + +// A `?` in the path is rejected: Express decodes `%3f` in a route param before +// the call site interpolates it, and URL construction would otherwise truncate +// the suffix and select a different control-api handler. The SharedFileSystem +// relay's opaque query travels through `rawQueryString` instead. +const REJECTED_PATHS = [ + ['raw query delimiter', '/external/members/invitations?/resend'], + ['raw query truncation', '/external/members/U?/teams/T'], + [ + 'raw SFS wildcard question mark', + '/external/contexts/c/shared-filesystems/s/proxy/files/a?b.txt', + ], + ['raw dot-dot segment', '/external/members/invitations/../../access/resolve?/resend'], + ['encoded dot-dot segments', '/external/members/invitations/%2e%2e/%2e%2e/access/resolve'], + ['mixed-case encoded dot-dot segments', '/external/members/invitations/%2E./%2e./access/resolve'], + ['single dot segment', '/external/contexts/./shared-filesystems'], + ['encoded single dot segment', '/external/contexts/%2e/shared-filesystems'], + [ + 'encoded separator dot-dot segments', + '/external/contexts/c/shared-filesystems/s/proxy/..%2f..%2f..%2f..%2f..%2faccess%2fresolve', + ], + [ + 'encoded separator capability alias', + '/external/contexts/c/shared-filesystems/s/proxy/..%2faccess%2fcapabilities', + ], + ['fragment delimiter', '/external/access/capabilities#/../access/resolve'], + ['fragment suffix', '/external/members/invitations/id/resend#/../../access/resolve'], +] as const + +const ACCEPTED_PATHS = [ + ['member invitations', '/external/members/invitations'], + [ + 'invitation resend', + '/external/members/invitations/7f9c2bce-1a2b-4c3d-8e4f-0123456789ab/resend', + ], + [ + 'shared filesystem relay file', + '/external/contexts/ctx-a/shared-filesystems/team-mission/proxy/files/report.txt', + ], +] as const + +const REJECTED_RAW_QUERIES = [ + ['missing leading question mark', 'download=1'], + ['fragment delimiter', '?download=1#fragment'], + ['control character', '?download=\u0001'], +] as const + +const ACCEPTED_RAW_QUERIES = [ + ['empty', ''], + ['product-shaped download', '?path=notes%2Fplan.md'], +] as const + +const REJECTED_PATH = '/external/members/invitations/../../access/resolve?/resend' +const ACCEPTED_PATH = '/external/members/invitations' + +function jsonResponse(body: unknown): Response { + return new Response(JSON.stringify(body), { + status: 200, + headers: { 'content-type': 'application/json' }, + }) +} + +function stubFetch(): ReturnType { + // A Response body can be consumed once, so each call needs a fresh response. + const fetchMock = vi + .fn() + .mockImplementation(() => Promise.resolve(jsonResponse({ served: true }))) + vi.stubGlobal('fetch', fetchMock) + return fetchMock +} + +function fetchedUrl(fetchMock: ReturnType, callIndex: number): URL { + const [url] = fetchMock.mock.calls[callIndex] as [string, RequestInit] + return new URL(url) +} + +describe('control-api path guard', () => { + it('rejects every relocation form including a raw question mark while accepting ordinary control-api paths in the same test', () => { + for (const [name, path] of REJECTED_PATHS) { + expect(() => assertControlApiPathAllowed(path), name).toThrow(ControlApiPathError) + } + for (const [name, path] of ACCEPTED_PATHS) { + expect(() => assertControlApiPathAllowed(path), name).not.toThrow() + } + }) + + it('classifies a rejected path as the existing invalid-request client error', () => { + expect.assertions(4) + try { + assertControlApiPathAllowed(REJECTED_PATH) + } catch (error) { + expect(error).toBeInstanceOf(ControlApiPathError) + expect(error).toBeInstanceOf(ControlApiError) + expect((error as ControlApiError).status).toBe(400) + expect((error as ControlApiError).code).toBe('invalid_request') + } + }) + + it('accepts only an empty or leading-question-mark raw query without fragments or control characters', () => { + for (const [name, rawQueryString] of ACCEPTED_RAW_QUERIES) { + expect(() => assertControlApiRawQueryAllowed(rawQueryString), name).not.toThrow() + } + for (const [name, rawQueryString] of REJECTED_RAW_QUERIES) { + expect(() => assertControlApiRawQueryAllowed(rawQueryString), name).toThrow( + ControlApiPathError + ) + } + }) + + it('fails loudly before fetch when raw and structured queries are both supplied', async () => { + const fetchMock = stubFetch() + + await expect( + controlApiStreamRequest( + 'GET', + '/external/contexts/ctx-a/shared-filesystems/team-mission/proxy/files', + { query: { download: '1' }, rawQueryString: '?download=1' } + ) + ).rejects.toThrow('mutually exclusive') + expect(fetchMock).not.toHaveBeenCalled() + }) + + it('fails closed in the JSON client before fetch, then fetches accepted paths', async () => { + const fetchMock = stubFetch() + + await expect(controlApiRequest('POST', REJECTED_PATH)).rejects.toBeInstanceOf( + ControlApiPathError + ) + await expect(controlApiRequestWithStatus('POST', REJECTED_PATH)).rejects.toBeInstanceOf( + ControlApiPathError + ) + expect(fetchMock).not.toHaveBeenCalled() + + // Same-test positive witness for each JSON entry point. + await expect(controlApiRequestWithStatus('GET', ACCEPTED_PATH)).resolves.toEqual({ + data: { served: true }, + status: 200, + }) + 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/members/invitations') + } + }) + + it('fails closed in the binary client before fetch, then fetches an accepted path', async () => { + const fetchMock = vi.fn().mockResolvedValue( + new Response(Buffer.from('binary-body'), { + status: 200, + headers: { 'content-type': 'application/octet-stream' }, + }) + ) + vi.stubGlobal('fetch', fetchMock) + + await expect(controlApiBinaryRequestWithStatus('GET', REJECTED_PATH)).rejects.toBeInstanceOf( + ControlApiPathError + ) + expect(fetchMock).not.toHaveBeenCalled() + + 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/members/invitations') + }) + + it('fails closed in the stream client before fetch, then forwards a raw query unchanged', async () => { + const fetchMock = vi.fn().mockResolvedValue(new Response('stream-body', { status: 200 })) + vi.stubGlobal('fetch', fetchMock) + + await expect(controlApiStreamRequest('GET', REJECTED_PATH)).rejects.toBeInstanceOf( + ControlApiPathError + ) + expect(fetchMock).not.toHaveBeenCalled() + + const response = await controlApiStreamRequest( + 'GET', + '/external/contexts/ctx-a/shared-filesystems/team-mission/proxy/v1/files/download', + { rawQueryString: '?path=notes%2Fplan.md' } + ) + expect(await response.text()).toBe('stream-body') + expect(fetchMock).toHaveBeenCalledTimes(1) + const upstream = fetchedUrl(fetchMock, 0) + expect(upstream.pathname).toBe( + '/api/v1/external/contexts/ctx-a/shared-filesystems/team-mission/proxy/v1/files/download' + ) + expect(upstream.search).toBe('?path=notes%2Fplan.md') + }) + + it('fails closed in the passthrough client before fetch, then fetches an accepted path', async () => { + const fetchMock = vi.fn().mockResolvedValue( + new Response('callback', { + status: 200, + headers: { 'content-type': 'text/html; charset=utf-8' }, + }) + ) + vi.stubGlobal('fetch', fetchMock) + + await expect(controlApiPassthroughGet(REJECTED_PATH, '?state=proof')).rejects.toBeInstanceOf( + ControlApiPathError + ) + await expect( + controlApiPassthroughGet('/oauth-callback/google-gmail', '?state=proof#fragment') + ).rejects.toBeInstanceOf(ControlApiPathError) + expect(fetchMock).not.toHaveBeenCalled() + + const result = await controlApiPassthroughGet('/oauth-callback/google-gmail', '?state=proof') + expect(result).toEqual({ + status: 200, + contentType: 'text/html; charset=utf-8', + body: 'callback', + }) + expect(fetchMock).toHaveBeenCalledTimes(1) + const upstream = fetchedUrl(fetchMock, 0) + expect(upstream.pathname).toBe('/api/v1/oauth-callback/google-gmail') + expect(upstream.search).toBe('?state=proof') + }) + + afterEach(() => { + vi.unstubAllGlobals() + }) +}) diff --git a/external-rest-api/test/globalPublicErrorHandler.test.ts b/external-rest-api/test/globalPublicErrorHandler.test.ts new file mode 100644 index 0000000000..5797b3625b --- /dev/null +++ b/external-rest-api/test/globalPublicErrorHandler.test.ts @@ -0,0 +1,212 @@ +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 { ControlApiError, ControlApiPathError, controlApiRequest } from '../src/controlApiClient.js' + +type ProducerResponse = { + status: number + body: unknown + 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() + // 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') + res.status(204).end() + } catch (error) { + 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 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', () => { + 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(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(body) + expect(JSON.stringify(response.body)).not.toContain('Control API') + } + ) + + it('keeps the 503 outage headers only when control-api sent them', async () => { + producer.respondWith({ + 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) + + // 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: '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/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/invitationPasswordFallback.chain.test.ts b/external-rest-api/test/invitationPasswordFallback.chain.test.ts new file mode 100644 index 0000000000..a68054e61a --- /dev/null +++ b/external-rest-api/test/invitationPasswordFallback.chain.test.ts @@ -0,0 +1,168 @@ +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' +const witnessToken = 'synthetic-witness-invitation-proof' +const witnessSession = 'synthetic-witness-session-token' + +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') { + 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 + } + 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 + } + + // 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, + 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, + }, + }) + await expectWitnessSessionCookie(requests) + + 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' } }) + await expectWitnessSessionCookie(requests) + + 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/external-rest-api/test/invitationPasswordPublicErrors.profileUiContract.test.ts b/external-rest-api/test/invitationPasswordPublicErrors.profileUiContract.test.ts new file mode 100644 index 0000000000..c7a6eea768 --- /dev/null +++ b/external-rest-api/test/invitationPasswordPublicErrors.profileUiContract.test.ts @@ -0,0 +1,177 @@ +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' + auth?: 'session' + serviceError?: string + memberServiceError?: string + acceptError?: string + status: number + body: Record + componentPreview: 'reset' | 'accepted_member' + terminal: boolean + uiMessage: string +} + +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(), +})) + +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 +} + +// 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: CONTRACT_EMAIL, + purpose: entry.previewPurpose, + status: 'pending', + }) + 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' + 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', + }) +} + +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) => { + 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(observedSetCookie(response)).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/external-rest-api/test/invitations.passwordRecovery.public.test.ts b/external-rest-api/test/invitations.passwordRecovery.public.test.ts index bdb29d78b8..40651a0312 100644 --- a/external-rest-api/test/invitations.passwordRecovery.public.test.ts +++ b/external-rest-api/test/invitations.passwordRecovery.public.test.ts @@ -223,6 +223,74 @@ describe('password reset preview authority failures', () => { expect(response.text).not.toContain(recoveryProof) 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) => { + const chunks: Buffer[] = [] + for await (const chunk of req) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)) + } + requests.push({ + method: req.method, + url: req.url, + body: JSON.parse(Buffer.concat(chunks).toString('utf8')), + }) + res.writeHead(409, { 'content-type': 'application/json' }) + res.end(JSON.stringify({ error: 'invitation_not_pending' })) + }) + + const response = await submitDedicatedReset() + + expect(response.status).toBe(409) + expect(response.body).toMatchObject({ error: 'invitation_not_pending' }) + expect(response.headers['set-cookie']).toBeUndefined() + expect(requests).toHaveLength(1) + expect(requests[0]).toMatchObject({ + method: 'POST', + url: '/api/v1/external/invitations/password-token', + body: { + token: recoveryProof, + email: 'member@example.invalid', + invitationId: '00000000-0000-4000-8000-000000000001', + password: 'Synthetic-Replacement-Password-123', + }, + }) + }) }) const fetchMock = vi.fn() @@ -265,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 }) diff --git a/external-rest-api/test/meUnavailable.contract.test.ts b/external-rest-api/test/meUnavailable.contract.test.ts index 86e76d776a..52e184e43e 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('forwards a Control API outage body verbatim through the public error handler', async () => { mocks.controlApiRequest.mockRejectedValue( new ControlApiError( 'private Control API failure', @@ -42,6 +43,62 @@ 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(503) + 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|synthetic-member-id|synthetic-session-token/ + ) + expect(mocks.controlApiRequest).toHaveBeenCalledWith( + 'GET', + '/external/users/synthetic-member-id/me', + expect.objectContaining({ userSessionToken: 'synthetic-session-token' }) + ) + }) + + it('forwards a revoked upstream session envelope as 401 so the client re-authenticates', async () => { + const envelope = { + error: { + code: 'invalid_session', + message: 'The session is not valid.', + correlationId: 'upstream-correlation', + retryable: false, + }, + } + mocks.controlApiRequest.mockRejectedValue( + new ControlApiError('private upstream detail', 401, envelope) + ) + 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(401) + expect(response.body).toEqual(envelope) + expect(JSON.stringify(response.body)).not.toMatch(/private upstream detail/) + expect(mocks.controlApiRequest).toHaveBeenCalledWith( + 'GET', + '/external/users/synthetic-member-id/me', + expect.objectContaining({ userSessionToken: 'synthetic-session-token' }) + ) + }) + + it('answers a bare upstream 401 as dev did (404)', 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') @@ -49,8 +106,8 @@ describe('External REST /me unavailable producer contract', () => { 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(JSON.stringify(response.body)).not.toMatch(/private upstream detail/) + expect(mocks.controlApiRequest).toHaveBeenCalledTimes(1) expect(mocks.controlApiRequest).toHaveBeenCalledWith( 'GET', '/external/users/synthetic-member-id/me', @@ -58,6 +115,45 @@ describe('External REST /me unavailable producer contract', () => { ) }) + it('answers a rejected edge token on /me with the invalid_session envelope', async () => { + mocks.verifyToken.mockReturnValue(null) + const app = express() + app.use(createMeRouter()) + app.use(externalRestPublicErrorHandler) + + const missing = await request(app).get('/me') + const rejected = await request(app) + .get('/me') + .set('authorization', 'Bearer synthetic-expired-token') + + for (const response of [missing, rejected]) { + expect(response.status).toBe(401) + expect(response.body).toEqual({ + error: { + code: 'invalid_session', + message: 'The session is not valid.', + correlationId: expect.any(String), + retryable: false, + }, + }) + } + // The edge verified the presented token itself and never reached control-api. + expect(mocks.verifyToken).toHaveBeenCalledWith('synthetic-expired-token') + expect(mocks.controlApiRequest).not.toHaveBeenCalled() + }) + + it('keeps the bare 401 on the other /me routes', async () => { + const app = express() + app.use(createMeRouter()) + app.use(externalRestPublicErrorHandler) + + const response = await request(app).get('/me/contexts') + + expect(response.status).toBe(401) + expect(response.body).toEqual({ error: 'Unauthorized' }) + expect(mocks.controlApiRequest).not.toHaveBeenCalled() + }) + 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..2da893addd --- /dev/null +++ b/external-rest-api/test/mixedVersion.baseControlApi.test.ts @@ -0,0 +1,300 @@ +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' +// A session the edge still verifies locally but the base control-api judges ended +// (revoked or a stale auth generation). +const ENDED_BASE_SESSION_TOKEN = 'synthetic-ended-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' || token === 'synthetic-ended-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 + } + // Base answer for a session it judges ended: the same bare 401 (base + // `externalSessionAuth.ts`, `!current` branch), with no envelope. + if (path.startsWith('/api/v1/external/users/') && sessionToken === ENDED_BASE_SESSION_TOKEN) { + 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 /me for a session the base judges ended with 404, as the base edge did', async () => { + const response = await request(createApp()) + .get('/api/v1/me') + .set('authorization', `Bearer ${ENDED_BASE_SESSION_TOKEN}`) + + expect(response.status).toBe(404) + expect(response.body).toStrictEqual({ error: 'User not found' }) + // Witness: the edge reached the base /me route once with the caller's session. + expect(producer.calls.map(call => `${call.method} ${call.path}`)).toEqual([ + `GET /api/v1/external/users/${MEMBER_ID}/me?teamId=${CURRENT_TEAM_ID}`, + ]) + expect(producer.calls[0].sessionToken).toBe(ENDED_BASE_SESSION_TOKEN) + }) + + 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 new file mode 100644 index 0000000000..a613800fe3 --- /dev/null +++ b/external-rest-api/test/passwordAdmissionError.test.ts @@ -0,0 +1,102 @@ +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 +} + +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, + message = 'private upstream message' +) { + const body = publicApiErrorBody('issuance_ID-42', code, 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', + }, + SESSION_ISSUANCE_SENTENCE + ) + + const response = await request(buildApp()) + .post('/api/v1/auth/password-login') + .send({ email: 'user@example.invalid', password: 'synthetic-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() + // 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 () => { + 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: 'synthetic-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: 'synthetic-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 }) + }) +}) diff --git a/external-rest-api/test/profileUiPublicErrorCompatibility.test.ts b/external-rest-api/test/profileUiPublicErrorCompatibility.test.ts new file mode 100644 index 0000000000..7e2cf284b6 --- /dev/null +++ b/external-rest-api/test/profileUiPublicErrorCompatibility.test.ts @@ -0,0 +1,148 @@ +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 { externalRestPublicErrorHandler } from '../src/app.js' +import { config } from '../src/config.js' +import { controlApiRequest } from '../src/controlApiClient.js' + +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', () => { + const requestCorrelationId = 'caller-correlation-123' + 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))( + '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).toEqual(expected.control.body) + + 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( + expected.profileMessage + ) + } + ) + + 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) + + // 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 da50b01ee1..9d948b27d0 100644 --- a/external-rest-api/test/routes.auth.test.ts +++ b/external-rest-api/test/routes.auth.test.ts @@ -1,6 +1,8 @@ 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' import { createAuthRouter } from '../src/routes/auth.js' @@ -8,6 +10,8 @@ const authServiceMock = vi.hoisted(() => ({ loginWithGoogle: vi.fn(), loginWithPassword: vi.fn(), requestPasswordReset: vi.fn(), + logoutUserSession: vi.fn(), + renewUserSession: vi.fn(), })) vi.mock('../src/services/authService.js', () => authServiceMock) @@ -17,13 +21,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,16 +29,105 @@ describe('routes/auth password-login', () => { beforeEach(() => { authServiceMock.loginWithPassword.mockReset() authServiceMock.requestPasswordReset.mockReset() + authServiceMock.logoutUserSession.mockReset() + authServiceMock.renewUserSession.mockReset() + }) + + 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 (${status})`, status, body) + ) + + const res = await request(buildApp()) + .post('/api/v1/auth/password-login') + .send({ email: 'user@example.invalid', password: 'synthetic-wrong-password' }) + + 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('propagates invalid credentials as a 401 instead of a 500', async () => { + it('keeps retired password accounts indistinguishable from invalid credentials', async () => { authServiceMock.loginWithPassword.mockRejectedValueOnce( - new ControlApiError('control-api error (401)', 401, { error: 'Unauthorized' }) + new ControlApiError('private upstream detail', 403, { error: 'membership_not_found' }) ) const res = await request(buildApp()) .post('/api/v1/auth/password-login') - .send({ email: 'user@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' }) + }) + + it('answers a retired Google account with control-api own 401 Unauthorized', async () => { + // control-api google-login answers a user with no active membership with + // `401 {"error":"Unauthorized"}`; it never sends 403 on this route. + 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: 'synthetic-google-id-token' }) + + expect(authServiceMock.loginWithGoogle).toHaveBeenCalledTimes(1) + expect(authServiceMock.loginWithGoogle).toHaveBeenCalledWith({ + idToken: 'synthetic-google-id-token', + }) + expect(res.status).toBe(401) + expect(res.body).toEqual({ error: 'Unauthorized' }) + expect(JSON.stringify(res.body)).not.toContain('private upstream detail') + }) + + it('answers a control-api 403 on Google login with membership_not_found', async () => { + authServiceMock.loginWithGoogle.mockReset() + authServiceMock.loginWithGoogle.mockRejectedValueOnce( + new ControlApiError('private upstream detail', 403, { error: 'private upstream code' }) + ) + + 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(403) + expect(res.body).toEqual({ error: 'membership_not_found' }) + }) + + 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' }) + ) + + 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: 'session_revoked' }) + expect(JSON.stringify(res.body)).not.toContain('private upstream detail') + }) + + it('keeps password setup state opaque on password-login', 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: 'synthetic-valid-password' }) expect(res.status).toBe(401) expect(res.body).toEqual({ error: 'invalid_credentials' }) @@ -64,7 +151,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', @@ -80,12 +167,14 @@ 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() 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=604800') expect(String(res.headers['set-cookie'])).toContain('Secure') @@ -95,7 +184,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', @@ -109,12 +198,72 @@ 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-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 without an edge-supplied client IP', async () => { + authServiceMock.loginWithGoogle.mockResolvedValueOnce({ + 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-test-token' }) + + expect(response.status).toBe(200) + expect(authServiceMock.loginWithGoogle).toHaveBeenCalledWith({ idToken: 'opaque-test-token' }) + }) + + 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) + }) + + 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 () => { @@ -142,4 +291,155 @@ 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, sessionPresented: true }) + expect(authServiceMock.logoutUserSession).toHaveBeenCalledWith('opaque-session') + 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: { + code: 'invalid_session', + message: 'The session is not valid.', + correlationId: 'synthetic-correlation', + retryable: false, + }, + }) + ) + + 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, sessionPresented: true }) + 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.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: 'synthetic-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=synthetic-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([ + ['synthetic-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, 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/external-rest-api/test/routes.contextSharedFilesystems.test.ts b/external-rest-api/test/routes.contextSharedFilesystems.test.ts index 1d8dd0fb4b..27f1fdef1b 100644 --- a/external-rest-api/test/routes.contextSharedFilesystems.test.ts +++ b/external-rest-api/test/routes.contextSharedFilesystems.test.ts @@ -1,21 +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(), -})) - -vi.mock('../src/authToken.js', () => authTokenMock) -vi.mock('../src/controlApiClient.js', () => ({ - ...controlApiClientMock, - ControlApiError: class ControlApiError extends Error { +const controlApiClientMock = vi.hoisted(() => { + class ControlApiError extends Error { status: number body: unknown constructor(message: string, status: number, body: unknown) { @@ -23,7 +17,23 @@ vi.mock('../src/controlApiClient.js', () => ({ 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', () => ({ + ...controlApiClientMock, })) const claims = { @@ -38,6 +48,7 @@ function buildApp() { const app = express() app.use(express.json()) app.use(createContextSharedFilesystemsRouter()) + app.use(externalRestPublicErrorHandler) return app } @@ -80,6 +91,24 @@ describe('GET /me/contexts/:contextId/shared-filesystems', () => { expect(res.status).toBe(401) expect(controlApiClientMock.controlApiRequest).not.toHaveBeenCalled() }) + + it('forwards an upstream error body verbatim', 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).toEqual({ error: { code: 'forbidden' } }) + expect(controlApiClientMock.controlApiRequest).toHaveBeenCalledTimes(1) + }) }) describe('Method gating', () => { @@ -119,8 +148,29 @@ describe('Method gating', () => { expect(res.body).toEqual(Buffer.from('file-data')) expect(controlApiClientMock.controlApiStreamRequest).toHaveBeenCalledWith( 'GET', - '/external/contexts/ctx-a/shared-filesystems/team-mission/proxy/files?download=1', - { userSessionToken: 'good-token', throwOnHttpError: false } + '/external/contexts/ctx-a/shared-filesystems/team-mission/proxy/files', + { rawQueryString: '?download=1', userSessionToken: 'good-token', throwOnHttpError: false } + ) + }) + + it('re-encodes every wildcard segment and passes the original query separately', async () => { + authTokenMock.verifyToken.mockReturnValue(claims) + controlApiClientMock.controlApiStreamRequest.mockResolvedValue( + new Response('file-data', { + status: 200, + headers: { 'content-type': 'application/octet-stream' }, + }) + ) + + await request(buildApp()) + .get('/me/contexts/ctx-a/shared-filesystems/team-mission/proxy/files/a%3Fb.txt?download=1') + .set('authorization', 'Bearer good-token') + .expect(200) + + expect(controlApiClientMock.controlApiStreamRequest).toHaveBeenCalledWith( + 'GET', + '/external/contexts/ctx-a/shared-filesystems/team-mission/proxy/files/a%3Fb.txt', + { rawQueryString: '?download=1', userSessionToken: 'good-token', throwOnHttpError: false } ) }) @@ -145,6 +195,47 @@ describe('Method gating', () => { expect(res.headers['retry-after']).toBe('7') }) + it('relays no-store on an upstream 503 from a proxy read', async () => { + authTokenMock.verifyToken.mockReturnValue(claims) + controlApiClientMock.controlApiStreamRequest.mockResolvedValue( + new Response(JSON.stringify({ error: 'authority_unavailable' }), { + status: 503, + headers: { + 'content-type': 'application/json', + 'retry-after': '2', + 'cache-control': 'no-store', + }, + }) + ) + + const res = await request(buildApp()) + .get('/me/contexts/ctx-a/shared-filesystems/team-mission/proxy/files') + .set('authorization', 'Bearer good-token') + + expect(res.status).toBe(503) + expect(res.body).toEqual({ error: 'authority_unavailable' }) + expect(res.headers['retry-after']).toBe('2') + expect(res.headers['cache-control']).toBe('no-store') + }) + + it('does not relay cache-control on a successful proxy read', async () => { + authTokenMock.verifyToken.mockReturnValue(claims) + controlApiClientMock.controlApiStreamRequest.mockResolvedValue( + new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { 'content-type': 'application/json', 'cache-control': 'no-store' }, + }) + ) + + const res = await request(buildApp()) + .get('/me/contexts/ctx-a/shared-filesystems/team-mission/proxy/files') + .set('authorization', 'Bearer good-token') + + expect(res.status).toBe(200) + expect(res.body).toEqual({ ok: true }) + expect(res.headers['cache-control']).toBeUndefined() + }) + it('forwards HEAD without draining an upstream response body', async () => { authTokenMock.verifyToken.mockReturnValue(claims) const getReader = vi.fn(() => { @@ -170,8 +261,8 @@ describe('Method gating', () => { expect(cancel).toHaveBeenCalledOnce() expect(controlApiClientMock.controlApiStreamRequest).toHaveBeenCalledWith( 'HEAD', - '/external/contexts/ctx-a/shared-filesystems/team-mission/proxy/files?download=1', - { userSessionToken: 'good-token', throwOnHttpError: false } + '/external/contexts/ctx-a/shared-filesystems/team-mission/proxy/files', + { rawQueryString: '?download=1', userSessionToken: 'good-token', throwOnHttpError: false } ) }) }) diff --git a/external-rest-api/test/routes.controlApiParamEncoding.test.ts b/external-rest-api/test/routes.controlApiParamEncoding.test.ts new file mode 100644 index 0000000000..76ad9157cf --- /dev/null +++ b/external-rest-api/test/routes.controlApiParamEncoding.test.ts @@ -0,0 +1,352 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import http from 'node:http' +import type { AddressInfo } from 'node:net' +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 USER_ID = '11111111-1111-4111-8111-111111111111' +const TEAM_ID = '22222222-2222-4222-8222-222222222222' +const INVITATION_ID = '7f9c2bce-1a2b-4c3d-8e4f-0123456789ab' +const TARGET_USER_ID = '33333333-3333-4333-8333-333333333333' +const ENCODED_PROBE = 'U%2FV%3FW' +const ENCODED_PROBE_VALUE = 'U/V?W' + +const claims = { + userId: USER_ID, + email: 'user@example.test', + teamId: TEAM_ID, + role: 'admin' as const, + exp: 2_000_000_000, +} + +type RawResponse = { status: number; body: unknown; headers: http.IncomingHttpHeaders } + +// Express matches the encoded request target before it decodes the captured +// param. Supertest re-encodes a URL, so every case goes out over a raw loopback +// socket through the real createApp() server, exactly as a proxy would send it. +async function rawRequest(options: { + method: 'GET' | 'POST' | 'PATCH' | 'DELETE' + path: string + token?: string + body?: unknown +}): Promise { + const server = createApp().listen(0) + await new Promise((resolve, reject) => { + server.once('listening', resolve) + server.once('error', reject) + }) + try { + const { port } = server.address() as AddressInfo + const payload = options.body === undefined ? undefined : JSON.stringify(options.body) + return await new Promise((resolve, reject) => { + const request = http.request( + { + host: '127.0.0.1', + port, + path: options.path, + method: options.method, + headers: { + authorization: `Bearer ${options.token ?? 'synthetic-session-token'}`, + ...(payload === undefined + ? {} + : { + 'content-type': 'application/json', + 'content-length': Buffer.byteLength(payload), + }), + }, + }, + response => { + const chunks: Buffer[] = [] + response.on('data', chunk => chunks.push(chunk as Buffer)) + response.on('end', () => { + const raw = Buffer.concat(chunks).toString('utf8') + let body: unknown = raw + try { + body = JSON.parse(raw) as unknown + } catch { + // Non-JSON bodies stay as the raw text witness. + } + resolve({ status: response.statusCode ?? 0, body, headers: response.headers }) + }) + } + ) + request.on('error', reject) + if (payload !== undefined) request.write(payload) + request.end() + }) + } finally { + await new Promise(resolve => server.close(() => resolve())) + } +} + +function jsonResponse(body: unknown): Response { + return new Response(JSON.stringify(body), { + status: 200, + headers: { 'content-type': 'application/json' }, + }) +} + +function callAt(callIndex: number): { url: URL; init: RequestInit } { + const [url, init] = mocks.controlApiFetch.mock.calls[callIndex] as [string, RequestInit] + return { url: new URL(url), init } +} + +describe('caller-controlled control-api path components stay in one segment', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.verifyToken.mockReturnValue(claims) + vi.stubGlobal('fetch', mocks.controlApiFetch) + }) + + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('encodes the member id on GET /members/:userId and forwards a normal id too', async () => { + mocks.controlApiFetch.mockResolvedValue(jsonResponse({ id: ENCODED_PROBE_VALUE })) + + const alias = await rawRequest({ + method: 'GET', + path: `${PUBLIC_API_PREFIX}/members/${ENCODED_PROBE}`, + }) + expect(alias.status).toBe(200) + + await rawRequest({ method: 'GET', path: `${PUBLIC_API_PREFIX}/members/${USER_ID}` }) + + expect(mocks.controlApiFetch).toHaveBeenCalledTimes(2) + const first = callAt(0) + expect(first.url.pathname).toBe( + `${PUBLIC_API_PREFIX}/external/members/${encodeURIComponent(ENCODED_PROBE_VALUE)}` + ) + expect(first.url.pathname.split('/')).toHaveLength(6) + expect(callAt(1).url.pathname).toBe(`${PUBLIC_API_PREFIX}/external/members/${USER_ID}`) + }) + + it('encodes both member and team ids on PATCH /members/:userId/teams/:teamId/role', async () => { + mocks.controlApiFetch.mockResolvedValue(jsonResponse({ updated: true })) + + await rawRequest({ + method: 'PATCH', + path: `${PUBLIC_API_PREFIX}/members/${ENCODED_PROBE}/teams/T%2FX%3FY/role`, + body: { role: 'member' }, + }) + await rawRequest({ + method: 'PATCH', + path: `${PUBLIC_API_PREFIX}/members/${USER_ID}/teams/${TEAM_ID}/role`, + body: { role: 'member' }, + }) + + expect(mocks.controlApiFetch).toHaveBeenCalledTimes(2) + const first = callAt(0) + expect(first.url.pathname).toBe( + `${PUBLIC_API_PREFIX}/external/members/${encodeURIComponent(ENCODED_PROBE_VALUE)}` + + `/teams/${encodeURIComponent('T/X?Y')}/role` + ) + expect(first.url.pathname.split('/')).toHaveLength(9) + expect(first.init.method).toBe('PATCH') + expect(callAt(1).url.pathname).toBe( + `${PUBLIC_API_PREFIX}/external/members/${USER_ID}/teams/${TEAM_ID}/role` + ) + }) + + it('keeps the detach alias on DELETE /members/:userId/teams/:teamId and never selects the revoke route', async () => { + mocks.controlApiFetch.mockResolvedValue(jsonResponse({ detached: true })) + + await rawRequest({ + method: 'DELETE', + path: `${PUBLIC_API_PREFIX}/members/invitations%2fINV%3f/teams/T`, + }) + await rawRequest({ + method: 'DELETE', + path: `${PUBLIC_API_PREFIX}/members/U%3f/teams/T`, + }) + await rawRequest({ + method: 'DELETE', + path: `${PUBLIC_API_PREFIX}/members/${USER_ID}/teams/${TEAM_ID}`, + }) + + expect(mocks.controlApiFetch).toHaveBeenCalledTimes(3) + const first = callAt(0) + expect(first.url.pathname).toBe( + `${PUBLIC_API_PREFIX}/external/members/invitations%2FINV%3F/teams/T` + ) + expect(first.url.pathname).not.toBe(`${PUBLIC_API_PREFIX}/external/members/invitations/INV`) + expect(first.url.search).toBe('') + expect(first.init.method).toBe('DELETE') + expect(callAt(1).url.pathname).toBe(`${PUBLIC_API_PREFIX}/external/members/U%3F/teams/T`) + expect(callAt(1).url.search).toBe('') + expect(callAt(2).url.pathname).toBe( + `${PUBLIC_API_PREFIX}/external/members/${USER_ID}/teams/${TEAM_ID}` + ) + }) + + it('keeps the resend alias on POST /members/invitations/:invitationId/resend and never selects creation', async () => { + mocks.controlApiFetch.mockResolvedValue(jsonResponse({ resent: true })) + + await rawRequest({ + method: 'POST', + path: `${PUBLIC_API_PREFIX}/members/invitations/%3f/resend`, + }) + await rawRequest({ + method: 'POST', + path: `${PUBLIC_API_PREFIX}/members/invitations/${INVITATION_ID}/resend`, + }) + + expect(mocks.controlApiFetch).toHaveBeenCalledTimes(2) + const first = callAt(0) + expect(first.url.pathname).toBe(`${PUBLIC_API_PREFIX}/external/members/invitations/%3F/resend`) + expect(first.url.pathname).not.toBe(`${PUBLIC_API_PREFIX}/external/members/invitations`) + expect(first.url.search).toBe('') + expect(first.init.method).toBe('POST') + expect(callAt(1).url.pathname).toBe( + `${PUBLIC_API_PREFIX}/external/members/invitations/${INVITATION_ID}/resend` + ) + }) + + it('encodes the invitation id on DELETE /members/invitations/:invitationId', async () => { + mocks.controlApiFetch.mockResolvedValue(jsonResponse({ cancelled: true })) + + await rawRequest({ + method: 'DELETE', + path: `${PUBLIC_API_PREFIX}/members/invitations/INV%2F%3F`, + }) + await rawRequest({ + method: 'DELETE', + path: `${PUBLIC_API_PREFIX}/members/invitations/${INVITATION_ID}`, + }) + + expect(mocks.controlApiFetch).toHaveBeenCalledTimes(2) + const first = callAt(0) + expect(first.url.pathname).toBe(`${PUBLIC_API_PREFIX}/external/members/invitations/INV%2F%3F`) + expect(first.url.pathname.split('/')).toHaveLength(7) + expect(callAt(1).url.pathname).toBe( + `${PUBLIC_API_PREFIX}/external/members/invitations/${INVITATION_ID}` + ) + }) + + it('encodes the requested team id from POST /me/switch-team', async () => { + mocks.controlApiFetch.mockImplementation((url: string) => { + if (String(url).includes('/memberships/')) { + return Promise.resolve( + jsonResponse({ team_id: 'B/C?D', role: 'member', team_name: 'Team B' }) + ) + } + return Promise.resolve(jsonResponse({ token: 'synthetic-next-token' })) + }) + + const alias = await rawRequest({ + method: 'POST', + path: `${PUBLIC_API_PREFIX}/me/switch-team`, + body: { teamId: 'B/C?D' }, + }) + expect(alias.status).toBe(200) + + await rawRequest({ + method: 'POST', + path: `${PUBLIC_API_PREFIX}/me/switch-team`, + body: { teamId: 'B' }, + }) + + expect(mocks.controlApiFetch).toHaveBeenCalledTimes(4) + expect(callAt(0).url.pathname).toBe( + `${PUBLIC_API_PREFIX}/external/users/${USER_ID}/memberships/${encodeURIComponent('B/C?D')}` + ) + expect(callAt(0).url.pathname.split('/')).toHaveLength(8) + expect(callAt(1).url.pathname).toBe(`${PUBLIC_API_PREFIX}/external/auth/session-token`) + expect(callAt(2).url.pathname).toBe( + `${PUBLIC_API_PREFIX}/external/users/${USER_ID}/memberships/B` + ) + }) + + it('encodes the target user on the GET /team/members/:userId/role preflight', async () => { + mocks.controlApiFetch.mockImplementation((_url: string, init: RequestInit) => + Promise.resolve(init.method === 'GET' ? jsonResponse({ role: 'member' }) : jsonResponse({})) + ) + + await rawRequest({ + method: 'PATCH', + path: `${PUBLIC_API_PREFIX}/team/members/${ENCODED_PROBE}/role`, + body: { role: 'member' }, + }) + await rawRequest({ + method: 'PATCH', + path: `${PUBLIC_API_PREFIX}/team/members/${TARGET_USER_ID}/role`, + body: { role: 'member' }, + }) + + expect(mocks.controlApiFetch).toHaveBeenCalledTimes(4) + const first = callAt(0) + expect(first.url.pathname).toBe( + `${PUBLIC_API_PREFIX}/external/teams/${TEAM_ID}/members/${encodeURIComponent( + ENCODED_PROBE_VALUE + )}/role` + ) + expect(first.url.pathname.split('/')).toHaveLength(9) + expect(first.init.method).toBe('GET') + expect(callAt(2).url.pathname).toBe( + `${PUBLIC_API_PREFIX}/external/teams/${TEAM_ID}/members/${TARGET_USER_ID}/role` + ) + }) + + it('encodes the target user on the PATCH /team/members/:userId/role update', async () => { + mocks.controlApiFetch.mockImplementation((_url: string, init: RequestInit) => + Promise.resolve(init.method === 'GET' ? jsonResponse({ role: 'member' }) : jsonResponse({})) + ) + + await rawRequest({ + method: 'PATCH', + path: `${PUBLIC_API_PREFIX}/team/members/${ENCODED_PROBE}/role`, + body: { role: 'member' }, + }) + await rawRequest({ + method: 'PATCH', + path: `${PUBLIC_API_PREFIX}/team/members/${TARGET_USER_ID}/role`, + body: { role: 'member' }, + }) + + expect(mocks.controlApiFetch).toHaveBeenCalledTimes(4) + const first = callAt(1) + expect(first.url.pathname).toBe( + `${PUBLIC_API_PREFIX}/external/teams/${TEAM_ID}/members/${encodeURIComponent( + ENCODED_PROBE_VALUE + )}/role` + ) + expect(first.init.method).toBe('PATCH') + expect(callAt(3).url.pathname).toBe( + `${PUBLIC_API_PREFIX}/external/teams/${TEAM_ID}/members/${TARGET_USER_ID}/role` + ) + }) + + it('encodes the target user on DELETE /team/members/:userId', async () => { + mocks.controlApiFetch.mockResolvedValue(jsonResponse({ deleted: true })) + + await rawRequest({ + method: 'DELETE', + path: `${PUBLIC_API_PREFIX}/team/members/${ENCODED_PROBE}`, + }) + await rawRequest({ + method: 'DELETE', + path: `${PUBLIC_API_PREFIX}/team/members/${TARGET_USER_ID}`, + }) + + expect(mocks.controlApiFetch).toHaveBeenCalledTimes(2) + const first = callAt(0) + expect(first.url.pathname).toBe( + `${PUBLIC_API_PREFIX}/external/teams/${TEAM_ID}/members/${encodeURIComponent( + ENCODED_PROBE_VALUE + )}` + ) + expect(first.url.pathname.split('/')).toHaveLength(8) + expect(first.init.method).toBe('DELETE') + expect(callAt(1).url.pathname).toBe( + `${PUBLIC_API_PREFIX}/external/teams/${TEAM_ID}/members/${TARGET_USER_ID}` + ) + }) +}) diff --git a/external-rest-api/test/routes.controlApiPathExposure.test.ts b/external-rest-api/test/routes.controlApiPathExposure.test.ts new file mode 100644 index 0000000000..1ae837a7f0 --- /dev/null +++ b/external-rest-api/test/routes.controlApiPathExposure.test.ts @@ -0,0 +1,238 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import http from 'node:http' +import type { AddressInfo } from 'node:net' +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 INVITATION_ID = '7f9c2bce-1a2b-4c3d-8e4f-0123456789ab' +const RESEND_ALIAS = `${PUBLIC_API_PREFIX}/members/invitations/..%2f..%2faccess%2fresolve%3f/resend` +const RESEND_POSITIVE = `${PUBLIC_API_PREFIX}/members/invitations/${INVITATION_ID}/resend` +const SFS_PREFIX = `${PUBLIC_API_PREFIX}/me/contexts/ctx-a/shared-filesystems/team-mission/proxy` +const SFS_RESOLVE_ALIAS = `${SFS_PREFIX}/..%2f..%2f..%2f..%2f..%2faccess%2fresolve` +const SFS_CAPABILITIES_ALIAS = `${SFS_PREFIX}/..%2f..%2f..%2f..%2f..%2faccess%2fcapabilities` +// Product shape from workspace-files-controller: GET /v1/files/download?path=... +const SFS_DOWNLOAD_POSITIVE = `${SFS_PREFIX}/v1/files/download?path=notes%2Fplan.md` +const SFS_ENCODED_QUESTION_MARK = `${SFS_PREFIX}/files/a%3Fb.txt?download=1` + +const claims = { + userId: '10000000-0000-4000-8000-000000000001', + email: 'user@example.test', + teamId: '', + role: 'member' as const, + exp: 2_000_000_000, +} + +type RawResponse = { status: number; body: unknown; headers: http.IncomingHttpHeaders } + +// Supertest resolves the URL through WHATWG URL, which can normalize encoded +// dot segments before sending. The exposure is defined by the path exactly as a +// proxy or an attacker sends it on the wire, so these cases go out over a raw +// loopback socket through the real createApp() server. +async function rawRequest(options: { + method: 'GET' | 'POST' + path: string + token: string + correlationId?: string +}): Promise { + const server = createApp().listen(0) + await new Promise((resolve, reject) => { + server.once('listening', resolve) + server.once('error', reject) + }) + try { + const { port } = server.address() as AddressInfo + return await new Promise((resolve, reject) => { + const request = http.request( + { + host: '127.0.0.1', + port, + path: options.path, + method: options.method, + headers: { + authorization: `Bearer ${options.token}`, + ...(options.correlationId ? { 'x-correlation-id': options.correlationId } : {}), + }, + }, + response => { + const chunks: Buffer[] = [] + response.on('data', chunk => chunks.push(chunk as Buffer)) + response.on('end', () => { + const raw = Buffer.concat(chunks).toString('utf8') + let body: unknown = raw + try { + body = JSON.parse(raw) as unknown + } catch { + // Non-JSON bodies stay as the raw text witness. + } + resolve({ status: response.statusCode ?? 0, body, headers: response.headers }) + }) + } + ) + request.on('error', reject) + request.end() + }) + } finally { + await new Promise(resolve => server.close(() => resolve())) + } +} + +function invalidRequestEnvelope(correlationId: string) { + return { + error: { + code: 'invalid_request', + message: 'The request is not valid.', + correlationId, + retryable: false, + }, + } +} + +function jsonResponse(body: unknown): Response { + return new Response(JSON.stringify(body), { + status: 200, + headers: { 'content-type': 'application/json' }, + }) +} + +function binaryResponse(body: string): Response { + return new Response(body, { + status: 200, + headers: { 'content-type': 'application/octet-stream' }, + }) +} + +async function expectProductShapedDownloadReachesStub(): Promise { + const positive = await rawRequest({ + method: 'GET', + path: SFS_DOWNLOAD_POSITIVE, + token: 'synthetic-session-token', + }) + + expect(positive.status).toBe(200) + expect(positive.body).toBe('file-data') + expect(mocks.controlApiFetch).toHaveBeenCalledTimes(1) + const [url, init] = mocks.controlApiFetch.mock.calls[0] as [string, RequestInit] + const upstream = new URL(url) + expect(upstream.pathname).toBe( + `${PUBLIC_API_PREFIX}/external/contexts/ctx-a/shared-filesystems/team-mission/proxy/v1/files/download` + ) + expect(upstream.search).toBe('?path=notes%2Fplan.md') + expect(init.method).toBe('GET') + expect((init.headers as Record)['x-user-session-token']).toBe( + 'synthetic-session-token' + ) +} + +describe('control-api path exposure through public aliases', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.verifyToken.mockReturnValue(claims) + vi.stubGlobal('fetch', mocks.controlApiFetch) + }) + + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('rejects the encoded invitation resend alias before fetch while a valid resend stays live', async () => { + mocks.controlApiFetch.mockResolvedValueOnce(jsonResponse({ resent: true })) + + const alias = await rawRequest({ + method: 'POST', + path: RESEND_ALIAS, + token: 'synthetic-session-token', + correlationId: 'resend-alias-correlation', + }) + + expect(alias.status).toBe(400) + 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('synthetic-session-token') + expect(mocks.controlApiFetch).not.toHaveBeenCalled() + + // Positive witness in the same test: the same app, router, auth middleware + // and control-api client still forward a well-formed invitation id. + const positive = await rawRequest({ + method: 'POST', + path: RESEND_POSITIVE, + token: 'synthetic-session-token', + }) + + expect(positive.status).toBe(200) + expect(positive.body).toEqual({ resent: true }) + expect(mocks.controlApiFetch).toHaveBeenCalledTimes(1) + const [url, init] = mocks.controlApiFetch.mock.calls[0] as [string, RequestInit] + expect(new URL(url).pathname).toBe( + `${PUBLIC_API_PREFIX}/external/members/invitations/${INVITATION_ID}/resend` + ) + expect(init.method).toBe('POST') + 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 () => { + mocks.controlApiFetch.mockResolvedValueOnce(binaryResponse('file-data')) + + const alias = await rawRequest({ + method: 'GET', + path: SFS_RESOLVE_ALIAS, + 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('synthetic-session-token') + expect(mocks.controlApiFetch).not.toHaveBeenCalled() + + await expectProductShapedDownloadReachesStub() + }) + + it('rejects the encoded SFS capabilities alias before fetch while a product-shaped download stays live', async () => { + mocks.controlApiFetch.mockResolvedValueOnce(binaryResponse('file-data')) + + const alias = await rawRequest({ + method: 'GET', + path: SFS_CAPABILITIES_ALIAS, + 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('synthetic-session-token') + expect(mocks.controlApiFetch).not.toHaveBeenCalled() + + await expectProductShapedDownloadReachesStub() + }) + + it('keeps an encoded question mark inside one SFS wildcard segment and forwards the query separately', async () => { + mocks.controlApiFetch.mockResolvedValueOnce(binaryResponse('file-data')) + + const response = await rawRequest({ + method: 'GET', + path: SFS_ENCODED_QUESTION_MARK, + token: 'synthetic-session-token', + }) + + expect(response.status).toBe(200) + expect(response.body).toBe('file-data') + expect(mocks.controlApiFetch).toHaveBeenCalledTimes(1) + const [url, init] = mocks.controlApiFetch.mock.calls[0] as [string, RequestInit] + const upstream = new URL(url) + expect(upstream.pathname).toBe( + `${PUBLIC_API_PREFIX}/external/contexts/ctx-a/shared-filesystems/team-mission/proxy/files/a%3Fb.txt` + ) + expect(upstream.search).toBe('?download=1') + expect(init.method).toBe('GET') + }) +}) diff --git a/external-rest-api/test/routes.gfs.test.ts b/external-rest-api/test/routes.gfs.test.ts index 6befe274ea..834a818c83 100644 --- a/external-rest-api/test/routes.gfs.test.ts +++ b/external-rest-api/test/routes.gfs.test.ts @@ -25,6 +25,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() })) @@ -564,6 +570,31 @@ describe('routes/gfs /me/gfs/* (user session passthrough → /external/gfs/*)', 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 () => { const body = { ok: false, diff --git a/external-rest-api/test/routes.invitations.test.ts b/external-rest-api/test/routes.invitations.test.ts index 0eb59c592e..07a08ba7e2 100644 --- a/external-rest-api/test/routes.invitations.test.ts +++ b/external-rest-api/test/routes.invitations.test.ts @@ -38,16 +38,18 @@ describe('routes/invitations', () => { return app } - it('sets invitation password by minting an invitation session from the link token', async () => { + it('clears the revoked invitation session after setting the password', async () => { + invitationsServiceMock.getInvitationByToken.mockResolvedValue({ + id: 'inv-1', + email: 'invitee@example.com', + purpose: 'member_invitation', + status: 'pending', + }) invitationsServiceMock.acceptInvitation.mockResolvedValue({ data: { - accepted: true, userId: 'user-1', email: 'invitee@example.com', - teamId: null, - teamName: null, - role: 'member', - token: 'invited-session-token', + token: 'synthetic-invited-session-token', }, }) invitationsServiceMock.setupInvitationPassword.mockResolvedValue({ @@ -56,48 +58,45 @@ describe('routes/invitations', () => { const res = await request(makeApp()) .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) - 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('synthetic-invited-session-token') expect(String(res.headers['set-cookie'])).toContain('HttpOnly') expect(authTokenMock.verifyToken).not.toHaveBeenCalled() + expect(invitationsServiceMock.getInvitationByToken).toHaveBeenCalledWith( + 'synthetic-invitation-link-token' + ) expect(invitationsServiceMock.acceptInvitation).toHaveBeenCalledWith( - 'invitation-link-token', - 'invitee@example.com' + 'synthetic-invitation-link-token', + 'invitee@example.com', + 'v2' ) expect(invitationsServiceMock.setupInvitationPassword).toHaveBeenCalledWith( { userId: 'user-1', email: 'invitee@example.com', - sessionToken: 'invited-session-token', + sessionToken: 'synthetic-invited-session-token', }, 'inv-1', - 'user123!' + 'synthetic-user123!' ) + expect(invitationsServiceMock.setupInvitationPasswordWithToken).not.toHaveBeenCalled() }) - it('restores a password-reset session only after the password is updated', async () => { - invitationsServiceMock.acceptInvitation.mockResolvedValue({ - data: { - accepted: true, - userId: 'user-1', - email: 'invitee@example.com', - teamId: null, - teamName: null, - role: 'member', - token: 'Synthetic-pre-reset-session-token', - }, - }) - invitationsServiceMock.setupInvitationPassword.mockResolvedValue({ - data: { id: 'inv-1', passwordUpdated: true }, - }) + it('requires reauthentication after password reset and never restores a session', async () => { invitationsServiceMock.getInvitationByToken.mockResolvedValue({ id: 'inv-1', email: 'invitee@example.com', @@ -111,7 +110,7 @@ describe('routes/invitations', () => { purpose: 'password_reset', status: 'accepted', passwordUpdated: true, - token: 'Synthetic-post-reset-session-token', + reauthenticationRequired: true, }, }) @@ -131,12 +130,11 @@ describe('routes/invitations', () => { purpose: 'password_reset', status: 'accepted', passwordUpdated: true, + reauthenticationRequired: true, }) - expect(JSON.stringify(res.body)).not.toContain('Synthetic-post-reset-session-token') - expect(String(res.headers['set-cookie'])).toContain( - 'profile_session=Synthetic-post-reset-session-token' - ) - expect(String(res.headers['set-cookie'])).not.toContain('Synthetic-pre-reset-session-token') + expect(res.body).not.toHaveProperty('token') + expect(String(res.headers['set-cookie'])).toContain('profile_session=;') + expect(String(res.headers['set-cookie'])).toContain('HttpOnly') expect(invitationsServiceMock.acceptInvitation).not.toHaveBeenCalled() expect(invitationsServiceMock.setupInvitationPassword).not.toHaveBeenCalled() expect(invitationsServiceMock.setupInvitationPasswordWithToken).toHaveBeenCalledWith( @@ -155,7 +153,7 @@ describe('routes/invitations', () => { purpose: 'password_reset', status: 'accepted', passwordUpdated: true, - token: 'Synthetic-reset-session-token', + reauthenticationRequired: true, }, }) @@ -170,11 +168,13 @@ describe('routes/invitations', () => { }) .expect(200) - expect(res.body).toMatchObject({ id: 'reset-1', passwordUpdated: true }) - expect(JSON.stringify(res.body)).not.toContain('Synthetic-reset-session-token') - expect(String(res.headers['set-cookie'])).toContain( - 'profile_session=Synthetic-reset-session-token' - ) + expect(res.body).toMatchObject({ + id: 'reset-1', + passwordUpdated: true, + reauthenticationRequired: true, + }) + expect(res.body).not.toHaveProperty('token') + expect(String(res.headers['set-cookie'])).toContain('profile_session=;') expect(invitationsServiceMock.getInvitationByToken).not.toHaveBeenCalled() expect(invitationsServiceMock.acceptInvitation).not.toHaveBeenCalled() expect(invitationsServiceMock.setupInvitationPassword).not.toHaveBeenCalled() @@ -421,18 +421,127 @@ 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.acceptInvitation.mockResolvedValue({ - data: { - accepted: true, + it.each([ + ['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) => { + authTokenMock.verifyToken.mockReturnValue(null) + invitationsServiceMock.getInvitationByToken.mockResolvedValue({ + id: 'inv-1', + email: 'invitee@example.com', + 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 }, + }) + + 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: 'synthetic-invitation-link-token', + email: 'invitee@example.com', + invitationId: 'inv-1', + password: 'synthetic-user123!', + }) + .expect(401) + + expect(res.body).toEqual({ error: 'Unauthorized' }) + // Liveness: the presented credential was verified and rejected. + expect(authTokenMock.verifyToken).toHaveBeenCalledWith('synthetic-stale-token') + expect(invitationsServiceMock.getInvitationByToken).toHaveBeenCalledWith( + 'synthetic-invitation-link-token' + ) + expect(invitationsServiceMock.acceptInvitation).not.toHaveBeenCalled() + expect(invitationsServiceMock.setupInvitationPassword).not.toHaveBeenCalled() + expect(invitationsServiceMock.setupInvitationPasswordWithToken).not.toHaveBeenCalled() + } + ) + + it.each([ + ['an empty bearer header', '203.0.113.31', { authorization: 'Bearer ' }], + ['an empty authorization header', '203.0.113.32', { authorization: '' }], + ['a non-bearer authorization header', '203.0.113.33', { authorization: 'Basic dXNlcjpwdw==' }], + ['a malformed authorization header', '203.0.113.34', { authorization: 'garbage' }], + ['an empty profile session cookie', '203.0.113.35', { cookie: 'profile_session=' }], + [ + 'a non-bearer authorization header next to a session cookie', + '203.0.113.36', + { authorization: 'Basic dXNlcjpwdw==', cookie: 'profile_session=synthetic-cookie-session' }, + ], + ])( + 'fails closed with 401 for %s presented alongside an invitation link', + async (_case, ip, headers: Record) => { + // The verifier accepts any token it is given, so a 401 here can only come + // from the presented credential being empty or malformed. + authTokenMock.verifyToken.mockReturnValue({ userId: 'user-1', email: 'invitee@example.com', - teamId: null, - teamName: null, + teamId: '', role: 'member', - token: 'fresh-session-token', - }, + exp: 9999999999, + }) + invitationsServiceMock.getInvitationByToken.mockResolvedValue({ + id: 'inv-1', + email: 'invitee@example.com', + 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 }, + }) + + let pending = request(makeApp()).post('/invitations/password').set('x-forwarded-for', ip) + for (const [name, value] of Object.entries(headers)) pending = pending.set(name, value) + const res = await pending + .send({ + token: 'synthetic-invitation-link-token', + email: 'invitee@example.com', + invitationId: 'inv-1', + password: 'synthetic-user123!', + }) + .expect(401) + + expect(res.body).toEqual({ error: 'Unauthorized' }) + // Liveness: the route reached the credential decision. + expect(invitationsServiceMock.getInvitationByToken).toHaveBeenCalledWith( + 'synthetic-invitation-link-token' + ) + expect(authTokenMock.verifyToken).not.toHaveBeenCalled() + expect(invitationsServiceMock.acceptInvitation).not.toHaveBeenCalled() + expect(invitationsServiceMock.setupInvitationPassword).not.toHaveBeenCalled() + expect(invitationsServiceMock.setupInvitationPasswordWithToken).not.toHaveBeenCalled() + } + ) + + it('uses the bearer header over a session cookie when both are presented', async () => { + authTokenMock.verifyToken.mockReturnValue({ + userId: 'user-1', + email: 'invitee@example.com', + teamId: '', + role: 'member', + exp: 9999999999, }) invitationsServiceMock.setupInvitationPassword.mockResolvedValue({ data: { id: 'inv-1', passwordUpdated: true }, @@ -440,21 +549,15 @@ describe('routes/invitations', () => { await request(makeApp()) .post('/invitations/password') - .set('authorization', 'Bearer stale-token') - .send({ - token: 'invitation-link-token', - email: 'invitee@example.com', - invitationId: 'inv-1', - password: 'user123!', - }) + .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: 'synthetic-user123!' }) .expect(200) - expect(authTokenMock.verifyToken).toHaveBeenCalledWith('stale-token') - expect(invitationsServiceMock.setupInvitationPassword).toHaveBeenCalledWith( - expect.objectContaining({ sessionToken: 'fresh-session-token' }), - 'inv-1', - 'user123!' - ) + expect(authTokenMock.verifyToken).toHaveBeenCalledTimes(1) + expect(authTokenMock.verifyToken).toHaveBeenCalledWith('fresh-session-token') + expect(invitationsServiceMock.acceptInvitation).not.toHaveBeenCalled() }) it('sets invitation password with a valid bearer token without accepting again', async () => { @@ -474,7 +577,7 @@ describe('routes/invitations', () => { .set('authorization', 'Bearer fresh-session-token') .send({ invitationId: 'inv-1', - password: 'user123!', + password: 'synthetic-user123!', }) .expect(200) @@ -487,7 +590,7 @@ describe('routes/invitations', () => { sessionToken: 'fresh-session-token', }, 'inv-1', - 'user123!' + 'synthetic-user123!' ) }) @@ -500,13 +603,13 @@ describe('routes/invitations', () => { teamId: 'team-1', teamName: 'Team 1', role: 'member', - token: 'accepted-session-token', + token: 'synthetic-accepted-session-token', }, }) 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() @@ -515,21 +618,355 @@ 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') }) + 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: 'synthetic-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({ + data: { + accepted: true, + userId: 'user-1', + email: 'invitee@example.com', + teamId: 'team-1', + teamName: 'Team 1', + role: 'member', + token: 'synthetic-first-session-token', + }, + }) + .mockResolvedValueOnce({ + data: { + accepted: true, + userId: 'user-1', + email: 'invitee@example.com', + teamId: 'team-1', + teamName: 'Team 1', + role: 'member', + replay: true, + }, + }) + + const first = await request(makeApp()) + .post('/invitations/accept') + .set('x-forwarded-for', '198.51.100.201') + .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: '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=synthetic-first-session-token' + ) + expect(first.body).not.toHaveProperty('replay') + expect(replay.body).toMatchObject({ + accepted: true, + userId: 'user-1', + email: 'invitee@example.com', + replay: true, + }) + expect(replay.body).not.toHaveProperty('token') + expect(replay.headers['set-cookie']).toBeUndefined() + expect(invitationsServiceMock.acceptInvitation).toHaveBeenCalledTimes(2) + }) + + it('refuses password setup for a replayed accepted invitation and reaches the password call for a pending one', async () => { + invitationsServiceMock.getInvitationByToken.mockResolvedValue({ + id: 'inv-1', + email: 'invitee@example.com', + purpose: 'member_invitation', + status: 'accepted', + }) + invitationsServiceMock.acceptInvitation + .mockResolvedValueOnce({ + data: { + accepted: true, + userId: 'user-1', + email: 'invitee@example.com', + teamId: null, + teamName: null, + role: 'member', + replay: true, + }, + }) + .mockResolvedValueOnce({ + data: { + accepted: true, + userId: 'user-1', + email: 'invitee@example.com', + teamId: null, + teamName: null, + role: 'member', + token: 'synthetic-invited-session-token', + }, + }) + invitationsServiceMock.setupInvitationPassword.mockResolvedValue({ + data: { id: 'inv-1', passwordUpdated: true }, + }) + + const replayed = await request(makeApp()) + .post('/invitations/password') + .set('x-forwarded-for', '198.51.100.203') + .send({ + token: 'synthetic-accepted-invitation-link', + email: 'invitee@example.com', + invitationId: 'inv-1', + password: 'synthetic-user123!', + }) + .expect(400) + + // `error` is unchanged and `code` is the stable machine signal stream A can + // match without parsing the human message. + expect(replayed.body).toEqual({ + error: 'Invitation is not pending', + code: 'invitation_not_pending', + }) + expect(replayed.headers['set-cookie']).toBeUndefined() + expect(invitationsServiceMock.setupInvitationPassword).not.toHaveBeenCalled() + expect(invitationsServiceMock.setupInvitationPasswordWithToken).not.toHaveBeenCalled() + + // Positive control: the same request for a pending invitation receives a + // session, reaches the password change and clears the replaced cookie. + const pending = await request(makeApp()) + .post('/invitations/password') + .set('x-forwarded-for', '198.51.100.204') + .send({ + token: 'synthetic-pending-invitation-link', + email: 'invitee@example.com', + invitationId: 'inv-1', + password: 'synthetic-user123!', + }) + .expect(200) + + expect(invitationsServiceMock.setupInvitationPassword).toHaveBeenCalledWith( + { + userId: 'user-1', + email: 'invitee@example.com', + sessionToken: 'synthetic-invited-session-token', + }, + 'inv-1', + 'synthetic-user123!' + ) + expect(String(pending.headers['set-cookie'])).toContain('profile_session=;') + expect(invitationsServiceMock.acceptInvitation).toHaveBeenCalledTimes(2) + }) + + it('refuses an invalid password before the sessionless fallback consumes a pending invitation', async () => { + const state = { invitation: 'pending' as 'pending' | 'accepted', acceptCalls: 0 } + invitationsServiceMock.getInvitationByToken.mockImplementation(async () => ({ + id: 'inv-1', + email: 'invitee@example.com', + purpose: 'member_invitation', + status: state.invitation, + })) + invitationsServiceMock.acceptInvitation.mockImplementation(async () => { + state.acceptCalls += 1 + if (state.invitation === 'accepted') { + return { + data: { + accepted: true, + userId: 'user-1', + email: 'invitee@example.com', + replay: true, + }, + } + } + // Model the producer's one-time transition: only this call consumes it. + state.invitation = 'accepted' + return { + data: { + accepted: true, + userId: 'user-1', + email: 'invitee@example.com', + token: 'synthetic-invited-session-token', + }, + } + }) + invitationsServiceMock.setupInvitationPassword.mockResolvedValue({ + data: { id: 'inv-1', passwordUpdated: true }, + }) + + const invalidPasswords = ['short', ' ', 'x'.repeat(257)] + for (const [index, password] of invalidPasswords.entries()) { + const rejected = await request(makeApp()) + .post('/invitations/password') + .set('x-forwarded-for', `198.51.100.${210 + index}`) + .send({ + token: 'synthetic-pending-invitation-link', + email: 'invitee@example.com', + invitationId: 'inv-1', + password, + }) + .expect(400) + expect(rejected.body).toEqual({ + error: 'Password must be between 8 and 256 characters', + }) + expect(rejected.headers['set-cookie']).toBeUndefined() + // Read the modeled producer state back: each refusal left it pending. + expect(state.invitation).toBe('pending') + } + expect(invitationsServiceMock.acceptInvitation).not.toHaveBeenCalled() + expect(invitationsServiceMock.setupInvitationPassword).not.toHaveBeenCalled() + + // Liveness: a valid retry still consumes the invitation and succeeds. Its + // cleared profile cookie is the positive witness for the invalid requests' + // no-cookie assertions above. + const accepted = await request(makeApp()) + .post('/invitations/password') + .set('x-forwarded-for', '198.51.100.220') + .send({ + token: 'synthetic-pending-invitation-link', + email: 'invitee@example.com', + invitationId: 'inv-1', + password: 'synthetic-user123!', + }) + .expect(200) + expect(String(accepted.headers['set-cookie'])).toContain('profile_session=;') + expect(String(accepted.headers['set-cookie'])).toContain('HttpOnly') + expect(state.invitation).toBe('accepted') + expect(state.acceptCalls).toBe(1) + expect(invitationsServiceMock.setupInvitationPassword).toHaveBeenCalledWith( + { + userId: 'user-1', + email: 'invitee@example.com', + sessionToken: 'synthetic-invited-session-token', + }, + 'inv-1', + 'synthetic-user123!' + ) + + // The consumed state is real: the same fallback now receives the replay + // refusal instead of a second password write. + const replayed = await request(makeApp()) + .post('/invitations/password') + .set('x-forwarded-for', '198.51.100.221') + .send({ + token: 'synthetic-pending-invitation-link', + email: 'invitee@example.com', + invitationId: 'inv-1', + password: 'synthetic-user123!', + }) + .expect(400) + expect(replayed.body).toEqual({ + error: 'Invitation is not pending', + code: 'invitation_not_pending', + }) + expect(state.acceptCalls).toBe(2) + expect(invitationsServiceMock.setupInvitationPassword).toHaveBeenCalledTimes(1) + }) + + it('keeps the invited-session 502 when a non-replay acceptance has no token', async () => { + invitationsServiceMock.getInvitationByToken.mockResolvedValue({ + id: 'inv-1', + email: 'invitee@example.com', + purpose: 'member_invitation', + status: 'pending', + }) + const acceptedWithoutSession = { + data: { + accepted: true, + userId: 'user-1', + email: 'invitee@example.com', + teamId: null, + teamName: null, + role: 'member', + }, + } + invitationsServiceMock.acceptInvitation + .mockResolvedValueOnce(acceptedWithoutSession) + .mockResolvedValueOnce({ + data: { ...acceptedWithoutSession.data, token: 'synthetic-invited-session-token' }, + }) + invitationsServiceMock.setupInvitationPassword.mockResolvedValue({ + data: { id: 'inv-1', passwordUpdated: true }, + }) + + const broken = await request(makeApp()) + .post('/invitations/password') + .set('x-forwarded-for', '198.51.100.205') + .send({ + token: 'synthetic-pending-invitation-link', + email: 'invitee@example.com', + invitationId: 'inv-1', + password: 'synthetic-user123!', + }) + .expect(502) + + expect(broken.body).toEqual({ error: 'Failed to start invited session' }) + expect(broken.headers['set-cookie']).toBeUndefined() + expect(invitationsServiceMock.setupInvitationPassword).not.toHaveBeenCalled() + + // Positive witness: the same branch succeeds once the non-replay response + // carries the invited session. + await request(makeApp()) + .post('/invitations/password') + .set('x-forwarded-for', '198.51.100.206') + .send({ + token: 'synthetic-pending-invitation-link', + email: 'invitee@example.com', + invitationId: 'inv-1', + password: 'synthetic-user123!', + }) + .expect(200) + expect(invitationsServiceMock.setupInvitationPassword).toHaveBeenCalledTimes(1) + expect(invitationsServiceMock.acceptInvitation).toHaveBeenCalledTimes(2) + }) + it('rejects password setup without a bearer token or invitation token identity', async () => { await request(makeApp()) .post('/invitations/password') .send({ invitationId: 'inv-1', - password: 'user123!', + password: 'synthetic-user123!', }) .expect(401) 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/routes.members.test.ts b/external-rest-api/test/routes.members.test.ts index 3543e4e8b3..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' }) @@ -71,22 +71,149 @@ describe('routes/members', () => { ) }) - it('rejects overlong invitee names', async () => { + it('filters invitation role candidates by TEAM_ROLES and defaults to member', 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', - name: 'a'.repeat(121), + 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` + + await request(makeApp()) + .post('/members/invite') + .set('authorization', 'Bearer good-token') + .send({ + email: overlongEmail, teams: [{ teamId: 'team-1', role: 'member' }], }) - .expect(400, { error: 'Name is too long' }) + .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.each([ + [ + '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', + ], + [ + '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) + + await request(makeApp()) + .post('/members/invite') + .set('authorization', 'Bearer good-token') + .send(body) + .expect(400, { error: message }) + + expect(memberManagementMock.inviteManagedMember).not.toHaveBeenCalled() + }) + + 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-ok' }) + + 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-ok' }) + + expect(memberManagementMock.inviteManagedMember).toHaveBeenCalledWith( + 'invitee@example.com', + 'Alice', + [{ teamId: 'team-1', role: 'member' }], + 'good-token' + ) + }) + it('forwards a governed member-retirement reason, replay key, and correlation id', async () => { authTokenMock.verifyToken.mockReturnValueOnce(claims) memberManagementMock.deleteManagedUser.mockResolvedValueOnce({ ok: true }) @@ -138,15 +265,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([ diff --git a/external-rest-api/test/routes.notifications.test.ts b/external-rest-api/test/routes.notifications.test.ts index c80487716c..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('propagates 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, { @@ -157,6 +168,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) 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..c4753f6061 --- /dev/null +++ b/external-rest-api/test/routes.oauthGrants.test.ts @@ -0,0 +1,51 @@ +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' + +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()) + app.use(externalRestPublicErrorHandler) + 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('forwards an upstream error body verbatim', 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).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 77c49cca65..f80d4a3ec5 100644 --- a/external-rest-api/test/routes.rpc.test.ts +++ b/external-rest-api/test/routes.rpc.test.ts @@ -16,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() @@ -29,17 +40,20 @@ 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.each(rpcDenialReasons)( + 'relays the control-api denial reason %s instead of a generic 403', + async reason => { + rpcServiceMock.issueRpcAccessToken.mockResolvedValue({ error: reason }) - const res = await request(buildApp()) - .post('/rpc/token') - .set('authorization', 'Bearer session-xyz') - .send({ scopes: ['desktop:view'], hostRefs: ['pro-agent'] }) + 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: 'desktop_requires_team' }) - }) + expect(res.status).toBe(403) + expect(res.body).toEqual({ error: reason }) + } + ) it('returns the issued token on success', async () => { rpcServiceMock.issueRpcAccessToken.mockResolvedValue({ @@ -58,5 +72,10 @@ 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'] + ) }) }) 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..cbf44b59a6 --- /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 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).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 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).toEqual({ error: 'session_backend_unavailable', retryAfterSeconds: 2 }) + 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).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.teamSwitchCompatibility.test.ts b/external-rest-api/test/routes.teamSwitchCompatibility.test.ts new file mode 100644 index 0000000000..28a6c6daeb --- /dev/null +++ b/external-rest-api/test/routes.teamSwitchCompatibility.test.ts @@ -0,0 +1,197 @@ +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() + // 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) => { + 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', + } + }) + }) + + 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: 'synthetic-team-b-token', sessionContract: 'v1', deprecated: true } + } + if (method === 'PUT' && path === '/external/teams/team-b/name') { + 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}`) + } + ) + + 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('synthetic-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') + }) + + // 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') + }) +}) diff --git a/external-rest-api/test/routes.userApprovalDecisions.test.ts b/external-rest-api/test/routes.userApprovalDecisions.test.ts index 99c08209d1..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('propagates 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' }) @@ -179,12 +181,16 @@ 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: 'expired' }) + expect(userApprovalDecisionsServiceMock.listPendingUserApprovalDecisions).toHaveBeenCalledTimes( + 1 + ) }) - it('propagates 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') @@ -197,7 +203,8 @@ describe('routes/userApprovalDecisions', () => { .send({ decision: 'deny' }) .expect(409) - expect(response.body).toEqual({ error: 'conflict' }) + 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 6f62c398dc..4238b743f2 100644 --- a/external-rest-api/test/routes.workflowApprovalMediums.test.ts +++ b/external-rest-api/test/routes.workflowApprovalMediums.test.ts @@ -1,6 +1,8 @@ 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' const serviceMock = vi.hoisted(() => ({ @@ -24,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 } @@ -282,4 +280,20 @@ describe('routes/workflowApprovalMediums', () => { expect(res.body.items).toHaveLength(1) expect(serviceMock.listApprovalChannelTargets).toHaveBeenCalledWith('session-token') }) + + 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' } }) + ) + + 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).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 0060a55704..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,6 +79,26 @@ describe('routes/workflows', () => { ) }) + 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' } }) + ) + + 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).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 () => { authTokenMock.verifyToken.mockReturnValueOnce(claims) controlApiClientMock.controlApiBinaryRequestWithStatus.mockResolvedValueOnce({ diff --git a/external-rest-api/test/services.directoryService.test.ts b/external-rest-api/test/services.directoryService.test.ts new file mode 100644 index 0000000000..2065af2446 --- /dev/null +++ b/external-rest-api/test/services.directoryService.test.ts @@ -0,0 +1,49 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { searchDirectory } from '../src/services/directoryService.js' + +const clientMock = vi.hoisted(() => ({ controlApiRequest: vi.fn() })) +vi.mock('../src/controlApiClient.js', async importOriginal => { + const actual = await importOriginal() + return { ...actual, controlApiRequest: clientMock.controlApiRequest } +}) + +// control-api's directory item: dev's raw row without `channels`, which stays private. +const DIRECTORY_ITEM_FIELDS = ['display_name', 'email', 'id', 'name'] + +describe('edge directory search', () => { + beforeEach(() => { + clientMock.controlApiRequest.mockReset() + }) + + it("relays control-api's four-field items unchanged and skips the call for a blank query", async () => { + const payload = { + items: [ + { id: 'u1', email: 'alice@example.com', name: null, display_name: 'Alice' }, + { id: 'u2', email: 'alina@example.com', name: 'Alina', display_name: null }, + ], + } + clientMock.controlApiRequest.mockResolvedValueOnce(structuredClone(payload)) + + const result = await searchDirectory('team-1', ' alice ', 'synthetic-session-token') + + expect(result).toEqual(payload) + for (const item of result.items as Record[]) { + expect(Object.keys(item).sort()).toEqual(DIRECTORY_ITEM_FIELDS) + expect(item).not.toHaveProperty('channels') + expect(item).not.toHaveProperty('sort_key') + expect(item).not.toHaveProperty('displayName') + } + // Witness: the relayed body came from the one control-api read, with the trimmed query. + expect(clientMock.controlApiRequest).toHaveBeenCalledTimes(1) + expect(clientMock.controlApiRequest).toHaveBeenCalledWith('GET', '/external/directory/search', { + query: { teamId: 'team-1', q: 'alice' }, + userSessionToken: 'synthetic-session-token', + }) + + await expect(searchDirectory('team-1', ' ', 'synthetic-session-token')).resolves.toEqual({ + items: [], + }) + // The blank query answers locally: still the single call recorded above. + expect(clientMock.controlApiRequest).toHaveBeenCalledTimes(1) + }) +}) 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..bc2bb51613 --- /dev/null +++ b/external-rest-api/test/services.externalUserClientIp.test.ts @@ -0,0 +1,148 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +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() })) +vi.mock('../src/controlApiClient.js', () => client) + +/** + * 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('sends no extra header from the four auth service calls', async () => { + client.controlApiRequest + .mockResolvedValueOnce({ token: 'synthetic-google-session' }) + .mockResolvedValueOnce({ token: 'synthetic-password-session' }) + .mockResolvedValueOnce({ token: 'synthetic-renewed-session', expiresInSeconds: 3600 }) + .mockResolvedValueOnce({ revoked: true }) + + 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/google-login', + { body: { idToken: 'synthetic-google-id-token' } } + ) + expect(client.controlApiRequest).toHaveBeenNthCalledWith( + 2, + 'POST', + '/external/auth/password-login', + { + body: { + email: 'user@example.invalid', + password: 'synthetic-password', + sessionContract: 'v2', + }, + } + ) + expect(client.controlApiRequest).toHaveBeenNthCalledWith( + 3, + 'POST', + '/external/auth/session/renew', + { userSessionToken: 'synthetic-session-token' } + ) + expect(client.controlApiRequest).toHaveBeenNthCalledWith( + 4, + 'POST', + '/external/auth/session/logout', + { userSessionToken: 'synthetic-session-token' } + ) + }) + + it('sends no extra header from team creation, team switch or RPC issuance', async () => { + client.controlApiRequest + .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: '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'] + ) + + 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', + { + 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', + { + 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'], + }, + }) + }) +}) 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..10ea2f0a01 --- /dev/null +++ b/external-rest-api/test/services.invitationsService.test.ts @@ -0,0 +1,96 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { ControlApiError } from '../src/controlApiClient.js' +import { + setupInvitationPassword, + setupInvitationPasswordWithToken, +} 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 => ({ + ...(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: 'authority_unavailable', + }) + + for (const operation of [ + () => acceptInvitation('flow-token', 'user@example.com', 'v2'), + () => + setupInvitationPassword( + { userId: 'user-1', email: 'user@example.com', sessionToken: 'synthetic-session-token' }, + 'inv-1', + 'synthetic-valid-password' + ), + () => + setupInvitationPasswordWithToken( + 'flow-token', + 'user@example.com', + 'inv-1', + 'synthetic-valid-password' + ), + ]) { + client.request.mockRejectedValueOnce(unavailable) + await expect(operation()).rejects.toBe(unavailable) + } + }) + + 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( + '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: 'synthetic-session-token' }, + 'inv-1', + 'synthetic-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', + email: 'user@example.com', + passwordUpdated: true, + token: 'synthetic-must-never-reach-browser-route', + }) + + const result = await setupInvitationPasswordWithToken( + 'flow-token', + 'user@example.com', + 'inv-1', + 'synthetic-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 new file mode 100644 index 0000000000..a4d3c704d8 --- /dev/null +++ b/external-rest-api/test/services.legacyAuthorityErrors.test.ts @@ -0,0 +1,321 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import request from 'supertest' +import { createApp } from '../src/app.js' +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 authTokenMock = vi.hoisted(() => ({ verifyToken: vi.fn() })) +vi.mock('../src/authToken.js', async importOriginal => { + const actual = await importOriginal() + return { ...actual, verifyToken: authTokenMock.verifyToken } +}) + +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', + }) +} + +// The body control-api sends with every bare 401: requireValidExternalSessionToken for a +// missing or ended session (control-api/src/middleware/externalSessionAuth.ts) and +// its service-credential guard (control-api/src/middleware/internalServiceAuth.ts) when it +// refuses this edge's credential. The two cannot be told apart. +const BARE_UNAUTHORIZED_BODY = { error: 'Unauthorized' } +const BARE_UNAUTHORIZED_REJECTION = { status: 401, body: BARE_UNAUTHORIZED_BODY } + +function bareUnauthorized(): ControlApiError { + return new ControlApiError('control api failure', 401, BARE_UNAUTHORIZED_BODY) +} + +describe('legacy authority adapters', () => { + beforeEach(() => { + clientMock.controlApiRequest.mockReset() + authTokenMock.verifyToken.mockReset() + }) + + it('maps every other /me and switch-team failure to absence, keeping outages, /me revocation and the switch-team bare 401', async () => { + for (const status of [404, 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() + } + // Witness: each getMe and switchTeam case reached control-api once. + expect(clientMock.controlApiRequest).toHaveBeenCalledTimes(4) + expect( + clientMock.controlApiRequest.mock.calls.filter( + ([method, path]) => method === 'GET' && path === `/external/users/${auth.userId}/me` + ) + ).toHaveLength(2) + + // /me: a bare 401 is no verdict on the session (it may be this edge's refused + // credential), so it reads as absence. + clientMock.controlApiRequest.mockRejectedValueOnce(bareUnauthorized()) + await expect(getMe(auth.userId, auth.teamId, auth.sessionToken)).resolves.toBeNull() + + // switch-team: control-api's membership read answers an ended session with the bare + // 401; it is relayed, never read as "not a member". + clientMock.controlApiRequest.mockRejectedValueOnce(bareUnauthorized()) + await expect( + switchTeam(auth.userId, auth.email, 'team-2', auth.sessionToken) + ).rejects.toMatchObject(BARE_UNAUTHORIZED_REJECTION) + + clientMock.controlApiRequest.mockRejectedValueOnce( + new ControlApiError('control api failure', 401, { + error: { code: 'invalid_session', message: 'The session is not valid.', retryable: false }, + }) + ) + await expect(getMe(auth.userId, auth.teamId, auth.sessionToken)).rejects.toMatchObject({ + status: 401, + body: { error: { code: 'invalid_session' } }, + }) + + clientMock.controlApiRequest.mockRejectedValueOnce(upstream(503)) + await expect(getMe(auth.userId, auth.teamId, auth.sessionToken)).rejects.toMatchObject({ + status: 503, + }) + + clientMock.controlApiRequest.mockRejectedValueOnce(upstream(503)) + await expect( + switchTeam(auth.userId, auth.email, 'team-2', auth.sessionToken) + ).rejects.toMatchObject({ status: 503 }) + + expect(clientMock.controlApiRequest).toHaveBeenCalledTimes(9) + }) + + it('keeps the current-team bare 401 and outages, and maps every other failure to no team', async () => { + for (const status of [403, 404, 500]) { + clientMock.controlApiRequest.mockRejectedValueOnce(upstream(status)) + await expect(getCurrentTeam(auth)).resolves.toBeNull() + } + + clientMock.controlApiRequest.mockRejectedValueOnce(bareUnauthorized()) + await expect(getCurrentTeam(auth)).rejects.toMatchObject(BARE_UNAUTHORIZED_REJECTION) + + clientMock.controlApiRequest.mockRejectedValueOnce(upstream(503)) + await expect(getCurrentTeam(auth)).rejects.toMatchObject({ status: 503 }) + + expect(clientMock.controlApiRequest).toHaveBeenCalledTimes(5) + }) + + it('keeps the rename bare 401, live demotion and outages, and maps the rest to not_found', async () => { + clientMock.controlApiRequest.mockRejectedValueOnce(bareUnauthorized()) + await expect(renameTeam(auth, 'Renamed')).rejects.toMatchObject(BARE_UNAUTHORIZED_REJECTION) + + clientMock.controlApiRequest.mockRejectedValueOnce(upstream(403)) + await expect(renameTeam(auth, 'Renamed')).resolves.toEqual({ error: 'forbidden' }) + + for (const status of [404, 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 }) + + expect(clientMock.controlApiRequest).toHaveBeenCalledTimes(5) + }) + + it('keeps the member-read bare 401, live demotion and outages, and maps the rest to not_found', async () => { + clientMock.controlApiRequest.mockRejectedValueOnce(bareUnauthorized()) + await expect(updateMemberRole(auth, 'user-2', 'member')).rejects.toMatchObject( + BARE_UNAUTHORIZED_REJECTION + ) + + clientMock.controlApiRequest.mockRejectedValueOnce(upstream(403)) + await expect(updateMemberRole(auth, 'user-2', 'member')).resolves.toEqual({ + error: 'forbidden', + }) + + for (const status of [404, 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, + }) + + // Every case stops at the existence read: the PATCH is never sent. + expect(clientMock.controlApiRequest).toHaveBeenCalledTimes(5) + for (const [method] of clientMock.controlApiRequest.mock.calls) expect(method).toBe('GET') + }) + + it('keeps the member-delete bare 401, live demotion and outages, and maps the rest to not_found', async () => { + clientMock.controlApiRequest.mockRejectedValueOnce(bareUnauthorized()) + await expect(deleteMember(auth, 'user-2')).rejects.toMatchObject(BARE_UNAUTHORIZED_REJECTION) + + clientMock.controlApiRequest.mockRejectedValueOnce(upstream(403)) + await expect(deleteMember(auth, 'user-2')).resolves.toEqual({ error: 'forbidden' }) + + for (const status of [404, 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("relays control-api's bare 401 from GET /api/v1/team instead of answering no team", async () => { + authTokenMock.verifyToken.mockReturnValue({ ...auth, exp: 9999999999 }) + clientMock.controlApiRequest.mockRejectedValueOnce(bareUnauthorized()) + + const response = await request(createApp()) + .get('/api/v1/team') + .set('authorization', 'Bearer synthetic-session-token') + expect(response.status).toBe(401) + expect(response.body).toEqual(BARE_UNAUTHORIZED_BODY) + + // Witness: the request reached the current-team read with the caller's session. + expect(clientMock.controlApiRequest).toHaveBeenCalledTimes(1) + expect(clientMock.controlApiRequest).toHaveBeenCalledWith( + 'GET', + `/external/teams/${auth.teamId}/users/${auth.userId}/current`, + { userSessionToken: 'synthetic-session-token' } + ) + }) + + it("relays control-api's bare 401 from the switch-team membership read instead of 'not a member'", async () => { + authTokenMock.verifyToken.mockReturnValue({ ...auth, exp: 9999999999 }) + clientMock.controlApiRequest.mockRejectedValueOnce(bareUnauthorized()) + + const response = await request(createApp()) + .post('/api/v1/me/switch-team') + .set('authorization', 'Bearer synthetic-session-token') + .send({ teamId: 'team-2' }) + expect(response.status).toBe(401) + expect(response.body).toEqual(BARE_UNAUTHORIZED_BODY) + + // Witness: exactly one control-api call, the membership read with the caller's + // session. The count also proves no /external/auth/session-token call was made. + expect(clientMock.controlApiRequest).toHaveBeenCalledTimes(1) + expect(clientMock.controlApiRequest).toHaveBeenCalledWith( + 'GET', + `/external/users/${auth.userId}/memberships/team-2`, + { userSessionToken: 'synthetic-session-token' } + ) + }) + + it("still answers 'not a member' when control-api's membership read says not_found", async () => { + authTokenMock.verifyToken.mockReturnValue({ ...auth, exp: 9999999999 }) + // control-api/src/routes/external/users.ts:222 + clientMock.controlApiRequest.mockRejectedValueOnce( + new ControlApiError('control api failure', 404, { error: 'not_found' }) + ) + + const response = await request(createApp()) + .post('/api/v1/me/switch-team') + .set('authorization', 'Bearer synthetic-session-token') + .send({ teamId: 'team-2' }) + expect(response.status).toBe(403) + expect(response.body).toEqual({ error: 'You are not a member of this team' }) + + expect(clientMock.controlApiRequest).toHaveBeenCalledTimes(1) + expect(clientMock.controlApiRequest).toHaveBeenCalledWith( + 'GET', + `/external/users/${auth.userId}/memberships/team-2`, + { userSessionToken: 'synthetic-session-token' } + ) + }) + + const teamMutationRoutes = [ + { + name: 'PUT /api/v1/team/name', + send: (app: ReturnType) => + request(app) + .put('/api/v1/team/name') + .set('authorization', 'Bearer synthetic-session-token') + .send({ name: 'Renamed' }), + call: [ + 'PUT', + `/external/teams/${auth.teamId}/name`, + { body: { name: 'Renamed' }, userSessionToken: 'synthetic-session-token' }, + ], + }, + { + name: 'PATCH /api/v1/team/members/:userId/role', + send: (app: ReturnType) => + request(app) + .patch('/api/v1/team/members/user-2/role') + .set('authorization', 'Bearer synthetic-session-token') + .send({ role: 'member' }), + call: [ + 'GET', + `/external/teams/${auth.teamId}/members/user-2/role`, + { userSessionToken: 'synthetic-session-token' }, + ], + }, + { + name: 'DELETE /api/v1/team/members/:userId', + send: (app: ReturnType) => + request(app) + .delete('/api/v1/team/members/user-2') + .set('authorization', 'Bearer synthetic-session-token'), + call: [ + 'DELETE', + `/external/teams/${auth.teamId}/members/user-2`, + { userSessionToken: 'synthetic-session-token' }, + ], + }, + ] as const + + for (const route of teamMutationRoutes) { + it(`relays control-api's bare 401 from ${route.name} instead of answering not found`, async () => { + authTokenMock.verifyToken.mockReturnValue({ ...auth, exp: 9999999999 }) + clientMock.controlApiRequest.mockRejectedValueOnce(bareUnauthorized()) + + const response = await route.send(createApp()) + expect(response.status).toBe(401) + expect(response.body).toEqual(BARE_UNAUTHORIZED_BODY) + + // Witness: exactly one control-api call, the route's first read or write with the + // caller's session; nothing after it was sent. + expect(clientMock.controlApiRequest).toHaveBeenCalledTimes(1) + expect(clientMock.controlApiRequest).toHaveBeenCalledWith(...route.call) + }) + } + + it('does not convert desktop-authorization authority outages into bad passwords', async () => { + 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', + }) + + clientMock.controlApiRequest.mockRejectedValueOnce(upstream(503)) + await expect(createDesktopAuthorization(auth, 'correct-password')).rejects.toMatchObject({ + status: 503, + }) + }) +}) diff --git a/external-rest-api/test/services.rpc.test.ts b/external-rest-api/test/services.rpc.test.ts index 58ca3ac7ca..6f25951307 100644 --- a/external-rest-api/test/services.rpc.test.ts +++ b/external-rest-api/test/services.rpc.test.ts @@ -13,7 +13,7 @@ describe('rpcService.issueRpcAccessToken', () => { clientMock.controlApiRequest.mockReset() }) - it('surfaces the control-api denial reason on a 403 instead of collapsing to null', 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): desktop_requires_team', @@ -22,12 +22,12 @@ describe('rpcService.issueRpcAccessToken', () => { ) ) - const result = await issueRpcAccessToken('session', ['desktop:view'], ['pro-agent']) - - expect(result).toEqual({ error: 'desktop_requires_team' }) + await expect(issueRpcAccessToken('session', ['desktop:view'], ['pro-agent'])).resolves.toEqual({ + error: 'desktop_requires_team', + }) }) - it('uses the generic forbidden reason when a 403 error code is not a string', 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( diff --git a/host-context-controller/test/hostReconciler.lifecycle.test.ts b/host-context-controller/test/hostReconciler.lifecycle.test.ts index 80b1500003..18917b62ed 100644 --- a/host-context-controller/test/hostReconciler.lifecycle.test.ts +++ b/host-context-controller/test/hostReconciler.lifecycle.test.ts @@ -381,7 +381,8 @@ async function mintedRuntimeCredentialRecord( coreApi.readNamespacedSecret.mockRejectedValue({ code: 404 }) await (producer as any).ensureMcpHostRuntimeTokenSecret(host) const created = coreApi.createNamespacedSecret.mock.calls.at(-1)?.[0].body as - k8s.V1Secret | undefined + | k8s.V1Secret + | undefined if (!created?.stringData) throw new Error('producer pass wrote no runtime credential Secret') let record: k8s.V1Secret = { ...created, @@ -406,7 +407,8 @@ async function mintedRuntimeCredentialRecord( coreApi.readNamespacedSecret.mockResolvedValue(record) await (producer as any).ensureMcpHostRuntimeTokenSecret(host) const consumed = coreApi.replaceNamespacedSecret.mock.calls.at(-1)?.[0].body as - k8s.V1Secret | undefined + | k8s.V1Secret + | undefined if ( consumed?.metadata?.annotations?.['clerum.io/runtime-token-bootstrap-state'] !== 'consumed' ) { 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) diff --git a/mcp-host/src/core/conversation/persistence/__tests__/persistQueue.test.ts b/mcp-host/src/core/conversation/persistence/__tests__/persistQueue.test.ts index c2cc8d641c..fe5ccc0c36 100644 --- a/mcp-host/src/core/conversation/persistence/__tests__/persistQueue.test.ts +++ b/mcp-host/src/core/conversation/persistence/__tests__/persistQueue.test.ts @@ -837,10 +837,7 @@ describe('PersistQueue progress bound: slow vs wedged, in service and at close() const closing = q.close() await vi.waitFor(() => expect(worker.sent.map(m => m.op.kind)).toContain('shutdown')) await sleep(90) - ack( - worker, - worker.sent.find(m => m.op.kind === 'shutdown')! - ) + ack(worker, worker.sent.find(m => m.op.kind === 'shutdown')!) // Liveness witness: close() resolves after the late shutdown ACK. await expect(closing).resolves.toBeUndefined() expect(failures).toHaveLength(0) diff --git a/mcp-host/src/db/worker/dispatcher.ts b/mcp-host/src/db/worker/dispatcher.ts index a19d4680af..d870380bba 100644 --- a/mcp-host/src/db/worker/dispatcher.ts +++ b/mcp-host/src/db/worker/dispatcher.ts @@ -428,7 +428,8 @@ export async function dispatch(op: WorkerOp, deps: DispatcherDeps): Promise { const row = s.selectSessionModelSelectionRevision.get({ id: op.sessionId }) as - { model_selection_revision: number; model_selections: string | null } | undefined + | { model_selection_revision: number; model_selections: string | null } + | undefined if (!row) { const err: Error & { code?: string } = new Error( `update_session_model_selections: no session row for id=${op.sessionId}` @@ -717,7 +718,8 @@ export async function dispatch(op: WorkerOp, deps: DispatcherDeps): Promise } | undefined + | { conditions?: Array<{ type?: string; status?: string; reason?: string }> } + | undefined )?.conditions return ( Array.isArray(conditions) && diff --git a/profile-ui/app/components/AuthContext/index.tsx b/profile-ui/app/components/AuthContext/index.tsx index 2ee392815e..8e7d253da2 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, @@ -24,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, @@ -48,6 +52,7 @@ function meFromPasswordLoginResponse(me: PasswordLoginResponse['me']): Me { } export function AuthProvider({ children }: { children: React.ReactNode }) { + const { showToast } = useToast() const sessionExpiredHandledRef = useRef(false) const authCheckSequenceRef = useRef(0) const [authState, setAuthState] = useState({ @@ -55,6 +60,12 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { isLoading: true, 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 + // successful login replaces it, so an answer that arrives late for an older request is + // ignored. A failed login leaves it in place: the old cookie may still be valid. + const pendingRevocationRef = useRef(null) const checkAuth = useCallback(async (): Promise => { const sequence = ++authCheckSequenceRef.current @@ -93,11 +104,14 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { authCheckSequenceRef.current += 1 try { const result = await loginWithPassword(email, password) + pendingRevocationRef.current = null + setSignOutPending(false) resetProfileAccessCache() const current = await getMe().catch(() => { return meFromPasswordLoginResponse(result.me) }) sessionExpiredHandledRef.current = false + setSignOutIncomplete(false) setAuthState({ isLoggedIn: true, isLoading: false, @@ -111,17 +125,68 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { } }, []) + const requestRevocation = useCallback(() => { + 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 && !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 }) + }, + () => { + 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 resetProfileAccessCache(authState.me?.id) - void logoutProfileUI() + clearToken() + setSignOutIncomplete(false) setAuthState({ isLoggedIn: false, isLoading: false, me: null }) - }, [authState.me?.id]) + requestRevocation() + }, [authState.me?.id, requestRevocation]) const value = useMemo( - () => ({ authState, login, logout, checkAuth }), - [authState, checkAuth, login, logout] + () => ({ + authState, + login, + logout, + checkAuth, + signOutIncomplete, + signOutPending, + retrySignOut, + }), + [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 7e85451f5b..0a293249c9 100644 --- a/profile-ui/app/components/AuthContext/types.ts +++ b/profile-ui/app/components/AuthContext/types.ts @@ -18,4 +18,7 @@ export type AuthContextValue = { login: (email: string, password: string) => Promise 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 baac1fbde0..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 } = useAuth() + const { login, signOutIncomplete, signOutPending, 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 new file mode 100644 index 0000000000..116d143b5c --- /dev/null +++ b/profile-ui/app/components/__tests__/AuthContext.logout.test.tsx @@ -0,0 +1,389 @@ +import { 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 '@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 })) + +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({ expectLoginFailure = false }: { expectLoginFailure?: boolean }) { + const { authState, login, logout, signOutIncomplete, signOutPending, retrySignOut } = useAuth() + const [loginFailure, setLoginFailure] = useState(null) + const startLogin = () => { + const attempt = login('user@example.invalid', 'password') + if (expectLoginFailure) { + // The login screen catches a rejected login; the probe records it as the witness. + void attempt.catch((error: Error) => setLoginFailure(error.message)) + } else { + void attempt + } + } + return ( +
+ {authState.isLoggedIn ? 'signed-in' : 'signed-out'} + {signOutPending ? 'sign-out-pending' : 'sign-out-idle'} + {loginFailure ? login-failed: {loginFailure} : null} + + + {signOutIncomplete ?

Sign-out is not complete

: null} + +
+ ) +} + +describe('Profile logout consumer', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.logoutProfileUI.mockReset() + 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, + fencePersisted: true, + sessionAbsent: 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 + ) + ) + }) + + function renderSignedIn() { + render( + + + + + + ) + } + + async function logOutWithUnsavedFence() { + 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) + } + + 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() + }) + + async function failLoginDuringPendingSignOut() { + const signOut = deferred() + mocks.logoutProfileUI.mockReturnValueOnce(signOut.promise) + mocks.loginWithPassword.mockRejectedValueOnce(new Error('invalid credentials')) + render( + + + + + + ) + + await screen.findByText('signed-in') + fireEvent.click(screen.getByRole('button', { name: 'Log out' })) + expect(screen.getByText('sign-out-pending')).toBeInTheDocument() + + fireEvent.click(screen.getByRole('button', { name: 'Log in' })) + // Liveness: the login ran and its rejection reached the login screen. + await screen.findByText('login-failed: invalid credentials') + expect(mocks.loginWithPassword).toHaveBeenCalledOnce() + expect(screen.getByText('signed-out')).toBeInTheDocument() + // The failed login did not supersede the sign-out whose answer is still due. + expect(screen.getByText('sign-out-pending')).toBeInTheDocument() + return signOut + } + + it('keeps a pending sign-out when the login that started during it fails', async () => { + const signOut = await failLoginDuringPendingSignOut() + + await act(async () => signOut.resolve(unsavedFailure)) + + await screen.findByText('sign-out-idle') + expect(screen.getByRole('alert')).toHaveTextContent('Sign-out is not complete') + expect(mocks.logoutProfileUI).toHaveBeenCalledOnce() + }) + + it('reports the unconfirmed revocation when the login that started during the sign-out fails', async () => { + const signOut = await failLoginDuringPendingSignOut() + + await act(async () => signOut.resolve(noSessionReached)) + + await screen.findByText('sign-out-idle') + await waitFor(() => + expect(screen.getByRole('status')).toHaveTextContent( + /server could not confirm that your session was revoked/i + ) + ) + expect(screen.queryByRole('alert')).toBeNull() + }) + + 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', + 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, + sessionAbsent: false, + }) + 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__/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/app/components/__tests__/AuthGate.test.tsx b/profile-ui/app/components/__tests__/AuthGate.test.tsx index f48e668fa0..52a049d6b7 100644 --- a/profile-ui/app/components/__tests__/AuthGate.test.tsx +++ b/profile-ui/app/components/__tests__/AuthGate.test.tsx @@ -15,6 +15,9 @@ describe('AuthGate', () => { checkAuth: vi.fn(), login: vi.fn(), logout: vi.fn(), + signOutIncomplete: false, + signOutPending: false, + retrySignOut: vi.fn(), }) const view = render( diff --git a/profile-ui/app/components/__tests__/InvitationClient.entryStates.test.tsx b/profile-ui/app/components/__tests__/InvitationClient.entryStates.test.tsx new file mode 100644 index 0000000000..6d67007959 --- /dev/null +++ b/profile-ui/app/components/__tests__/InvitationClient.entryStates.test.tsx @@ -0,0 +1,136 @@ +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' + +vi.mock('@lib/api', async () => { + const actual = await vi.importActual('@lib/api') + return { + ...actual, + acceptInvitation: vi.fn(), + setupAcceptedInvitationPasswordWithToken: vi.fn(), + setupInvitationPasswordWithToken: vi.fn(), + } +}) + +const baseInvitation: InvitationPreview = { + id: 'entry-state-row', + teamId: null, + teamName: null, + email: 'entry-state@example.invalid', + role: 'member', + purpose: 'member_invitation', + status: 'pending', + expiresAt: new Date(Date.now() + 60_000).toISOString(), + acceptedAt: null, + userId: 'entry-state-user', + passwordPending: true, +} + +afterEach(cleanup) + +describe('InvitationClient entry states', () => { + it('shows the expired state without a password form', () => { + render( + + ) + + expect( + screen.getByText('This invitation has expired.', { selector: '.message--error-plain' }) + ).toBeInTheDocument() + expect(screen.queryByLabelText('Password')).toBeNull() + expect(screen.queryByRole('button', { name: 'Set password and continue' })).toBeNull() + expect(screen.getByRole('link', { name: 'Sign in' }).getAttribute('href')).toBe( + PROFILE_ROUTES.login({ email: baseInvitation.email }) + ) + }) + + it('shows the used state for an invitation whose password was already set', () => { + render( + + ) + + expect(screen.getByText('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: 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() + }) + + it('shows the used state with a fresh-link path for a redeemed recovery link', () => { + render( + + ) + + expect(screen.getByText('This invitation has already been used.')).toBeInTheDocument() + 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', () => { + render( + + ) + + expect(screen.getByRole('alert')).toHaveTextContent('Invitation not found.') + expect(screen.queryByLabelText('Password')).toBeNull() + expect(screen.queryByRole('heading', { name: 'Password updated' })).toBeNull() + expect(screen.getByRole('link', { name: 'Sign in' }).getAttribute('href')).toBe( + PROFILE_ROUTES.login() + ) + }) + + it('shows the not-found state for an expired link the authority already hides', () => { + render( + + ) + + expect(screen.getByRole('alert')).toHaveTextContent('Invitation not found.') + expect(screen.queryByLabelText('Password')).toBeNull() + expect(screen.getByRole('link', { name: 'Sign in' })).toBeInTheDocument() + }) +}) diff --git a/profile-ui/app/components/__tests__/InvitationClient.identityFence.test.tsx b/profile-ui/app/components/__tests__/InvitationClient.identityFence.test.tsx index 6ec9b75522..aadbe8842e 100644 --- a/profile-ui/app/components/__tests__/InvitationClient.identityFence.test.tsx +++ b/profile-ui/app/components/__tests__/InvitationClient.identityFence.test.tsx @@ -1,28 +1,18 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { PROFILE_ROUTES } from '@constants/routes' import type { InvitationPreview } from '@/app/types/api' import { InvitationClient } from '../../invitations/[token]/InvitationClient' const mocks = vi.hoisted(() => ({ - checkAuth: vi.fn(), - routerReplace: vi.fn(), setupInvitationPasswordWithToken: vi.fn(), })) -vi.mock('next/navigation', () => ({ - useRouter: () => ({ replace: mocks.routerReplace }), -})) - -vi.mock('@components/AuthContext', () => ({ - useAuth: () => ({ checkAuth: mocks.checkAuth }), -})) - vi.mock('@lib/api', async () => { const actual = await vi.importActual('@lib/api') return { ...actual, acceptInvitation: vi.fn(), - getEvenfireDownloadUrl: () => 'https://downloads.example.invalid/evenfire', setupInvitationPasswordWithToken: mocks.setupInvitationPasswordWithToken, } }) @@ -42,8 +32,6 @@ const invitation: InvitationPreview = { } beforeEach(() => { - mocks.checkAuth.mockReset() - mocks.routerReplace.mockReset() mocks.setupInvitationPasswordWithToken.mockReset().mockResolvedValue({ ...invitation, status: 'accepted', @@ -54,8 +42,8 @@ beforeEach(() => { afterEach(cleanup) -describe('R6 reset response identity fence', () => { - it('does not hydrate or navigate when the committed reset response names another user', async () => { +describe('R6 reset response session boundary', () => { + it('shows the password-updated screen from the reset response without navigating', async () => { render( { }) fireEvent.click(screen.getByRole('button', { name: 'Reset password' })) + await waitFor(() => + expect(screen.getByRole('heading', { name: 'Password updated' })).toBeInTheDocument() + ) expect( - await screen.findByText( - 'Your password was updated, but the signed-in account does not match this recovery link.' + screen.getByText( + 'Your password has been updated. Sign in with your new password to continue.' ) - ).toBeTruthy() - expect(mocks.setupInvitationPasswordWithToken).toHaveBeenCalledOnce() - expect(mocks.checkAuth).not.toHaveBeenCalled() - expect(mocks.routerReplace).not.toHaveBeenCalled() + ).toBeInTheDocument() + expect(screen.getByRole('link', { name: 'Sign in' }).getAttribute('href')).toBe( + PROFILE_ROUTES.login({ email: invitation.email }) + ) expect(screen.queryByLabelText('Password')).toBeNull() + expect(mocks.setupInvitationPasswordWithToken).toHaveBeenCalledOnce() }) }) diff --git a/profile-ui/app/components/__tests__/InvitationClient.recoveryContract.test.tsx b/profile-ui/app/components/__tests__/InvitationClient.recoveryContract.test.tsx index bd81db1372..f962fc110c 100644 --- a/profile-ui/app/components/__tests__/InvitationClient.recoveryContract.test.tsx +++ b/profile-ui/app/components/__tests__/InvitationClient.recoveryContract.test.tsx @@ -7,29 +7,20 @@ import { InvitationClient } from '../../invitations/[token]/InvitationClient' const mocks = vi.hoisted(() => ({ acceptInvitation: vi.fn(), - checkAuth: vi.fn(), - routerReplace: vi.fn(), setupInvitationPasswordWithToken: vi.fn(), })) -vi.mock('next/navigation', () => ({ - useRouter: () => ({ replace: mocks.routerReplace }), -})) - -vi.mock('@components/AuthContext', () => ({ - useAuth: () => ({ checkAuth: mocks.checkAuth }), -})) - vi.mock('@lib/api', async () => { const actual = await vi.importActual('@lib/api') return { ...actual, acceptInvitation: mocks.acceptInvitation, - getEvenfireDownloadUrl: () => 'https://downloads.example.invalid/evenfire', setupInvitationPasswordWithToken: mocks.setupInvitationPasswordWithToken, } }) +const LOGOUT_FENCE_KEY = 'profile_logout_unconfirmed' + const passwordReset: InvitationPreview = { id: 'r6-reset-1', teamId: null, @@ -44,6 +35,13 @@ const passwordReset: InvitationPreview = { passwordPending: true, } +const updatedResponse = { + ...passwordReset, + status: 'accepted', + passwordPending: false, + passwordUpdated: true, +} + function renderReset() { render( { + window.localStorage.clear() mocks.acceptInvitation.mockReset() - mocks.checkAuth.mockReset().mockResolvedValue({ status: 'unavailable' }) - mocks.routerReplace.mockReset() - mocks.setupInvitationPasswordWithToken.mockReset().mockResolvedValue({ - ...passwordReset, - status: 'accepted', - passwordUpdated: true, - userId: passwordReset.userId, - }) + mocks.setupInvitationPasswordWithToken.mockReset().mockResolvedValue(updatedResponse) }) afterEach(cleanup) -describe('R6 recovery outcome and identity fences', () => { +describe('R6 recovery outcome and guided reauthentication', () => { + it('moves a committed reset to the password-updated screen', async () => { + renderReset() + fillPassword() + submitReset() + + await waitFor(expectPasswordUpdatedScreen) + expect(mocks.setupInvitationPasswordWithToken).toHaveBeenCalledOnce() + expect(mocks.setupInvitationPasswordWithToken).toHaveBeenCalledWith( + 'r6-one-use-reset-proof', + passwordReset.email, + passwordReset.id, + 'R6-synthetic-new-password' + ) + expect(mocks.acceptInvitation).not.toHaveBeenCalled() + }) + it.each([ ['transport TypeError', new TypeError('socket closed after dispatch')], ['response SyntaxError', new SyntaxError('upstream response was not JSON')], @@ -90,18 +115,17 @@ describe('R6 recovery outcome and identity fences', () => { mocks.setupInvitationPasswordWithToken.mockRejectedValueOnce(error) renderReset() fillPassword() - fireEvent.click(screen.getByRole('button', { name: 'Reset password' })) + submitReset() expect( await screen.findByText( '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.', { exact: true } ) - ).toBeTruthy() + ).toBeInTheDocument() expect(screen.queryByLabelText('Password')).toBeNull() + expect(screen.getByRole('link', { name: 'Try signing in' })).toBeInTheDocument() expect(mocks.setupInvitationPasswordWithToken).toHaveBeenCalledOnce() - expect(mocks.checkAuth).not.toHaveBeenCalled() - expect(mocks.routerReplace).not.toHaveBeenCalled() }) it.each([ @@ -112,27 +136,36 @@ describe('R6 recovery outcome and identity fences', () => { ], ['definite internal failure', new ProfileApiError(500, 'internal_error', 'private detail')], ])('keeps a definite %s retryable rather than ambiguous', async (_label, error) => { - mocks.setupInvitationPasswordWithToken.mockRejectedValueOnce(error).mockResolvedValueOnce({ - ...passwordReset, - status: 'accepted', - passwordUpdated: true, - userId: passwordReset.userId, - }) - mocks.checkAuth.mockResolvedValueOnce({ - status: 'authenticated', - me: { id: passwordReset.userId, email: passwordReset.email }, - }) + mocks.setupInvitationPasswordWithToken + .mockRejectedValueOnce(error) + .mockResolvedValueOnce(updatedResponse) renderReset() fillPassword() - fireEvent.click(screen.getByRole('button', { name: 'Reset password' })) + submitReset() expect(await screen.findByRole('button', { name: 'Reset password' })).toBeEnabled() - expect(mocks.checkAuth).not.toHaveBeenCalled() - expect(mocks.routerReplace).not.toHaveBeenCalled() + expect(screen.queryByRole('heading', { name: 'Password updated' })).toBeNull() fillPassword() - fireEvent.click(screen.getByRole('button', { name: 'Reset password' })) - await waitFor(() => expect(mocks.routerReplace).toHaveBeenCalledWith(PROFILE_ROUTES.home)) + submitReset() + await waitFor(expectPasswordUpdatedScreen) expect(mocks.setupInvitationPasswordWithToken).toHaveBeenCalledTimes(2) }) + + 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() + + // 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 e2081b5c50..6fed5c61dc 100644 --- a/profile-ui/app/components/__tests__/InvitationClient.reset.test.tsx +++ b/profile-ui/app/components/__tests__/InvitationClient.reset.test.tsx @@ -1,32 +1,47 @@ 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 { 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 +} + +// 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(), - checkAuth: vi.fn(), - routerReplace: vi.fn(), + downloadUrl: 'https://downloads.example.invalid/evenfire', setupAcceptedInvitationPasswordWithToken: vi.fn(), setupInvitationPasswordWithToken: vi.fn(), })) -vi.mock('next/navigation', () => ({ - useRouter: () => ({ replace: mocks.routerReplace }), -})) - -vi.mock('@components/AuthContext', () => ({ - useAuth: () => ({ checkAuth: mocks.checkAuth }), -})) - vi.mock('@lib/api', async () => { const actual = await vi.importActual('@lib/api') return { ...actual, acceptInvitation: mocks.acceptInvitation, - getEvenfireDownloadUrl: () => 'https://downloads.example.invalid/evenfire', + getEvenfireDownloadUrl: () => mocks.downloadUrl, setupAcceptedInvitationPasswordWithToken: mocks.setupAcceptedInvitationPasswordWithToken, setupInvitationPasswordWithToken: mocks.setupInvitationPasswordWithToken, } @@ -73,13 +88,33 @@ function fillPassword() { }) } +function expectPasswordUpdatedScreen(email: string) { + expect(screen.getByRole('heading', { name: 'Password updated' })).toBeInTheDocument() + expect( + screen.getByText('Your password has been updated. Sign in with your new password to continue.') + ).toBeInTheDocument() + expect(screen.getByRole('link', { name: 'Sign in' }).getAttribute('href')).toBe( + PROFILE_ROUTES.login({ email }) + ) + 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() - mocks.checkAuth.mockReset().mockResolvedValue({ - status: 'authenticated', - me: { id: 'user-1', email: 'member@example.invalid' }, - }) - mocks.routerReplace.mockReset() mocks.setupAcceptedInvitationPasswordWithToken.mockReset().mockResolvedValue({ ...acceptedMemberInvitation, passwordPending: false, @@ -88,13 +123,17 @@ beforeEach(() => { mocks.setupInvitationPasswordWithToken.mockReset().mockResolvedValue({ ...passwordReset, status: 'accepted', + passwordPending: false, passwordUpdated: true, }) }) -afterEach(cleanup) +afterEach(() => { + cleanup() + vi.unstubAllGlobals() +}) -describe('InvitationClient verified password recovery', () => { +describe('InvitationClient guided password reauthentication', () => { it('omits the expiry notice when the invitation timestamp is invalid', () => { render( { expect(screen.queryByText('This invitation will expire in .')).toBeNull() }) - it('navigates to the authenticated home after reset completes', async () => { + it('moves a completed reset to the password-updated screen with a login link', async () => { render( { fillPassword() fireEvent.click(screen.getByRole('button', { name: 'Reset password' })) - await waitFor(() => expect(mocks.routerReplace).toHaveBeenCalledWith(PROFILE_ROUTES.home)) - expect(mocks.checkAuth).toHaveBeenCalledOnce() - expect(mocks.checkAuth.mock.invocationCallOrder[0]).toBeLessThan( - mocks.routerReplace.mock.invocationCallOrder[0] - ) - expect(mocks.acceptInvitation).not.toHaveBeenCalled() + 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', - 'member@example.invalid', - 'reset-1', + passwordReset.email, + passwordReset.id, 'new-synthetic-password' ) + expect(mocks.acceptInvitation).not.toHaveBeenCalled() + expect(mocks.setupAcceptedInvitationPasswordWithToken).not.toHaveBeenCalled() }) - it('keeps a committed reset retryable when session hydration fails, without resubmitting it', async () => { - mocks.checkAuth - .mockReset() - .mockResolvedValueOnce({ status: 'unavailable' }) - .mockResolvedValueOnce({ - status: 'authenticated', - me: { id: 'user-1', email: 'member@example.invalid' }, - }) - render( - - ) - - fillPassword() - fireEvent.click(screen.getByRole('button', { name: 'Reset password' })) - - expect( - await screen.findByText( - 'Your password was updated, but we could not verify your account session. Try again.' - ) - ).toBeTruthy() - expect(mocks.routerReplace).not.toHaveBeenCalled() - expect(mocks.setupInvitationPasswordWithToken).toHaveBeenCalledOnce() - - fireEvent.click(screen.getByRole('button', { name: 'Retry account session check' })) - - await waitFor(() => expect(mocks.routerReplace).toHaveBeenCalledWith(PROFILE_ROUTES.home)) - expect(mocks.checkAuth).toHaveBeenCalledTimes(2) - expect(mocks.setupInvitationPasswordWithToken).toHaveBeenCalledOnce() - }) - - it('does not navigate when hydrated identity differs from the recovered member', async () => { - mocks.checkAuth.mockResolvedValueOnce({ - status: 'authenticated', - me: { id: 'different-user', email: 'other@example.invalid' }, - }) - render( - - ) - - fillPassword() - fireEvent.click(screen.getByRole('button', { name: 'Reset password' })) - - expect( - await screen.findByText( - 'Your password was updated, but the signed-in account does not match this recovery link.' - ) - ).toBeTruthy() - expect(mocks.routerReplace).not.toHaveBeenCalled() - expect(mocks.setupInvitationPasswordWithToken).toHaveBeenCalledOnce() - }) - - it('keeps member invitation password setup on its existing page flow', async () => { - mocks.setupInvitationPasswordWithToken.mockRejectedValue( - new ProfileApiError(409, 'invitation_not_pending', '409 Conflict - invitation_not_pending') - ) + it('moves a completed member invitation password setup to the password-updated screen', async () => { render( { fillPassword() fireEvent.click(screen.getByRole('button', { name: 'Set password and continue' })) - await waitFor(() => expect(screen.getByText('You are now registered with Evenfire.'))) - expect(mocks.routerReplace).not.toHaveBeenCalled() + 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', - 'member@example.invalid', - 'reset-1', + acceptedMemberInvitation.email, + acceptedMemberInvitation.id, 'new-synthetic-password' ) expect(mocks.setupInvitationPasswordWithToken).not.toHaveBeenCalled() }) - it('keeps admin desktop password setup on the ordinary invitation operation', async () => { + it('moves a completed admin desktop password setup to the password-updated screen', async () => { mocks.setupAcceptedInvitationPasswordWithToken.mockResolvedValue({ ...acceptedAdminDesktopInvitation, passwordPending: false, @@ -240,11 +220,11 @@ describe('InvitationClient verified password recovery', () => { fillPassword() fireEvent.click(screen.getByRole('button', { name: 'Set password and continue' })) - await waitFor(() => expect(screen.getByText('You are now registered with Evenfire.'))) + await waitFor(() => expectPasswordUpdatedScreen(acceptedAdminDesktopInvitation.email)) expect(mocks.setupAcceptedInvitationPasswordWithToken).toHaveBeenCalledWith( 'admin-invitation-proof', - 'admin@example.invalid', - 'admin-invite-1', + acceptedAdminDesktopInvitation.email, + acceptedAdminDesktopInvitation.id, 'new-synthetic-password' ) expect(mocks.setupInvitationPasswordWithToken).not.toHaveBeenCalled() @@ -269,30 +249,178 @@ describe('InvitationClient verified password recovery', () => { await screen.findByText( 'Account recovery is temporarily busy. Please wait a moment and try again.' ) - ).toBeTruthy() + ).toBeInTheDocument() expect(screen.getByRole('button', { name: 'Reset password' })).toBeEnabled() - expect(mocks.checkAuth).not.toHaveBeenCalled() - expect(mocks.routerReplace).not.toHaveBeenCalled() + 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(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: submitLabel })).toBeInTheDocument() + fillPassword() + fireEvent.click(screen.getByRole('button', { name: submitLabel })) + + 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: submitLabel })).toBeNull() + expect(screen.getByRole('link', { name: 'Sign in' }).getAttribute('href')).toBe( + PROFILE_ROUTES.login({ email: preview.email }) + ) + expect( + screen.getByRole('link', { name: 'Request a new recovery link' }).getAttribute('href') + ).toBe(PROFILE_ROUTES.forgotPassword({ email: preview.email })) + expect(fetch).toHaveBeenCalledOnce() + } + ) + + it('ends an accepted-invitation password setup blocked by the logout fence at Sign in and recovery', async () => { + const actual = await vi.importActual('@lib/api') + // The real API function produces the rejection: the persisted fence makes + // apiSend throw AuthExpiredError before any request leaves the browser. + window.localStorage.setItem('profile_logout_unconfirmed', 'true') + mocks.setupAcceptedInvitationPasswordWithToken.mockImplementation( + async (invitationUrlId: string, email: string, invitationId: string, password: string) => + actual.setupAcceptedInvitationPasswordWithToken( + invitationUrlId, + email, + invitationId, + password + ) ) + vi.stubGlobal('fetch', vi.fn()) + render( ) + expect(screen.getByRole('button', { name: 'Set password and continue' })).toBeInTheDocument() fillPassword() - fireEvent.click(screen.getByRole('button', { name: 'Reset password' })) + fireEvent.click(screen.getByRole('button', { name: 'Set password and continue' })) + + expect(await screen.findByText('Sign in to continue with your account.')).toBeInTheDocument() + expect(screen.getByRole('alert')).toHaveTextContent('Session expired') + expect(screen.queryByLabelText('Password')).toBeNull() + expect(screen.queryByRole('button', { name: 'Set password and continue' })).toBeNull() + expect(screen.queryByRole('heading', { name: 'Password updated' })).toBeNull() + expect(screen.getByRole('link', { name: 'Sign in' }).getAttribute('href')).toBe( + PROFILE_ROUTES.login({ email: acceptedMemberInvitation.email }) + ) + expect( + screen.getByRole('link', { name: 'Request a new recovery link' }).getAttribute('href') + ).toBe(PROFILE_ROUTES.forgotPassword({ email: acceptedMemberInvitation.email })) + // Witness: the password call ran once and was rejected by the fence itself. + expect(mocks.setupAcceptedInvitationPasswordWithToken).toHaveBeenCalledOnce() + await expect( + mocks.setupAcceptedInvitationPasswordWithToken.mock.results[0]?.value + ).rejects.toBeInstanceOf(actual.AuthExpiredError) + expect(fetch).not.toHaveBeenCalled() + expect(mocks.setupInvitationPasswordWithToken).not.toHaveBeenCalled() + // Only an accepted reset releases the fence; this path leaves it in place. + expect(window.localStorage.getItem('profile_logout_unconfirmed')).toBe('true') + }) + + 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( + + ) + // 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') - expect(await screen.findByText('This recovery link is invalid or expired.')).toBeTruthy() - expect(mocks.checkAuth).not.toHaveBeenCalled() - expect(mocks.routerReplace).not.toHaveBeenCalled() + rerender( + + ) + expect(screen.getByRole('alert')).toHaveTextContent('Invitation not found.') + expect(window.localStorage.getItem('profile_logout_unconfirmed')).toBe('true') + + rerender( + + ) + // 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() }) it('does not resubmit a reset proof after an ambiguous recovery outcome', async () => { @@ -319,17 +447,15 @@ describe('InvitationClient verified password recovery', () => { '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.', { exact: true } ) - ).toBeTruthy() + ).toBeInTheDocument() expect(screen.queryByLabelText('Password')).toBeNull() expect(screen.queryByRole('button', { name: 'Reset password' })).toBeNull() expect(screen.getByRole('link', { name: 'Try signing in' }).getAttribute('href')).toBe( - PROFILE_ROUTES.login({ email: 'member@example.invalid' }) + PROFILE_ROUTES.login({ email: passwordReset.email }) ) expect( screen.getByRole('link', { name: 'Request a new recovery link' }).getAttribute('href') - ).toBe(PROFILE_ROUTES.forgotPassword({ email: 'member@example.invalid' })) + ).toBe(PROFILE_ROUTES.forgotPassword({ email: passwordReset.email })) expect(mocks.setupInvitationPasswordWithToken).toHaveBeenCalledOnce() - expect(mocks.checkAuth).not.toHaveBeenCalled() - expect(mocks.routerReplace).not.toHaveBeenCalled() }) }) 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..d3dbe848c3 --- /dev/null +++ b/profile-ui/app/components/__tests__/InviteMemberPage.emailValidation.test.tsx @@ -0,0 +1,86 @@ +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("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() + + 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/components/__tests__/LoginPanel.signOutIncomplete.test.tsx b/profile-ui/app/components/__tests__/LoginPanel.signOutIncomplete.test.tsx new file mode 100644 index 0000000000..9057bb32f3 --- /dev/null +++ b/profile-ui/app/components/__tests__/LoginPanel.signOutIncomplete.test.tsx @@ -0,0 +1,80 @@ +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, 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, + }), +})) + +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.signOutPending = 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('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() + + 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 new file mode 100644 index 0000000000..910cbab751 --- /dev/null +++ b/profile-ui/app/components/__tests__/api.logout.test.ts @@ -0,0 +1,393 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + AuthExpiredError, + ProfileApiError, + acceptInvitation, + apiGet, + clearUnconfirmedLogout, + loginWithPassword, + logoutProfileUI, + setupInvitationPasswordWithToken, +} from '@lib/api' + +describe('Profile logout client boundary', () => { + beforeEach(() => { + window.localStorage.clear() + clearUnconfirmedLogout() + vi.stubGlobal('fetch', vi.fn()) + }) + + afterEach(() => { + vi.unstubAllGlobals() + }) + + 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) + window.localStorage.setItem('external_session_token', 'legacy-token') + + await expect(logoutProfileUI()).resolves.toEqual({ + revocationConfirmed: false, + fencePersisted: true, + sessionAbsent: false, + }) + + 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() + }) + + 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, + sessionAbsent: 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() + } + ) + + 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 })) + .mockResolvedValueOnce( + new Response(JSON.stringify({ token: 'new-test-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') + }) + + it('keeps the logout fence when a successful login response cannot be parsed', async () => { + vi.mocked(fetch) + .mockResolvedValueOnce(new Response('', { status: 503 })) + .mockResolvedValueOnce( + new Response('{"token":', { + status: 200, + headers: { 'content-type': 'application/json' }, + }) + ) + .mockResolvedValueOnce( + new Response(JSON.stringify({ id: 'user-1' }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }) + ) + + await expect(logoutProfileUI()).resolves.toEqual({ + revocationConfirmed: false, + fencePersisted: true, + sessionAbsent: false, + }) + await expect(loginWithPassword('user@example.invalid', 'new-password')).rejects.toThrow() + + expect(window.localStorage.getItem('profile_logout_unconfirmed')).toBe('true') + await expect(apiGet('/api/v1/me')).rejects.toBeInstanceOf(AuthExpiredError) + // Liveness: the login reached its 200 branch (logout + login), and the fenced + // session request never reached the network. + expect(fetch).toHaveBeenCalledTimes(2) + expect(String(vi.mocked(fetch).mock.calls[1]?.[0])).toContain('/api/v1/auth/password-login') + }) + + it('clears the in-memory and stored logout fence', async () => { + vi.mocked(fetch) + .mockResolvedValueOnce(new Response('', { status: 503 })) + .mockResolvedValueOnce( + new Response(JSON.stringify({ id: 'user-1' }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }) + ) + + 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) + expect(fetch).toHaveBeenCalledOnce() + + clearUnconfirmedLogout() + + expect(window.localStorage.getItem('profile_logout_unconfirmed')).toBeNull() + 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) + }) + + const acceptedInvitation = { + accepted: true, + id: 'invitation-row-1', + teamId: 'team-1', + teamName: 'Team One', + role: 'member', + status: 'accepted', + expiresAt: '2026-12-01T00:00:00.000Z', + acceptedAt: '2026-11-05T00:00:00.000Z', + userId: 'user-2', + passwordPending: true, + email: 'invitee@example.invalid', + } + + it('sends a token-proof invitation acceptance through the fence and releases it', async () => { + vi.mocked(fetch) + .mockResolvedValueOnce(new Response('', { status: 503 })) + .mockResolvedValueOnce(jsonResponse(JSON.stringify(acceptedInvitation))) + .mockResolvedValueOnce(jsonResponse('{"id":"user-2"}')) + + await logoutProfileUI() + expect(window.localStorage.getItem('profile_logout_unconfirmed')).toBe('true') + + await expect( + acceptInvitation('synthetic-invitation-proof', 'invitee@example.invalid') + ).resolves.toEqual(acceptedInvitation) + + // Liveness: the acceptance reached the edge with the token-plus-email proof. + expect(fetch).toHaveBeenCalledTimes(2) + expect(String(vi.mocked(fetch).mock.calls[1]?.[0])).toContain('/api/v1/invitations/accept') + expect(vi.mocked(fetch).mock.calls[1]?.[1]?.method).toBe('POST') + expect(vi.mocked(fetch).mock.calls[1]?.[1]?.body).toBe( + JSON.stringify({ token: 'synthetic-invitation-proof', email: 'invitee@example.invalid' }) + ) + // The edge set the invitee's session cookie, so the fence is released. + expect(window.localStorage.getItem('profile_logout_unconfirmed')).toBeNull() + await expect(apiGet('/api/v1/me')).resolves.toEqual({ id: 'user-2' }) + expect(fetch).toHaveBeenCalledTimes(3) + }) + + it('keeps the fence after a replayed invitation acceptance, which sets no session', async () => { + const replayed = { ...acceptedInvitation, replay: true } + vi.mocked(fetch) + .mockResolvedValueOnce(new Response('', { status: 503 })) + .mockResolvedValueOnce(jsonResponse(JSON.stringify(replayed))) + + await logoutProfileUI() + expect(window.localStorage.getItem('profile_logout_unconfirmed')).toBe('true') + + await expect( + acceptInvitation('synthetic-replayed-invitation-proof', 'invitee@example.invalid') + ).resolves.toEqual(replayed) + + // Liveness: the replayed acceptance reached the edge through the fence. + expect(fetch).toHaveBeenCalledTimes(2) + expect(String(vi.mocked(fetch).mock.calls[1]?.[0])).toContain('/api/v1/invitations/accept') + expect(vi.mocked(fetch).mock.calls[1]?.[1]?.method).toBe('POST') + // No new cookie replaced the surviving one, so session requests stay fenced. + expect(window.localStorage.getItem('profile_logout_unconfirmed')).toBe('true') + await expect(apiGet('/api/v1/me')).rejects.toBeInstanceOf(AuthExpiredError) + expect(fetch).toHaveBeenCalledTimes(2) + }) +}) diff --git a/profile-ui/app/invitations/[token]/InvitationActionPanel.tsx b/profile-ui/app/invitations/[token]/InvitationActionPanel.tsx index 0f8fa98504..2b42e34f40 100644 --- a/profile-ui/app/invitations/[token]/InvitationActionPanel.tsx +++ b/profile-ui/app/invitations/[token]/InvitationActionPanel.tsx @@ -7,13 +7,6 @@ type InvitationAction = 'accept' | 'password' type SubmittingAction = InvitationAction | null type InvitationActionPanelProps = - | { - mode: 'session-check' - disabled: boolean - checking: boolean - onRetrySession: () => void - loginHref: string - } | { mode: 'outcome-unknown' loginHref: string @@ -34,11 +27,21 @@ type InvitationActionPanelProps = ) => void } | { - mode: 'accepted' + mode: 'password-updated' + loginHref: string downloadUrl: string + isPasswordReset: boolean + } + | { + mode: 'used' loginHref: string + downloadUrl: string + isPasswordReset: boolean + forgotPasswordHref: string } - | { mode: 'expired' } + | { mode: 'link-error'; loginHref: string } + | { mode: 'terminal'; loginHref: string; forgotPasswordHref?: string } + | { mode: 'expired'; loginHref: string } | { mode: 'accept' busy: boolean @@ -60,17 +63,6 @@ function buttonLabel(submitting: SubmittingAction, kind: InvitationAction, label export function InvitationActionPanel(props: InvitationActionPanelProps) { switch (props.mode) { - case 'session-check': - return ( -
- - - Sign in instead - -
- ) case 'outcome-unknown': return (
@@ -131,24 +123,71 @@ 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 + + Download Evenfire + {props.isPasswordReset ? ( + + Request a new recovery link + + ) : null} +
+ ) + case 'link-error': + return ( + ) + case 'terminal': + return ( +
+ + Sign in + + {props.forgotPasswordHref ? ( + + Request a new recovery link + + ) : null} +
+ ) 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 a6c0306865..011dc21606 100644 --- a/profile-ui/app/invitations/[token]/InvitationClient.tsx +++ b/profile-ui/app/invitations/[token]/InvitationClient.tsx @@ -9,16 +9,17 @@ import { useRef, useState, } from 'react' -import { useRouter } from 'next/navigation' -import { useAuth } from '@components/AuthContext' import { PROFILE_ROUTES } from '@constants/routes' import { + AuthExpiredError, ProfileApiError, acceptInvitation, + clearUnconfirmedLogout, getEvenfireDownloadUrl, setupAcceptedInvitationPasswordWithToken, setupInvitationPasswordWithToken, } from '@lib/api' +import { isTerminalPasswordError } from '@lib/invitationErrors' import { buildInvitationHeading, buildInvitationTeamsLabel, @@ -29,14 +30,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' | 'terminal' function statusForInvitation(invitation: InvitationPreview): string { if (invitation.purpose === 'password_reset') { @@ -95,8 +89,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 +98,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,7 +113,7 @@ export function InvitationClient({ }, [initialInvitation, initialError]) useEffect(() => { - setRecoveryPhase({ state: 'editing' }) + setPasswordPhase('editing') }, [invitationToken]) const invitationExpired = useMemo(() => { @@ -136,7 +128,6 @@ export function InvitationClient({ return remaining ? `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) @@ -148,6 +139,7 @@ export function InvitationClient({ ? [invitation.teamName] : [] const hasMultipleTeams = teamNames.length > 1 + const downloadUrl = getEvenfireDownloadUrl() const profileLoginHref = PROFILE_ROUTES.login({ email: invitation?.email.trim().toLowerCase(), }) @@ -155,12 +147,34 @@ 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 passwordTerminal = passwordPhase === 'terminal' + const invitationUsed = Boolean( + invitation && invitation.status === 'accepted' && !invitation.passwordPending + ) + const showPasswordSetup = Boolean( + invitation && + !passwordUpdated && + !recoveryOutcomeUnknown && + !passwordTerminal && + !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.' + : 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.' @@ -192,6 +206,20 @@ 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.' + 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.' if (message === 'invalid_invitation') return 'This recovery link is invalid or expired.' @@ -257,54 +285,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 } @@ -320,48 +307,48 @@ 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 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') + 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 && nextError instanceof AuthExpiredError) { + // The accepted-invitation password call does not pass the logout fence: + // the edge treats a presented session cookie as its only proof, and the + // fenced cookie may belong to another user. Resubmitting the form would + // hit the same fence, so end at Sign in and the recovery link instead. + setPasswordPhase('terminal') + setPassword('') + setConfirmPassword('') + setError(friendlyInvitationError(nextError, isPasswordReset)) + } 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 { - if (isPasswordReset) setRecoveryPhase({ state: 'editing' }) + setPasswordPhase('editing') setError(friendlyInvitationError(nextError, isPasswordReset)) } } finally { @@ -389,12 +376,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 ? ( @@ -438,22 +421,38 @@ export function InvitationClient({
) : null} - {!invitation ? null : recoveryNeedsSessionCheck ? ( + {!invitation ? ( + + ) : passwordUpdated ? ( void retryRecoveredSession()} + mode="password-updated" loginHref={profileLoginHref} + downloadUrl={downloadUrl} + isPasswordReset={isPasswordReset} /> - ) : isPasswordReset && recoveryOutcomeUnknown ? ( + ) : recoveryOutcomeUnknown ? ( - ) : (isPasswordReset && invitation.status === 'pending') || - (invitation.status === 'accepted' && invitation.passwordPending) ? ( + ) : passwordTerminal ? ( + + ) : invitationUsed ? ( + + ) : invitationExpired ? ( + + ) : showPasswordSetup ? ( - ) : invitation.status === 'accepted' ? ( - - ) : invitationExpired ? ( - ) : ( >({}) 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/app/types/api.ts b/profile-ui/app/types/api.ts index da8d19bc46..0f0a18496c 100644 --- a/profile-ui/app/types/api.ts +++ b/profile-ui/app/types/api.ts @@ -29,6 +29,9 @@ export type AcceptInvitationResponse = { userId: string | null passwordPending: boolean email: string + // Set by the edge when the invitation was already accepted: the answer is a + // success without a session, so no session cookie was set. + replay?: true } export type DesktopAuthorizationResponse = { diff --git a/profile-ui/lib/api.ts b/profile-ui/lib/api.ts index 589c2fd3cb..0b6c7cda8e 100644 --- a/profile-ui/lib/api.ts +++ b/profile-ui/lib/api.ts @@ -32,6 +32,58 @@ let globalHandleAuthError: (() => void) | null = null type ApiRequestOptions = { silentUnauthorized?: boolean + /** + * Allows one invitation-proof request through the unconfirmed-logout fence. + * Two token-plus-email proofs use it, and neither reads the surviving cookie: + * - a password-reset submission clears that cookie server-side, so the screen + * releases the fence after the server accepts the recovery action; + * - an invitation acceptance that is not a replay sets a new session cookie + * for the invitee, which replaces the fenced cookie, so `acceptInvitation` + * releases the fence; a replay sets no cookie and keeps it. + * Ordinary session requests must not bypass the fence. + */ + allowUnconfirmedLogout?: 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(): boolean { + if (typeof window === 'undefined') return false + logoutUnconfirmedInMemory = true + try { + window.localStorage.setItem(PROFILE_LOGOUT_UNCONFIRMED_KEY, 'true') + } catch { + // Only this page is fenced; the caller must not report a durable sign-out. + return false + } + return true +} + +/** + * Clears the logout fence. The fence is released only after a successful + * password login (parsed response), a confirmed logout, an accepted recovery submission, or a + * non-replay invitation acceptance that set a new session cookie; a recovery page view never + * releases it. + */ +export 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 { @@ -78,6 +130,39 @@ 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 + 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 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 + : 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 @@ -88,9 +173,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( @@ -98,6 +191,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', @@ -120,8 +220,10 @@ export async function apiSend( path: string, body?: unknown, q: Record = {}, - extraHeaders: Record = {} + extraHeaders: Record = {}, + options: ApiRequestOptions = {} ) { + if (hasUnconfirmedLogout() && !options.allowUnconfirmedLogout) handleUnauthorized() const headers: Record = { 'Content-Type': 'application/json', ...extraHeaders, @@ -136,35 +238,33 @@ export async function apiSend( if (!res.ok) { const text = await res.text() if (res.status === 401) handleUnauthorized() - let detail: string - let code: string | null = null - try { - const parsed = JSON.parse(text) as unknown - if (parsed && typeof parsed === 'object') { - const body = parsed as { error?: unknown; message?: unknown } - code = typeof body.error === 'string' ? body.error : null - detail = String(body.message || body.error || text) - } else { - detail = 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 // embedded in a wrapper string rather than as the bare { error: '' } body. - if (detail.includes('member_registration_unavailable')) { + if ( + publicError.code === 'member_registration_unavailable' || + detail.includes('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' || + detail.includes('member_registration_misconfigured') + ) { throw new Error( 'Invitations are unavailable — member registration is misconfigured. Check the server logs for details.' ) } - throw new ProfileApiError(res.status, code, `${res.status} ${res.statusText} - ${detail}`) + throw new ProfileApiError( + res.status, + publicError.code ?? null, + `${res.status} ${res.statusText} - ${detail}` + ) } if (res.status === 204) { return null @@ -184,28 +284,44 @@ 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}`) } + const body = (await res.json()) as PasswordLoginResponse clearToken() - return (await res.json()) as PasswordLoginResponse -} - -export async function logoutProfileUI(): Promise { + clearUnconfirmedLogout() + return body +} + +export async function logoutProfileUI(): Promise<{ + revocationConfirmed: boolean + fencePersisted: boolean + sessionAbsent: boolean +}> { + const fencePersisted = markLogoutUnconfirmed() + let revocationConfirmed = false + let sessionAbsent = 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', }) + 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. } finally { clearToken() } + if (revocationConfirmed) clearUnconfirmedLogout() + return { revocationConfirmed, fencePersisted, sessionAbsent } } export async function requestPasswordReset(email: string): Promise<{ requested: true }> { @@ -230,11 +346,18 @@ export async function acceptInvitation( invitationUrlId: string, email: string ): Promise { - const response = (await apiSend('POST', '/api/v1/invitations/accept', { - token: invitationUrlId, - email, - })) as AcceptInvitationResponse + const response = (await apiSend( + 'POST', + '/api/v1/invitations/accept', + { token: invitationUrlId, email }, + {}, + {}, + { allowUnconfirmedLogout: true } + )) as AcceptInvitationResponse clearToken() + // A non-replay accept set a new session cookie for the invitee, replacing the + // cookie the fence protected. A replay sets no cookie, so the fence stays. + if (!response.replay) clearUnconfirmedLogout() return response } @@ -253,12 +376,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/invitationEmail.ts b/profile-ui/lib/invitationEmail.ts new file mode 100644 index 0000000000..391095650b --- /dev/null +++ b/profile-ui/lib/invitationEmail.ts @@ -0,0 +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 + + return EMAIL_PATTERN.test(value.trim()) +} diff --git a/profile-ui/lib/invitationErrors.ts b/profile-ui/lib/invitationErrors.ts new file mode 100644 index 0000000000..54daba633b --- /dev/null +++ b/profile-ui/lib/invitationErrors.ts @@ -0,0 +1,22 @@ +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', + 'password_already_set', +]) + +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 228f839b4f..990617219b 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.202", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-profile-ui", - "version": "0.1.186", + "version": "0.1.202", "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..0662efa3f4 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.202", "private": true, "scripts": { "dev": "next dev", diff --git a/rpc-proxy/package-lock.json b/rpc-proxy/package-lock.json index fa1d65ca44..c106e87fed 100644 --- a/rpc-proxy/package-lock.json +++ b/rpc-proxy/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-rpc-proxy", - "version": "0.1.138", + "version": "0.1.139", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-rpc-proxy", - "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/rpc-proxy/package.json b/rpc-proxy/package.json index 9d42bb142e..0fc6642d3f 100644 --- a/rpc-proxy/package.json +++ b/rpc-proxy/package.json @@ -1,6 +1,6 @@ { "name": "clerum-rpc-proxy", - "version": "0.1.138", + "version": "0.1.139", "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 f29b3f84fa..6b5d1ff412 100644 --- a/rpc-proxy/src/__tests__/approval-artifacts.test.ts +++ b/rpc-proxy/src/__tests__/approval-artifacts.test.ts @@ -406,6 +406,59 @@ describe('POST /rpc/hosts/:hostRef/approvals/deny', () => { // Artifact Routes // ===================================================================== describe('GET /rpc/hosts/:hostRef/artifacts', () => { + it('forwards more than 30 mixed artifact reads', async () => { + globalThis.fetch = vi.fn().mockResolvedValue(mockFetchResponse(200, '[]')) + const app = makeApp() + + for (let requestNumber = 0; requestNumber < 35; 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) + } + + await request(app) + .get('/rpc/hosts/chatllm/artifacts/final.txt/download') + .set('authorization', 'Bearer token') + .expect(200) + + expect(serviceMock.resolveHostConnectionForUser).toHaveBeenCalledTimes(36) + expect(globalThis.fetch).toHaveBeenCalledTimes(36) + }) + + 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 } + ) + serviceMock.resolveHostConnectionForUser.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(200) + + 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' }, @@ -467,6 +520,21 @@ describe('GET /rpc/hosts/:hostRef/artifacts', () => { .expect(403) }) + it('does not forward an artifact read when Host resolution fails', async () => { + globalThis.fetch = vi.fn() + serviceMock.resolveHostConnectionForUser.mockRejectedValue( + new Error('Host resolution unavailable') + ) + const app = makeApp() + + await request(app) + .get('/rpc/hosts/chatllm/artifacts') + .set('authorization', 'Bearer token') + .expect(500) + + expect(globalThis.fetch).not.toHaveBeenCalled() + }) + it('rejects invalid hostRef before resolving host access', async () => { const app = makeApp() await request(app) 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..b172dc901e --- /dev/null +++ b/rpc-proxy/src/__tests__/control-api-gateway-parity.test.ts @@ -0,0 +1,303 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import express from 'express' +import { readFileSync, readdirSync } from 'node:fs' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import request from 'supertest' +import { config } from '../config.js' +import { createMcpOauthRouter } from '../routes/mcpOauth.js' +import { createSandboxUiSessionRouter } from '../routes/sandboxUi.js' +import { + fetchHostConnectionFromControlApi, + fetchUserAllowedServersFromControlApi, + fetchUserConnectorsFromControlApi, + requestHostWakeFromControlApi, +} from '../services/controlApiRestService.js' +import { + _clearSandboxUiRegistryCache, + listSandboxUiApps, + lookupSandboxUiRegistry, +} from '../services/sandboxUiRegistry.js' +import { createSandboxUiSession } from '../services/sandboxUiSession.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 emitters (service functions and route +// handlers), not from a hand-written list. A source inventory below fails when +// a new control-api URL is built somewhere this test does not exercise. + +const authTokenMock = vi.hoisted(() => ({ verifyRpcToken: vi.fn() })) +vi.mock('../authToken.js', () => authTokenMock) + +const USER = '00000000-0000-4000-8000-000000000001' +const HOST = 'host-a' +const RPC_CLAIMS = { + sub: USER, + typ: 'user' as const, + accessScope: 'team' as const, + teamId: 'team-1', + scopes: ['mcp:server:invoke', 'sandbox:ui:view'], + hostRefs: [HOST], + jti: 'j1', + iat: 1, + exp: 9999999999, +} + +// Every source file outside config.ts that mentions controlApiBaseUrl, with +// the number of mentions. The two services count their local +// controlApiBaseUrl() helper's definition and body; every other mention is a +// URL construction site. Any new use of the base URL, in any form, changes a +// count: add its emitter to emittedRequests() and update this inventory. +const CONTROL_API_BASE_URL_MENTIONS: Record = { + 'routes/mcpOauth.ts': 2, + 'routes/sandboxUi.ts': 3, + 'services/controlApiRestService.ts': 6, + 'services/sandboxUiRegistry.ts': 4, +} + +function productionSources(): Array<{ rel: string; source: string }> { + const srcDir = fileURLToPath(new URL('..', import.meta.url)) + const files: Array<{ rel: string; source: string }> = [] + const visit = (dir: string, relative: string): void => { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const rel = relative ? `${relative}/${entry.name}` : entry.name + if (entry.isDirectory()) { + if (entry.name !== '__tests__') visit(join(dir, entry.name), rel) + continue + } + if (!entry.name.endsWith('.ts') || entry.name.endsWith('.test.ts')) continue + files.push({ rel, source: readFileSync(join(dir, entry.name), 'utf-8') }) + } + } + visit(srcDir, '') + if (files.length === 0) throw new Error('no rpc-proxy production sources found') + return files +} + +function controlApiBaseUrlMentions(): Record { + const mentions: Record = {} + for (const { rel, source } of productionSources()) { + if (rel === 'config.ts') continue + const count = source.split('controlApiBaseUrl').length - 1 + if (count > 0) mentions[rel] = count + } + return mentions +} + +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, _init?: RequestInit) => { + const { pathname } = new URL(String(url)) + if (pathname.includes('/mcp-hosts/')) return hostConnectionBody() + if (pathname.endsWith('/wake')) return Response.json({ status: 'active', wakeGeneration: 1 }) + if (pathname.includes('/internal/sandbox-ui/registry/')) { + return Response.json({ + appRef: 'sandbox-recipes/r1', + service: { name: 'web', namespace: config.sandboxUiNamespace, port: 8080 }, + ready: true, + defaultPath: '/', + }) + } + if (pathname.endsWith('/internal/sandbox-ui/apps')) return Response.json({ apps: [] }) + if (pathname.includes('/oauth/') || pathname.includes('/mcp-oauth/')) { + return Response.json({ authorizeUrl: 'https://login.invalid/authorize' }) + } + return Response.json({ userId: USER, contextIds: [], servers: [], agents: [] }) + }) + vi.stubGlobal('fetch', fetchMock) + authTokenMock.verifyRpcToken.mockReturnValue({ ...RPC_CLAIMS }) + _clearSandboxUiRegistryCache() + 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 requestHostWakeFromControlApi(HOST, 'jwt') + await lookupSandboxUiRegistry('sandbox-recipes', 'r1', USER, 'team-1') + await listSandboxUiApps(USER, 'team-1') + + const app = express() + app.use(express.json()) + app.use('/api/v1', createMcpOauthRouter()) + app.use('/api/v1', createSandboxUiSessionRouter()) + const bearer = 'Bearer rpc-token' + await request(app).post('/api/v1/mcp-oauth/gdrive/authorize-url').set('Authorization', bearer) + await request(app).delete('/api/v1/mcp-oauth/gdrive/grant').set('Authorization', bearer) + _clearSandboxUiRegistryCache() + await request(app) + .post('/api/v1/sandbox-ui/sandbox-recipes/r1/oauth/authorize-url') + .set('Authorization', bearer) + .send({ oauthClientId: 'client-a' }) + const sandboxCookie = `${config.sandboxUiCookieName}=${createSandboxUiSession(USER, 'sandbox-recipes', 'r1')}` + await request(app) + .post('/api/v1/sandbox-ui/sandbox-recipes/r1/oauth/token') + .set('Cookie', sandboxCookie) + .send({ oauthClientId: 'client-a' }) + await request(app) + .delete('/api/v1/sandbox-ui/sandbox-recipes/r1/oauth/grant') + .set('Cookie', sandboxCookie) + .send({ oauthClientId: 'client-a' }) + + 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. The + // registry lookup appears twice: once called directly and once from the + // sandbox OAuth authorize-url route, which resolves the app first. + 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/hosts/${HOST}/wake` }, + { method: 'GET', path: '/api/v1/internal/sandbox-ui/registry/sandbox-recipes/r1' }, + { method: 'GET', path: '/api/v1/internal/sandbox-ui/apps' }, + { method: 'POST', path: '/api/v1/internal/mcp-oauth/authorize-url' }, + { method: 'DELETE', path: '/api/v1/internal/mcp-oauth/grant' }, + { method: 'GET', path: '/api/v1/internal/sandbox-ui/registry/sandbox-recipes/r1' }, + { method: 'POST', path: '/api/v1/internal/sandbox-ui/oauth/authorize-url' }, + { method: 'POST', path: '/api/v1/internal/sandbox-ui/oauth/token' }, + { method: 'DELETE', path: '/api/v1/internal/sandbox-ui/oauth/grant' }, + ]) + + const locations = gatewayLocations(gatewayNginxConf()) + const denied = requests.filter(r => !gatewayAdmits(locations, r.method, r.path)) + expect(denied).toEqual([]) + }) + + it('exercises every control-api URL construction site in rpc-proxy', () => { + expect(controlApiBaseUrlMentions()).toEqual(CONTROL_API_BASE_URL_MENTIONS) + }) + + 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. 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) + // Method gate on the proxying parent location itself. + expect(admits('DELETE', removed)).toBe(false) + expect(admits('GET', `${removed}/other`)).toBe(false) + }) +}) 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 6d4a641050..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 @@ -63,9 +63,11 @@ function makeApp() { } type ControlApiAnswer = { status: number; body?: unknown; headers?: Record } +type ControlApiRequest = { url: string; init?: RequestInit } const originalFetch = globalThis.fetch let controlApiAnswer: ControlApiAnswer let controlApiCalls: string[] +let controlApiRequests: ControlApiRequest[] let hostCalls: string[] let hostAnswer: { status: number; body: string } @@ -73,6 +75,7 @@ beforeEach(() => { vi.clearAllMocks() authTokenMock.verifyRpcToken.mockReturnValue({ ...CLAIMS }) controlApiCalls = [] + controlApiRequests = [] hostCalls = [] hostAnswer = { status: 200, body: '{}' } controlApiAnswer = { @@ -80,10 +83,11 @@ beforeEach(() => { body: CONTROL_API_403_BODY, headers: { [REASON_HEADER]: 'directory_grant_missing' }, } - globalThis.fetch = vi.fn(async (input: string | URL | Request) => { + globalThis.fetch = vi.fn(async (input: string | URL | Request, init?: RequestInit) => { const url = String(input) if (url.includes('/rpc/access/users/')) { controlApiCalls.push(url) + controlApiRequests.push({ url, init }) return new Response( controlApiAnswer.body === undefined ? null : JSON.stringify(controlApiAnswer.body), { @@ -93,6 +97,23 @@ beforeEach(() => { ) } hostCalls.push(url) + if (hostAnswer.status === 200 && hostAnswer.body === '{}') { + // Minimal route-shaped success payloads so the liveness witnesses exercise + // the same rail the route would use in production. Explicit hostAnswer + // overrides in a test always win. + if (url.endsWith('/v1/runtime/status')) { + return new Response(JSON.stringify({ agent: { state: 'running' }, queue: {} }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }) + } + if (url.endsWith('/rpc/hosts/chatllm/wake')) { + return new Response(JSON.stringify({ status: 'active' }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }) + } + } return new Response(hostAnswer.body, { status: hostAnswer.status, headers: { 'content-type': 'application/json' }, @@ -175,11 +196,44 @@ const DENYING_ROUTES: RouteCase[] = [ }, ] -async function send(route: RouteCase) { +function send(route: RouteCase) { const pending = request(makeApp())[route.method](route.path).set('authorization', 'Bearer tok') return route.body ? pending.send(route.body) : pending } +const CONTROL_API_CONNECTION = { + userId: CLAIMS.sub, + hostRef: 'chatllm', + url: 'http://chatllm.mcp-host.svc.cluster.local:8080', + bindingStatus: 'recorded', +} + +/** + * Liveness witness for the negative `hostCalls` assertions: run the same route + * with a successful control-api authorization and prove it reaches its Host + * rail. A long-lived SSE route never completes, so its response is aborted once + * the Host call is observable. + */ +async function proveAuthorizedForwarding(route: RouteCase): Promise { + controlApiAnswer = { status: 200, body: CONTROL_API_CONNECTION } + const pending = send(route) + let settled = false + const completion = pending.then( + () => { + settled = true + }, + () => { + settled = true + } + ) + const deadline = Date.now() + 2_000 + while (!settled && hostCalls.length === 0 && Date.now() < deadline) { + await new Promise(resolve => setTimeout(resolve, 5)) + } + if (!settled) pending.abort() + await completion +} + describe('host authorization 403 carries a machine-readable code at every site', () => { it.each(DENYING_ROUTES)( '$label: membership/grant revocation -> host_access_revoked', @@ -194,16 +248,110 @@ describe('host authorization 403 carries a machine-readable code at every site', } ) - it.each(DENYING_ROUTES)('$label: a missing host (404) -> host_access_denied', async route => { - controlApiAnswer = { status: 404, body: { error: 'not found' } } + it.each(DENYING_ROUTES)( + '$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 + // contacted its Host rail, so both counters are live before the refusal. + expect(controlApiCalls).toHaveLength(1) + expect(hostCalls).toHaveLength(1) - const res = await send(route) + controlApiAnswer = { status: 404, body: { error: 'not found' } } + + const res = await send(route) + // Witness: the refused request still reached control-api (authorization + // ran) and contacted no Host rail. + expect(controlApiCalls).toHaveLength(2) + expect(hostCalls).toHaveLength(1) + 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: 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) - expect(hostCalls).toEqual([]) + expect(hostCalls).toHaveLength(1) + + controlApiAnswer = { + status: 403, + body: CONTROL_API_403_BODY, + headers: { [REASON_HEADER]: 'host_disabled' }, + } + + const res = await send(messages) + + // The refused request still reached control-api and did not forward. + expect(controlApiCalls).toHaveLength(2) + expect(hostCalls).toHaveLength(1) expect(res.status).toBe(403) expect(res.body).toEqual({ error: DENIED_ERROR, code: 'host_access_denied' }) }) + + 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', + path: '/rpc/hosts/chatllm/messages', + body: { content: 'hi', threadId: 'thread-1' }, + } + await proveAuthorizedForwarding(threaded) + expect(controlApiCalls).toHaveLength(1) + expect(hostCalls).toHaveLength(1) + + // 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), + sessionId: 'thread-1', + origin: 'direct_chat', + }) + + controlApiAnswer = { status: 404, body: { error: 'not found' } } + + const res = await send(threaded) + + expect(controlApiCalls).toHaveLength(2) + expect(hostCalls).toHaveLength(1) + 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. An unbound control-api 404 is that + // denial, not an upstream failure. + it.each([ + { + label: 'task result', + method: 'get' as const, + path: '/rpc/hosts/chatllm/tasks/task-1/result', + }, + { + label: 'task cancel', + method: 'post' as const, + path: '/rpc/hosts/chatllm/tasks/task-1/cancel', + }, + ])('$label: an unbound control-api 404 stays Host-not-accessible', async route => { + await proveAuthorizedForwarding(route) + expect(controlApiCalls).toHaveLength(1) + expect(hostCalls).toHaveLength(1) + + controlApiAnswer = { status: 404, body: { error: 'not found' } } + + const res = await send(route) + + expect(controlApiCalls).toHaveLength(2) + expect(hostCalls).toHaveLength(1) + expect(res.status).toBe(404) + expect(res.body).toEqual({ error: 'Host not found or not accessible' }) + }) }) describe('R3-L8 hostAccessDenialCodeForReason', () => { 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..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 @@ -112,11 +112,12 @@ 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) } /** Ends the TCP connection without a response: undici reports it as `fetch failed`. */ @@ -720,7 +721,8 @@ 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 = 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 +739,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__/sender-assignment.test.ts b/rpc-proxy/src/__tests__/sender-assignment.test.ts index 60f1591f0c..c306feee62 100644 --- a/rpc-proxy/src/__tests__/sender-assignment.test.ts +++ b/rpc-proxy/src/__tests__/sender-assignment.test.ts @@ -150,6 +150,19 @@ describe('POST /rpc/hosts/:hostRef/messages — sender assignment invariant', () const forwardedBody = serviceMock.forwardHostMessageToHost.mock.calls[0][1] expect(forwardedBody.metadata).toEqual({ accessScope: 'user' }) }) + + it('does not forward a message when Host resolution fails', async () => { + serviceMock.resolveHostConnectionForUser.mockRejectedValue(new Error('Host resolution failed')) + + await request(makeApp()) + .post('/rpc/hosts/chatllm/messages') + .set('authorization', `Bearer ${crypto.randomUUID()}`) + .send({ content: 'hello' }) + .expect(502) + + expect(serviceMock.resolveHostConnectionForUser).toHaveBeenCalledOnce() + 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-idempotent-gets.real-http.test.ts b/rpc-proxy/src/__tests__/wake-and-hold-idempotent-gets.real-http.test.ts index 2f1b8c5025..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 @@ -92,11 +92,12 @@ 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) } /** 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 cb35bfa87f..07a7f87f97 100644 --- a/rpc-proxy/src/__tests__/wake-and-hold-route.test.ts +++ b/rpc-proxy/src/__tests__/wake-and-hold-route.test.ts @@ -133,6 +133,7 @@ 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(controlApiMock.requestHostWakeFromControlApi).toHaveBeenCalledTimes(1) expect(controlApiMock.requestHostWakeFromControlApi).toHaveBeenCalledWith('chatllm', 'token') expect(serviceMock.forwardHostMessageToHost).toHaveBeenCalledTimes(2) @@ -177,6 +178,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 @@ -211,6 +213,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/sandboxUi.test.ts b/rpc-proxy/src/routes/sandboxUi.test.ts index f6aa82338e..9d79cdd881 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,99 @@ 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]) + }) + + 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 ─────────────────────────────────── diff --git a/rpc-proxy/src/routes/sandboxUi.ts b/rpc-proxy/src/routes/sandboxUi.ts index dfd3e66e48..0147270b2e 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 once-only 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. diff --git a/rpc-proxy/test/services.controlApiHostAccess.test.ts b/rpc-proxy/test/services.controlApiHostAccess.test.ts index 0d2e1ac093..79343698f1 100644 --- a/rpc-proxy/test/services.controlApiHostAccess.test.ts +++ b/rpc-proxy/test/services.controlApiHostAccess.test.ts @@ -11,6 +11,35 @@ const BINDING = { } describe('control-api canonical host access client', () => { + it('resolves an ordinary send with one GET to the parent host-access URL', async () => { + const fetchImpl = vi.fn( + async () => + 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( + fetchHostConnectionFromControlApi('user-1', 'host-a', 'signed-rpc-token', { + fetchImpl: fetchImpl as typeof fetch, + }) + ).resolves.toEqual({ + name: 'host-a', + url: 'http://host-a.mcp-host.svc.cluster.local:8080', + headers: {}, + }) + + 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 () => { const fetchImpl = vi.fn( async () => @@ -153,7 +182,7 @@ describe('control-api canonical host access client', () => { expect(fetchImpl).toHaveBeenCalledOnce() }) - it('a non-direct-run 404 resolves to host_access_denied', async () => { + it('a non-direct-run 404 resolves to host_access_denied on the parent path', async () => { const fetchImpl = jsonResponse(404, { error: 'not found' }) await expect( @@ -162,6 +191,30 @@ describe('control-api canonical host access client', () => { }) ).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' }) + }) + + 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' }, + }) + ) + + 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/ci/certification-inputs.mjs b/scripts/ci/certification-inputs.mjs new file mode 100644 index 0000000000..11de8c0854 --- /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, + 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') { + // 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) + } + 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/e2e-rpc-gateway-host-paths.sh b/scripts/e2e/e2e-rpc-gateway-host-paths.sh new file mode 100755 index 0000000000..a3a04d8a52 --- /dev/null +++ b/scripts/e2e/e2e-rpc-gateway-host-paths.sh @@ -0,0 +1,327 @@ +#!/usr/bin/env bash +# ====================================================================== +# 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 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. 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 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: +# T2_HEALTHCHECK_COMMAND="KUBECONTEXT= E2E_PROFILE_PORTS_ENV= \ +# bash scripts/e2e/e2e-rpc-gateway-host-paths.sh" +# +# Required environment: +# KUBECONTEXT (or E2E_K8S_CONTEXT) explicit branch-profile context +# E2E_PROFILE_PORTS_ENV //ports.env of +# that same profile (T2 exports it as +# T2_PORTS_ENV); the External REST and +# rpc-proxy URLs come from it, never from +# fixed ports +# E2E_DEV_LOGIN_EMAIL seeded Desktop login for the Host owner +# E2E_USER_PASSWORD or ADMIN_PASSWORD +# Optional: +# E2E_GATEWAY_HOST_REF (default chatllm) +# E2E_TURN_TIMEOUT (default 120), J4_DEADLINE_SECONDS (default 300) +# +# J4_DEADLINE_SECONDS is the run budget: no request, kubectl call or retry +# sleep starts once it is spent, each gets at most the remaining budget, and +# 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 + exit 2 +fi +require_safe_kube_context +if [ -z "${E2E_PROFILE_PORTS_ENV:-}" ] || [ ! -r "$E2E_PROFILE_PORTS_ENV" ]; then + echo "J4 requires E2E_PROFILE_PORTS_ENV pointing at the branch profile ports.env" >&2 + exit 2 +fi +# A profile keeps its ports map at //ports.env +# (scripts/minikube-profiles/branch-profile.sh set_profile_paths), and the +# Minikube context is named after the profile. A ports file from another +# profile would send the login and the message to that profile's services. +ports_profile="$(basename "$(cd "$(dirname "$E2E_PROFILE_PORTS_ENV")" && pwd -P)")" +if [ "$ports_profile" != "$E2E_KUBECONTEXT" ]; then + echo "J4 refuses to run: ${E2E_PROFILE_PORTS_ENV} belongs to profile ${ports_profile}, not ${E2E_KUBECONTEXT}" >&2 + exit 2 +fi +profile_port_value() { + awk -F= -v key="$1" '$1 == key { sub(/^[^=]*=/, ""); print; exit }' "$E2E_PROFILE_PORTS_ENV" +} +CONTROL_API_BASE_URL="$(profile_port_value CONTROL_API_URL)" +CONTROL_UI_BASE_URL="$(profile_port_value CONTROL_UI_URL)" +EXTERNAL_REST_API_BASE_URL="$(profile_port_value EXTERNAL_REST_API_URL)" +RPC_PROXY_BASE_URL="$(profile_port_value RPC_PROXY_URL)" +export CONTROL_API_BASE_URL CONTROL_UI_BASE_URL EXTERNAL_REST_API_BASE_URL RPC_PROXY_BASE_URL +if ! require_branch_profile_urls "$E2E_KUBECONTEXT" "$E2E_PROFILE_PORTS_ENV"; then + echo "J4 refuses to run: profile URLs are missing from ${E2E_PROFILE_PORTS_ENV}" >&2 + exit 2 +fi +: "${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%/}" +RPC_BASE="${RPC_PROXY_BASE_URL%/}" +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}" +# The budget arithmetic is integer-only; a fractional value would make the +# comparisons error out and skip the cap. +for setting in E2E_TURN_TIMEOUT J4_DEADLINE_SECONDS; do + if ! [[ "${!setting}" =~ ^[1-9][0-9]*$ ]]; then + echo "J4 requires ${setting} to be a positive integer number of seconds, got '${!setting}'" >&2 + exit 2 + fi +done +RUN_ID="$(date +%s)-$$-${RANDOM}" +MARKER="j4-gateway-${RUN_ID}" +THREAD_ID="e2e-rpc-gateway-host-paths-${RUN_ID}" +STARTED_SECONDS=$SECONDS + +remaining_seconds() { + printf '%s\n' $((J4_DEADLINE_SECONDS - (SECONDS - STARTED_SECONDS))) +} + +within_deadline() { + if [ "$(remaining_seconds)" -le 0 ]; then + fail "J4 deadline of ${J4_DEADLINE_SECONDS}s expired before: $1" + print_results || true + exit 1 + fi +} + +# Cap a per-call timeout at the remaining run budget. +call_budget() { + local requested=$1 remaining + remaining="$(remaining_seconds)" + if [ "$remaining" -lt "$requested" ]; then + printf '%s\n' "$remaining" + else + printf '%s\n' "$requested" + fi +} + +# Every kubectl call names the context and cannot outlive the run budget. +kctl() { + local budget + budget="$(call_budget 30)" + if [ "$budget" -le 0 ]; then + echo "J4 deadline expired before kubectl $*" >&2 + return 124 + fi + node "${ROOT}/scripts/minikube/run-with-deadline.mjs" \ + --timeout-seconds "$budget" --kill-grace-seconds 2 --heartbeat-seconds 30 \ + --label j4-kubectl -- "$KUBECTL_BIN" --context="$E2E_KUBECONTEXT" \ + --request-timeout="${budget}s" "$@" +} + +# Credentials never reach argv, where any local user can read them with ps: +# http_call (e2e-rpc-http-lib.sh) sends request bodies on stdin and bearer +# tokens as a header read from a file descriptor, never from a file on disk. + +header "Prerequisites" +for bin in jq curl node; do + command -v "$bin" >/dev/null 2>&1 || { fail "missing required binary: $bin"; print_results || true; exit 1; } +done +for base in "$EXT_BASE" "$RPC_BASE"; do + if http_call health 10 '' '' -o /dev/null "${base}/health" && [ "$HTTP_STATUS" = "200" ]; then + ok "${base}/health → 200" + else + fail "${base}/health → ${HTTP_STATUS} (port-forward down or service unhealthy)" + print_results || true + exit 1 + fi +done +if ! gateway_pods="$(kctl -n "$GATEWAY_NS" get pods -l "$GATEWAY_SELECTOR" \ + --field-selector=status.phase=Running -o name)"; then + fail "cannot list control-api-rpc-gateway pods in ${GATEWAY_NS}" + print_results || true + exit 1 +fi +if [ -n "$gateway_pods" ]; then + ok "control-api-rpc-gateway pods running: $(printf '%s' "$gateway_pods" | tr '\n' ' ')" +else + fail "no Running control-api-rpc-gateway pod in ${GATEWAY_NS}" + kctl -n "$GATEWAY_NS" get pods -l "$GATEWAY_SELECTOR" -o wide >&2 || true + print_results || true + exit 1 +fi + +header "Tokens" +SESSION_TOKEN='' +# jq reads the login from its own environment, so the password is in no argv. +login_body="$(E2E_DEV_LOGIN_EMAIL="$E2E_DEV_LOGIN_EMAIL" DEV_PASSWORD="$DEV_PASSWORD" \ + jq -cn '{email: env.E2E_DEV_LOGIN_EMAIL, password: env.DEV_PASSWORD}')" +if http_call password-login 30 "$login_body" '' -X POST "${EXT_BASE}/api/v1/auth/password-login" \ + -H 'Content-Type: application/json'; then + SESSION_TOKEN="$(printf '%s' "$HTTP_BODY" | jq -r '.token // empty' 2>/dev/null || true)" +fi +unset login_body +if [ "$HTTP_STATUS" = "200" ] && [ -n "$SESSION_TOKEN" ]; then + ok "session token minted for ${E2E_DEV_LOGIN_EMAIL}" +else + fail "password-login → HTTP ${HTTP_STATUS} (no session token)" + print_results || true + exit 1 +fi + +mint_rpc_token() { + RPC_TOKEN='' + http_call rpc/token 30 \ + "$(jq -cn --arg h "$HOST_REF" '{hostRefs: [$h], scopes: ["host:message:invoke", "host:task:read"]}')" \ + "$SESSION_TOKEN" \ + -X POST "${EXT_BASE}/api/v1/rpc/token" \ + -H 'Content-Type: application/json' || + return 1 + RPC_TOKEN="$(printf '%s' "$HTTP_BODY" | jq -r '.token // empty' 2>/dev/null || true)" + [ "$HTTP_STATUS" = "200" ] && [ -n "$RPC_TOKEN" ] +} +if mint_rpc_token; then + ok "RPC token minted for hostRef=${HOST_REF}" +else + # An auth response body can carry a token: report the status only. + fail "rpc/token → HTTP ${HTTP_STATUS}" + print_results || true + exit 1 +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 the parent Host path" +attempts=0 +message_sent=false +while :; do + within_deadline "message send" + attempts=$((attempts + 1)) + if ! mint_rpc_token; then + fail "rpc/token re-mint → HTTP ${HTTP_STATUS}" + break + fi + if ! http_call message "$E2E_TURN_TIMEOUT" \ + "$(jq -cn --arg c "J4 gateway check ${MARKER}. Reply with OK." --arg t "$THREAD_ID" '{content: $c, threadId: $t}')" \ + "$RPC_TOKEN" \ + -X POST "${RPC_BASE}/api/v1/rpc/hosts/${HOST_REF}/messages" \ + -H 'Content-Type: application/json'; then + fail "POST /rpc/hosts/${HOST_REF}/messages: no HTTP response" + break + fi + # Only the documented retryable wake signal is retried, with a bounded budget. + if [ "$HTTP_STATUS" = "503" ] && grep -q 'host_waking' <<<"$HTTP_BODY" && [ "$attempts" -lt 5 ]; then + warn "message got retryable 503 host_waking (attempt ${attempts}/5)" + # A spent budget skips the sleep; the next within_deadline fails the run. + pause="$(call_budget 5)" + if [ "$pause" -gt 0 ]; then + sleep "$pause" + fi + continue + fi + message_sent=true + break +done +if [ "$message_sent" = true ]; then + if [ "$HTTP_STATUS" = "200" ] && jq -e '.success == true' >/dev/null 2>&1 <<<"$HTTP_BODY"; then + ok "POST /rpc/hosts/${HOST_REF}/messages → 200 success=true" + else + fail "POST /rpc/hosts/${HOST_REF}/messages → HTTP ${HTTP_STATUS}: ${HTTP_BODY}" + fi +fi + +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" +elif ! http_call artifacts 30 '' "$RPC_TOKEN" "${RPC_BASE}/api/v1/rpc/hosts/${HOST_REF}/artifacts"; then + fail "GET /rpc/hosts/${HOST_REF}/artifacts: no HTTP response" +elif [ "$HTTP_STATUS" = "200" ]; then + ok "GET /rpc/hosts/${HOST_REF}/artifacts → 200" +else + fail "GET /rpc/hosts/${HOST_REF}/artifacts → HTTP ${HTTP_STATUS}: ${HTTP_BODY}" +fi + +header "Gateway access log" +within_deadline "gateway log read" +gateway_log='' +if ! gateway_log="$(kctl -n "$GATEWAY_NS" logs -l "$GATEWAY_SELECTOR" --since-time="$LOG_SINCE" \ + --tail=-1 --max-log-requests=10)"; then + fail "cannot read control-api-rpc-gateway logs" + gateway_log='' +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 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} .../mcp-hosts/${HOST_REF} → 200" + else + 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} .../mcp-hosts/${HOST_REF} with 403 (missing or wrong location)" + else + ok "gateway logged no 403 for ${method} .../mcp-hosts/${HOST_REF}" + fi +} +check_gateway_line POST +check_gateway_line GET + +header "Persisted user turn" +within_deadline "state.db read" +pod="$(ready_pod_name "$MCP_HOST_NS" "app=${HOST_REF}" || true)" +if [ -z "$pod" ]; then + fail "no Ready pod for app=${HOST_REF} in ${MCP_HOST_NS}" +else + db_dir="$(kctl exec "$pod" -n "$MCP_HOST_NS" -- printenv CLERUM_SESSION_DB_DIR 2>/dev/null || true)" + if [ -z "$db_dir" ]; then + fail "pod ${pod} has no CLERUM_SESSION_DB_DIR" + else + db_path="${db_dir%/}/state.db" + sql="SELECT COUNT(*) FROM messages WHERE role='user' AND (content LIKE '%${MARKER}%' OR content_parts LIKE '%${MARKER}%')" + 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("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 + ok "user turn ${MARKER} persisted once in ${db_path}" + else + fail "expected 1 persisted user turn with ${MARKER} in ${db_path}, found '${count}'" + fi + fi +fi + +within_deadline "results" +print_results diff --git a/scripts/e2e/e2e-rpc-http-lib.sh b/scripts/e2e/e2e-rpc-http-lib.sh new file mode 100755 index 0000000000..90fbfa7b2d --- /dev/null +++ b/scripts/e2e/e2e-rpc-http-lib.sh @@ -0,0 +1,44 @@ +# shellcheck shell=bash +# shellcheck disable=SC2034 # HTTP_STATUS and HTTP_BODY are outputs read by the caller. +# HTTP helper for the RPC gateway Host-path journey +# (e2e-rpc-gateway-host-paths.sh). Sourced by the harness; defines functions +# only. The caller defines `call_budget `, which caps a request at the +# remaining run budget. +{ set +x; } 2>/dev/null + +# http_call