diff --git a/.changeset/23190-auth-gate-cli-sites.md b/.changeset/23190-auth-gate-cli-sites.md new file mode 100644 index 00000000000..994e089e60f --- /dev/null +++ b/.changeset/23190-auth-gate-cli-sites.md @@ -0,0 +1,24 @@ +--- +'@objectstack/runtime': minor +'@objectstack/rest': minor +'@objectstack/cloud-connection': minor +--- + +fix(runtime,rest,cloud-connection)!: the declared-endpoint fallback, REST's direct-mounted publish and federation routes, and cloud-connection's session doors refuse a session the ADR-0069 authentication policy holds + +Clause-②: no (narrowing) — a session ADR-0069 holds out of protected resources is refused by doors that answered it; core's existing exports only, no published schema or export moves. + + + +**BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. + +ADR-0069 holds a signed-in user out of every protected resource while their password has expired or while enforced MFA is not yet enrolled, until they remediate. REST's `enforceAuth` and the runtime dispatcher already refuse such a session. Three groups of doors that resolve a session on their own never asked, so they served it as an ordinary signed-in caller: + +- **`@objectstack/runtime`**: the declared-endpoint fallback (app-declared `apis:` endpoints, served by the dispatcher plugin's fallback seam). Its policy chain now asks the shared decision (`evaluateAuthGate`, `@objectstack/core`) before `authRequired` and before the endpoint's target runs, on every declared endpoint, an `authRequired: false` one included, because the target runs as the caller. While a gate feature is active the chain reads the session for this, and when an active gate cannot read the session the request is answered `503` (`SERVICE_UNAVAILABLE`), as REST answers it. No declared path is handed to the allow-list, so no endpoint can exempt itself through the prefix it sits under. +- **`@objectstack/rest`**: the direct-mounted package publish door (`registerPackageRoutes`) and the external-datasource federation family (`registerExternalDatasourceRoutes`). Each refuses on the `authGate` the REST resolver already puts on the execution context it hands them. +- **`@objectstack/cloud-connection`**: `CloudConnectionPlugin`'s seven session doors (bind, poll, unbind, install, and the three installed-state reads) and all five `MarketplaceInstallLocalPlugin` doors (install, list, uninstall, reseed, purge). Each plugin asks the gate once, where it resolves the session, and every door that resolves one refuses on it. + +- **Refused now:** a held session gets `403` with the gate's own `code` (`PASSWORD_EXPIRED` or `MFA_REQUIRED`) and `message`, in each door's declared error envelope, before the door does anything else. This is the answer REST gives the same session. +- **Unchanged:** a session the policy does not hold is served exactly as before, an API-key caller is unaffected because it has no session to hold, and an anonymous caller gets the same `401`. `CloudConnectionPlugin`'s status read and `MarketplaceProxyPlugin`'s catalog proxy resolve no session and answer every caller alike, so they are unchanged. + +What changes for you. A user the policy holds must remediate (change the password, or enroll an authenticator) before these doors answer them, as on every other protected route. Nothing needs configuring: the posture comes from the kernel's `auth` service, as it does for REST and the dispatcher. diff --git a/packages/cloud-connection/src/cloud-connection-auth-gate.pin.test.ts b/packages/cloud-connection/src/cloud-connection-auth-gate.pin.test.ts new file mode 100644 index 00000000000..ee57748b85e --- /dev/null +++ b/packages/cloud-connection/src/cloud-connection-auth-gate.pin.test.ts @@ -0,0 +1,384 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * ADR-0069 at cloud-connection's raw-app plugins: a session the authentication + * policy HOLDS (an expired password, enforced MFA not yet enrolled) is refused + * by every door that resolves a session, with REST's answer (`403`, the gate's + * `code` and `message`), before any door logic runs. + * + * ## The two plugins that resolve a session, and where each consults + * + * - `CloudConnectionPlugin` reads the session in ONE place + * (`sessionFromAuthService`, under both resolution modes). Seven of its + * eight doors call it; `/status` reads no session at all. + * - `MarketplaceInstallLocalPlugin` reads it in ONE place + * (`resolveInstallPrincipal`), and all five doors answer a held session + * through the same refusal (`refuseHeldSession`). + * + * Each consults `evaluateAuthGate` (`@objectstack/core`) once, with no request + * path, so the shared allow-list can grant no exemption. No route either plugin + * mounts is allow-listed (asserted below), so there is no exemption to keep. + * + * `MarketplaceProxyPlugin` is not here: it resolves no session. It forwards the + * public catalog with no credential, and every caller gets the same answer. + * + * ## The cases, per plugin + * + * - HELD: under both postures, every session door answers `403` with the + * gate's `code` and `message`, and no effect fires (no control-plane call, + * no credential cleared; no registry, schema, ledger, seed or delete write). + * - UN-HELD: the same caller and grants, without a posture, are served. + * - Anonymous is refused `401` as before, and `/status` is untouched. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { mkdtempSync, rmSync, existsSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { isAuthGateAllowlisted } from '@objectstack/core'; + +const seedCalls: unknown[] = []; +vi.mock('@objectstack/runtime', () => ({ + SeedLoaderService: class { + async load(request: unknown) { + seedCalls.push(request); + return { summary: { totalInserted: 2, totalUpdated: 0, totalSkipped: 0 }, errors: [] }; + } + async buildDependencyGraph(objectNames: string[]) { + return { + nodes: objectNames.map((object) => ({ object, dependsOn: [], references: [] })), + insertOrder: objectNames, + circularDependencies: [], + }; + } + }, + recordSeedOutcome: vi.fn(), +})); + +import { assertEngineDeleteDispatch } from '@objectstack/metadata-core'; +import { CloudConnectionPlugin } from './cloud-connection-plugin.js'; +import { ConnectionCredentialStore } from './connection-credential-store.js'; +import { MarketplaceInstallLocalPlugin } from './marketplace-install-local-plugin.js'; +import { bindCatalogFromTables } from './__tests__/security-catalog.testkit.js'; + +const PASSWORD_EXPIRED = { code: 'PASSWORD_EXPIRED', message: 'You must change your password before continuing.' }; +const MFA_REQUIRED = { + code: 'MFA_REQUIRED', + message: 'Multi-factor authentication is required. Please set up an authenticator app to continue.', +}; +const HELD = [ + { user: 'u_held_pw', gate: PASSWORD_EXPIRED }, + { user: 'u_held_mfa', gate: MFA_REQUIRED }, +] as const; +/** The posture each test user's session carries. The grants are the same for all three. */ +const POSTURES: Record = { + u_held_pw: PASSWORD_EXPIRED, + u_held_mfa: MFA_REQUIRED, + u_member: undefined, +}; + +/** A better-auth session read keyed by the `x-test-user` header, counting its calls. */ +function sessionAuth() { + const reads = { count: 0 }; + return { + reads, + api: { + getSession: async ({ headers }: { headers: Headers }) => { + reads.count++; + const uid = headers?.get?.('x-test-user'); + if (!uid || !(uid in POSTURES)) return null; + const authGate = POSTURES[uid]; + return { user: { id: uid, ...(authGate ? { authGate } : {}) }, session: {} }; + }, + }, + }; +} + +type Handler = (c: any) => Promise; + +function makeRawApp() { + const routes = new Map(); + return { + routes, + get: (p: string, h: Handler) => routes.set(`GET ${p}`, h), + post: (p: string, h: Handler) => routes.set(`POST ${p}`, h), + delete: (p: string, h: Handler) => routes.set(`DELETE ${p}`, h), + }; +} + +function makeC(url: string, user: string | undefined, body: unknown = {}, manifestId?: string) { + const headers = new Headers(user ? { 'x-test-user': user } : {}); + return { + req: { + url, + raw: new Request(url, { headers }), + header: (n: string) => headers.get(n) ?? undefined, + json: async () => body, + param: () => manifestId, + }, + json: (payload: any, status?: number) => ({ payload, status: status ?? 200 }), + }; +} + +let dir: string; +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'cc-gate-pin-')); + seedCalls.length = 0; +}); +afterEach(() => { + rmSync(dir, { recursive: true, force: true }); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +// --------------------------------------------------------------------------- +// CloudConnectionPlugin +// --------------------------------------------------------------------------- + +const CC = '/api/v1/cloud-connection'; +const ORIGIN = 'https://env.example.test'; + +/** The seven doors that resolve a session, each with what it needs to reach the control plane. */ +const CC_SESSION_DOORS = [ + { route: `POST ${CC}/bind/start`, url: `${ORIGIN}${CC}/bind/start`, body: {} }, + { route: `POST ${CC}/bind/poll`, url: `${ORIGIN}${CC}/bind/poll`, body: { device_code: 'dc_1' } }, + { route: `POST ${CC}/unbind`, url: `${ORIGIN}${CC}/unbind`, body: {} }, + { route: `POST ${CC}/install`, url: `${ORIGIN}${CC}/install`, body: { package_id: 'pkg_1' } }, + { route: `GET ${CC}/installation`, url: `${ORIGIN}${CC}/installation?package_id=pkg_1`, body: {} }, + { route: `GET ${CC}/installed`, url: `${ORIGIN}${CC}/installed`, body: {} }, + { route: `GET ${CC}/org-packages`, url: `${ORIGIN}${CC}/org-packages`, body: {} }, +] as const; + +async function mountCloudConnection(mode: 'single' | 'multi') { + const rawApp = makeRawApp(); + const hooks = new Map(); + const auth = sessionAuth(); + const credentialPath = join(dir, 'cloud-connection.json'); + const store = new ConnectionCredentialStore(credentialPath); + store.write({ runtimeToken: 'oscc_pin', runtimeId: 'rt_1', environmentId: 'env-1' }); + const services: Record = mode === 'single' + ? { auth } + : { + 'env-registry': { resolveByHostname: async () => ({ environmentId: 'env-1' }) }, + 'kernel-manager': { getOrCreate: async () => ({ getServiceAsync: async () => auth }) }, + }; + const ctx = { + hook: (e: string, h: any) => hooks.set(e, h), + getService: (name: string) => { + if (name === 'http-server') return { getRawApp: () => rawApp }; + if (name in services) return services[name]; + throw new Error(`service ${name} not registered`); + }, + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, + }; + const fetchCalls: string[] = []; + vi.stubGlobal('fetch', vi.fn(async (url: string) => { + fetchCalls.push(String(url)); + // One body that every door can read: the device-token grant reads + // `access_token` at the top level, the others read `data`. + return new Response(JSON.stringify({ success: true, access_token: 'at_1', data: { installed: false, packages: [], items: [] } }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); + })); + await new CloudConnectionPlugin({ + controlPlaneUrl: 'http://cloud.test', + controlPlaneApiKey: 'svc-key', + credentialPath, + ...(mode === 'single' ? { singleEnvironment: true, environmentId: 'env-1' } : {}), + }).start(ctx as any); + await hooks.get('kernel:ready')?.(); + return { rawApp, auth, fetchCalls, credentialPath }; +} + +describe('ADR-0069 at CloudConnectionPlugin', () => { + it('census: eight routes, none allow-listed; seven resolve a session and /status resolves none', async () => { + const { rawApp, auth } = await mountCloudConnection('single'); + const keys = [...rawApp.routes.keys()]; + expect(keys).toHaveLength(8); + for (const key of keys) expect(isAuthGateAllowlisted(key.split(' ')[1]), key).toBe(false); + expect(keys.filter((k) => k !== `GET ${CC}/status`).sort()).toEqual(CC_SESSION_DOORS.map((d) => d.route).sort()); + await rawApp.routes.get(`GET ${CC}/status`)!(makeC(`${ORIGIN}${CC}/status`, 'u_held_pw')); + expect(auth.reads.count, '/status read a session').toBe(0); + }); + + for (const mode of ['single', 'multi'] as const) { + for (const { user, gate } of HELD) { + it(`HELD (${gate.code}, ${mode}-environment): all seven session doors answer 403 with the gate's code, and nothing reaches the control plane`, async () => { + const { rawApp, fetchCalls, credentialPath } = await mountCloudConnection(mode); + for (const door of CC_SESSION_DOORS) { + const res = await rawApp.routes.get(door.route)!(makeC(door.url, user, door.body)); + expect(res.status, door.route).toBe(403); + expect(res.payload, door.route).toEqual({ success: false, error: { code: gate.code, message: gate.message } }); + } + expect(fetchCalls, 'a held session reached the control plane').toEqual([]); + expect(new ConnectionCredentialStore(credentialPath).read()?.runtimeToken, 'unbind cleared the credential').toBe('oscc_pin'); + }); + } + } + + it('UN-HELD: the same caller without a posture is served by all seven doors', async () => { + const { rawApp, fetchCalls } = await mountCloudConnection('single'); + for (const door of CC_SESSION_DOORS) { + const before = fetchCalls.length; + const res = await rawApp.routes.get(door.route)!(makeC(door.url, 'u_member', door.body)); + expect(res.status, `${door.route}: ${JSON.stringify(res.payload)}`).toBe(200); + expect(fetchCalls.length, `${door.route} did not reach the control plane`).toBeGreaterThan(before); + } + }); + + it('anonymous: every session door still answers 401 UNAUTHENTICATED', async () => { + const { rawApp, fetchCalls } = await mountCloudConnection('single'); + for (const door of CC_SESSION_DOORS) { + const res = await rawApp.routes.get(door.route)!(makeC(door.url, undefined, door.body)); + expect(res.status, door.route).toBe(401); + expect(res.payload?.error?.code, door.route).toBe('UNAUTHENTICATED'); + } + expect(fetchCalls).toEqual([]); + }); +}); + +// --------------------------------------------------------------------------- +// MarketplaceInstallLocalPlugin +// --------------------------------------------------------------------------- + +const IL = '/api/v1/marketplace/install-local'; +const APP = { + id: 'com.acme.gated', + namespace: 'gated', + version: '1.0.0', + objects: [{ name: 'widget', fields: { code: { type: 'text' } } }], + data: [{ object: 'widget', externalId: 'code', records: [{ code: 'a' }, { code: 'b' }] }], +}; +const LEDGER_FILE = 'com.acme.gated.json'; + +/** Every test user holds the authoring capability, so a refusal below is the gate's alone. */ +function grantRows(): Record { + return { + sys_user: Object.keys(POSTURES).map((id) => ({ id, email: `${id}@acme.test` })), + sys_member: [], + sys_user_position: [], + sys_position: [], + sys_metadata_activation: [], + sys_position_permission_set: [], + sys_user_permission_set: Object.keys(POSTURES).map((id, i) => ({ + id: `ups${i}`, user_id: id, permission_set_id: 'ps1', permission_set: 'admin_full_access', organization_id: null, + })), + sys_permission_set: [{ id: 'ps1', name: 'admin_full_access', system_permissions: ['manage_metadata', 'studio.access', 'setup.access'] }], + }; +} + +async function mountInstallLocal(storageDir: string) { + const register = vi.fn(async () => undefined); + const syncSchemas = vi.fn(async () => undefined); + const widgets = [{ id: 'w1', code: 'a' }, { id: 'w2', code: 'b' }]; + const engineDelete = vi.fn(async (object: string, options?: any) => { + const dispatch = assertEngineDeleteDispatch(options); + if (object !== 'widget' || dispatch.kind !== 'by-id') return false; + const at = widgets.findIndex((w) => w.id === dispatch.id); + if (at < 0) return false; + widgets.splice(at, 1); + return true; + }); + const rawApp = makeRawApp(); + const hooks = new Map(); + const rows = grantRows(); + const services: Record = { + manifest: { register }, + auth: sessionAuth(), + objectql: bindCatalogFromTables({ + syncSchemas, + find: async (object: string) => (object === 'widget' ? widgets.map((w) => ({ ...w })) : rows[object] ?? []), + delete: engineDelete, + }, rows), + metadata: { getObject: async () => ({ name: 'widget', fields: {} }) }, + }; + const ctx: any = { + hook: (e: string, h: any) => hooks.set(e, h), + getService: (name: string) => { + if (name === 'http-server') return { getRawApp: () => rawApp }; + const svc = services[name]; + if (svc === undefined) throw new Error(`no ${name}`); + return svc; + }, + registerService: () => undefined, + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, + }; + await new MarketplaceInstallLocalPlugin({ controlPlaneUrl: 'off', storageDir }).start(ctx); + await hooks.get('kernel:ready')?.(); + // `kernel:ready` rehydrates the ledger (registry, schema sync, seed heal), + // so every counter starts AFTER it: the observation window is the request. + register.mockClear(); + syncSchemas.mockClear(); + engineDelete.mockClear(); + seedCalls.length = 0; + return { + rawApp, + effects: () => register.mock.calls.length + syncSchemas.mock.calls.length + seedCalls.length + engineDelete.mock.calls.length, + }; +} + +/** The five doors, addressed with what each needs to act. */ +const IL_DOORS = [ + { route: `POST ${IL}`, body: { manifest: APP } }, + { route: `GET ${IL}`, body: {} }, + { route: `DELETE ${IL}/:manifestId`, body: {} }, + { route: `POST ${IL}/:manifestId/reseed-sample-data`, body: {} }, + { route: `POST ${IL}/:manifestId/purge-sample-data`, body: {} }, +] as const; + +/** Install the app as the un-held operator, so the follow-on doors have a target. */ +async function seedInstall(storageDir: string) { + const { rawApp } = await mountInstallLocal(storageDir); + const res = await rawApp.routes.get(`POST ${IL}`)!(makeC(`http://localhost${IL}`, 'u_member', { manifest: APP })); + expect(res.payload.success, JSON.stringify(res.payload)).toBe(true); + expect(existsSync(join(storageDir, LEDGER_FILE))).toBe(true); +} + +describe('ADR-0069 at MarketplaceInstallLocalPlugin', () => { + it('census: five routes, none allow-listed, all five covered below', async () => { + const { rawApp } = await mountInstallLocal(dir); + const keys = [...rawApp.routes.keys()].sort(); + expect(keys).toEqual(IL_DOORS.map((d) => d.route).sort()); + for (const key of keys) expect(isAuthGateAllowlisted(key.split(' ')[1]), key).toBe(false); + }); + + for (const { user, gate } of HELD) { + it(`HELD (${gate.code}): all five doors answer 403 with the gate's code, before any effect and before the ledger is touched`, async () => { + await seedInstall(dir); + const ledgerBefore = existsSync(join(dir, LEDGER_FILE)); + const { rawApp, effects } = await mountInstallLocal(dir); + for (const door of IL_DOORS) { + const res = await rawApp.routes.get(door.route)!(makeC(`http://localhost${IL}`, user, door.body, APP.id)); + expect(res.status, door.route).toBe(403); + expect(res.payload, door.route).toEqual({ success: false, error: { code: gate.code, message: gate.message } }); + } + expect(effects(), 'a held session caused an effect').toBe(0); + expect(existsSync(join(dir, LEDGER_FILE))).toBe(ledgerBefore); + }); + } + + it('UN-HELD: the same operator without a posture is served on every door', async () => { + await seedInstall(dir); + const { rawApp, effects } = await mountInstallLocal(dir); + const list = await rawApp.routes.get(`GET ${IL}`)!(makeC(`http://localhost${IL}`, 'u_member')); + expect(list.status).toBe(200); + expect(list.payload.success).toBe(true); + for (const route of [`POST ${IL}/:manifestId/reseed-sample-data`, `POST ${IL}/:manifestId/purge-sample-data`, `DELETE ${IL}/:manifestId`]) { + const res = await rawApp.routes.get(route)!(makeC(`http://localhost${IL}`, 'u_member', {}, APP.id)); + expect(res.status, `${route}: ${JSON.stringify(res.payload)}`).toBe(200); + } + expect(effects()).toBeGreaterThan(0); + expect(existsSync(join(dir, LEDGER_FILE)), 'the uninstall removed the ledger entry').toBe(false); + }); + + it('anonymous: every door still answers 401 UNAUTHENTICATED', async () => { + const { rawApp, effects } = await mountInstallLocal(dir); + for (const door of IL_DOORS) { + const res = await rawApp.routes.get(door.route)!(makeC(`http://localhost${IL}`, undefined, door.body, APP.id)); + expect(res.status, door.route).toBe(401); + expect(res.payload?.error?.code, door.route).toBe('UNAUTHENTICATED'); + } + expect(effects()).toBe(0); + }); +}); diff --git a/packages/cloud-connection/src/cloud-connection-plugin.ts b/packages/cloud-connection/src/cloud-connection-plugin.ts index b749e06e2f3..b3271971823 100644 --- a/packages/cloud-connection/src/cloud-connection-plugin.ts +++ b/packages/cloud-connection/src/cloud-connection-plugin.ts @@ -23,8 +23,10 @@ * `apps/objectos` (multi-tenant) and `apps/objectos-ee` (single-environment) * — wire the same canonical implementation. cloud ADR-0008 Phase 2 moves this * surface into the open `@objectstack/cloud-connection` package; keep this - * file dependency-light (structural Plugin types, no @objectstack/core) so - * the move is mechanical. + * file dependency-light (structural Plugin types) so the move is mechanical. + * The one value it takes from `@objectstack/core` is the ADR-0069 gate's + * decision, `evaluateAuthGate`: a second copy of a security decision is the + * defect that function exists to prevent (see `sessionFromAuthService`). * * ## Environment & session resolution — two modes * @@ -50,7 +52,7 @@ * self-hosted runtimes). */ -// Minimal structural plugin contract — keep this module independent of +// Minimal structural plugin contract — keep this module's TYPES independent of // @objectstack/core so the cloud ADR-0008 Phase 2 move to the open package stays // mechanical (mirrors the other plugins in this package). interface PluginContext { @@ -78,12 +80,22 @@ interface Plugin { } import { hostname } from 'node:os'; +import { evaluateAuthGate, type AuthGate } from '@objectstack/core'; import { inProcessSessionReadInput } from '@objectstack/types'; import { ConnectionCredentialStore } from './connection-credential-store.js'; import { CLOUD_CONNECTION_UI_BUNDLE } from './cloud-connection-ui.js'; const CLOUD_CONNECTION_PREFIX = '/api/v1/cloud-connection'; +/** + * A signed-in caller of this surface, and the ADR-0069 posture its session + * carries (`authGate`, present only for a session the policy holds). + */ +interface ResolvedSession { + userId: string; + authGate?: AuthGate; +} + /** * The one message every `ENVIRONMENT_NOT_FOUND` refusal on this surface carries. * @@ -201,17 +213,40 @@ export class CloudConnectionPlugin implements Plugin { manifest?.register?.(CLOUD_CONNECTION_UI_BUNDLE); } catch { /* no manifest service — headless kernel */ } - const sessionFromAuthService = async (authSvc: any, rawReq: Request): Promise<{ userId?: string } | null> => { + const sessionFromAuthService = async (authSvc: any, rawReq: Request): Promise => { const api = typeof authSvc?.getApi === 'function' ? await authSvc.getApi() : authSvc?.api ?? authSvc; // [#22258] The in-process session-read rule: a request carrying a // session cookie reads without renewal, because this route's // response never carries a renewed cookie. const session = await api?.getSession?.(inProcessSessionReadInput(rawReq.headers)); const userId = session?.user?.id ? String(session.user.id) : undefined; - return userId ? { userId } : null; + if (!userId) return null; + // ADR-0069: the ONE consult of the authentication-policy gate for + // every door on this surface, asked of the session this read + // returned (the posture rides its user, computed by plugin-auth's + // `customSession` enrichment only while a gate feature is active). + // Both modes read here: the host kernel's `auth` (single-env) and + // the environment's own kernel's (multi-tenant). + // + // No request path is passed, so the allow-list can grant no + // exemption (an empty path earns none, #7898). Nothing this plugin + // mounts is a remediation or bootstrap route. + const authGate = evaluateAuthGate(session?.user, ''); + return authGate ? { userId, authGate } : { userId }; }; - const resolveSession = async (environmentId: string, rawReq: Request): Promise<{ userId?: string } | null> => { + /** + * ADR-0069: a session the authentication policy HOLDS (an expired + * password, enforced MFA not yet enrolled) is gated out of every + * protected resource until it remediates. Every door that resolves + * a session answers it this way, before its own 401 and before any + * control-plane call: REST's answer (`403` with the gate's own + * `code` and `message`), in this surface's envelope. + */ + const refuseHeldSession = (c: any, gate: AuthGate) => + c.json({ success: false, error: { code: gate.code, message: gate.message } }, 403); + + const resolveSession = async (environmentId: string, rawReq: Request): Promise => { try { if (this.cfg.singleEnvironment) { // Single-env: the host kernel owns auth. @@ -294,6 +329,7 @@ export class CloudConnectionPlugin implements Plugin { return c.json({ success: false, error: { code: 'ENVIRONMENT_NOT_FOUND', message: ENVIRONMENT_NOT_FOUND_MESSAGE } }, 404); } const session = await resolveSession(environmentId ?? '', c.req.raw); + if (session?.authGate) return refuseHeldSession(c, session.authGate); if (!session?.userId) return c.json({ success: false, error: { code: 'UNAUTHENTICATED', message: 'Sign in to this environment to connect a cloud account.' } }, 401); if (!cloudUrl) return c.json({ success: false, error: { code: 'CLOUD_UNCONFIGURED', message: 'No cloud control plane configured.' } }, 503); try { @@ -349,6 +385,7 @@ export class CloudConnectionPlugin implements Plugin { return c.json({ success: false, error: { code: 'ENVIRONMENT_NOT_FOUND', message: ENVIRONMENT_NOT_FOUND_MESSAGE } }, 404); } const session = await resolveSession(environmentId ?? '', c.req.raw); + if (session?.authGate) return refuseHeldSession(c, session.authGate); if (!session?.userId) return c.json({ success: false, error: { code: 'UNAUTHENTICATED', message: 'Sign in to this environment.' } }, 401); if (!cloudUrl) return c.json({ success: false, error: { code: 'CLOUD_UNCONFIGURED', message: 'No cloud control plane configured.' } }, 503); @@ -447,6 +484,7 @@ export class CloudConnectionPlugin implements Plugin { return c.json({ success: false, error: { code: 'ENVIRONMENT_NOT_FOUND', message: ENVIRONMENT_NOT_FOUND_MESSAGE } }, 404); } const session = await resolveSession(environmentId ?? '', c.req.raw); + if (session?.authGate) return refuseHeldSession(c, session.authGate); if (!session?.userId) return c.json({ success: false, error: { code: 'UNAUTHENTICATED', message: 'Sign in to this environment.' } }, 401); // Revoke cloud-side FIRST (the oscc_ bearer self-identifies; @@ -495,6 +533,7 @@ export class CloudConnectionPlugin implements Plugin { // explicit env-admin role check; today only owners/admins obtain // a session via SSO-as-owner.) const session = await resolveSession(environmentId, c.req.raw); + if (session?.authGate) return refuseHeldSession(c, session.authGate); if (!session?.userId) { return c.json({ success: false, error: { code: 'UNAUTHENTICATED', message: 'Sign in to this environment to install apps.' } }, 401); } @@ -545,6 +584,7 @@ export class CloudConnectionPlugin implements Plugin { } const session = await resolveSession(environmentId, c.req.raw); + if (session?.authGate) return refuseHeldSession(c, session.authGate); if (!session?.userId) { return c.json({ success: false, error: { code: 'UNAUTHENTICATED', message: 'Sign in to this environment.' } }, 401); } @@ -607,6 +647,7 @@ export class CloudConnectionPlugin implements Plugin { } const session = await resolveSession(environmentId, c.req.raw); + if (session?.authGate) return refuseHeldSession(c, session.authGate); if (!session?.userId) { return c.json({ success: false, error: { code: 'UNAUTHENTICATED', message: 'Sign in to this environment.' } }, 401); } @@ -644,6 +685,7 @@ export class CloudConnectionPlugin implements Plugin { } const session = await resolveSession(environmentId ?? '', c.req.raw); + if (session?.authGate) return refuseHeldSession(c, session.authGate); if (!session?.userId) { return c.json({ success: false, error: { code: 'UNAUTHENTICATED', message: 'Sign in to this environment.' } }, 401); } diff --git a/packages/cloud-connection/src/marketplace-install-local-plugin.ts b/packages/cloud-connection/src/marketplace-install-local-plugin.ts index 120aee2bf64..42622829a2f 100644 --- a/packages/cloud-connection/src/marketplace-install-local-plugin.ts +++ b/packages/cloud-connection/src/marketplace-install-local-plugin.ts @@ -112,6 +112,10 @@ import type { Plugin, PluginContext } from '@objectstack/core'; import { resolveAuthzContext, isAuthzStoreUnavailableError, + // ADR-0069 — the authentication-policy gate's ONE decision, asked of the + // session `resolveInstallPrincipal` reads (see `refuseHeldSession`). + evaluateAuthGate, + type AuthGate, // [#16013] The ADMISSION seam's posture derivation, in ONE call — the same // symbol `packages/rest`'s seam now imports, for the same reason. It reads // the posture IN FORCE off the kernel's `tenancy` service and carries @@ -529,6 +533,42 @@ function organizationWallActive(ctx: PluginContext): boolean { const NO_ACTIVE_ORGANIZATION_CODE = 'PERMISSION_DENIED'; const NO_ACTIVE_ORGANIZATION_STATUS = 403; +/** + * The caller one install-local admission resolved: identity and grants off the + * shared resolver, plus the two postures the doors read. + */ +interface InstallPrincipal { + userId: string; + systemPermissions: string[]; + /** [#15204 W2] The ADR-0095 posture rung the resolver derived. */ + posture?: unknown; + /** [#15204 W2] The EFFECTIVE tenancy posture this admission resolved. */ + tenancyPosture?: TenancyPosture; + /** + * ADR-0069: the authentication-policy posture of the session this + * admission resolved, present only for a session the policy holds. + * `refuseHeldSession` refuses on it. + */ + authGate?: AuthGate; +} + +/** + * ADR-0069: the refusal every install-local door gives a session the + * authentication policy HOLDS (an expired password, enforced MFA not yet + * enrolled), right after the 401 and before the caller's capabilities are + * consulted or the ledger is read. Such a session is gated out of every + * protected resource until it remediates, so it gets REST's answer: `403` + * with the gate's own `code` and `message`, in this plugin's envelope. The + * consult itself happens once, in `resolveInstallPrincipal`; this only + * answers on it, in one place, so a door added later cannot copy a weaker + * answer. `null` when the session is not held. + */ +function refuseHeldSession(c: any, principal: InstallPrincipal): Response | null { + if (!principal.authGate) return null; + const { code, message } = principal.authGate; + return c.json({ success: false, error: { code, message } }, 403); +} + /** * [#15204 W2] What an admitted mutating request carries past * `requireInstallCapability`: the acting user, plus the two inputs ADR-0131 @@ -1721,6 +1761,8 @@ export class MarketplaceInstallLocalPlugin implements Plugin { // installed from timing or from a downstream storage error. const principal = await this.resolveInstallPrincipal(c, ctx); if (!principal) return this.refuseUnauthenticated(c); + const held = refuseHeldSession(c, principal); + if (held) return held; const operator = principal.systemPermissions.includes(INSTALL_LOCAL_CAPABILITY); const { entries, skipped } = this.readAll(); @@ -3280,14 +3322,7 @@ export class MarketplaceInstallLocalPlugin implements Plugin { private resolveInstallPrincipal = async ( c: any, ctx: PluginContext, - ): Promise<{ - userId: string; - systemPermissions: string[]; - /** [#15204 W2] The ADR-0095 posture rung the resolver derived. */ - posture?: unknown; - /** [#15204 W2] The EFFECTIVE tenancy posture this admission resolved. */ - tenancyPosture?: TenancyPosture; - } | null> => { + ): Promise => { const headers = c?.req?.raw?.headers; if (!headers) return null; try { @@ -3301,6 +3336,13 @@ export class MarketplaceInstallLocalPlugin implements Plugin { let ql: IObjectQLEngine | undefined; try { ql = ctx.getService('objectql'); } catch { /* no data engine */ } + // The session user the bridge below read, kept so the ADR-0069 gate + // is asked about the SAME session the resolver admitted: one read, + // both decisions. The resolver calls the bridge only when no API + // key admitted the caller, so a key caller leaves it unset and no + // policy holds it. + let sessionUser: unknown; + // The better-auth session bridge — resolved lazily and defensively, // exactly as the previous implementation did, then handed to the // shared resolver instead of being trusted on its own. @@ -3313,7 +3355,9 @@ export class MarketplaceInstallLocalPlugin implements Plugin { } // [#22258] The in-process session-read rule, as in // `resolveActiveOrgId` above. - return await api?.getSession?.(inProcessSessionReadInput(h)); + const session = await api?.getSession?.(inProcessSessionReadInput(h)); + sessionUser = session?.user; + return session; } catch { return undefined; } @@ -3325,11 +3369,19 @@ export class MarketplaceInstallLocalPlugin implements Plugin { const tenancyPosture = await this.resolveAdmissionTenancyPosture(ctx); const authz = await resolveAuthzContext({ ql, headers, getSession, tenancyPosture }); if (!authz.userId) return null; + // ADR-0069: the ONE consult of the authentication-policy gate for + // all five doors, on the session read above (the posture rides its + // user, computed by plugin-auth's `customSession` enrichment only + // while a gate feature is active). No request path is passed, so + // the allow-list can grant no exemption (an empty path earns none, + // #7898); no install-local route is a remediation or bootstrap route. + const authGate = evaluateAuthGate(sessionUser, ''); return { userId: String(authz.userId), systemPermissions: Array.isArray(authz.systemPermissions) ? authz.systemPermissions : [], posture: authz.posture, tenancyPosture, + ...(authGate ? { authGate } : {}), }; } catch (err) { // [commit 6a180e42d] `null` here means "nobody is authenticated", which is @@ -3381,6 +3433,8 @@ export class MarketplaceInstallLocalPlugin implements Plugin { ): Promise => { const principal = await this.resolveInstallPrincipal(c, ctx); if (!principal) return { ok: false, response: this.refuseUnauthenticated(c) }; + const held = refuseHeldSession(c, principal); + if (held) return { ok: false, response: held }; if (!principal.systemPermissions.includes(INSTALL_LOCAL_CAPABILITY)) { ctx.logger?.warn?.( `[MarketplaceInstallLocal] refused ${action} for ${principal.userId} — ` diff --git a/packages/rest/src/direct-mount-auth-gate.pin.test.ts b/packages/rest/src/direct-mount-auth-gate.pin.test.ts new file mode 100644 index 00000000000..be153743174 --- /dev/null +++ b/packages/rest/src/direct-mount-auth-gate.pin.test.ts @@ -0,0 +1,267 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * ADR-0069 at REST's direct mounts: a session the authentication policy HOLDS + * (an expired password, enforced MFA not yet enrolled) is refused at the + * publish door (`registerPackageRoutes`) and the federation floor + * (`registerExternalDatasourceRoutes`), with the answer `enforceAuth` gives. + * + * ## Why through the real composition + * + * Both registrars are mounted straight onto the host server by + * `mountAndRecordDirectRoutes`, so they pass through none of `RestServer`'s own + * handlers and never meet `enforceAuth`. The posture reaches them only because + * the composition hands them the `RestServer`'s resolver, which lifts the + * session's `authGate` onto the context it returns (`computeExecCtx`, only + * while a gate feature is active). A pin that mounted a registrar with a + * hand-built context would verify the read and not the wiring. This file boots + * `createRestApiPlugin(...).start(ctx)`, as the federation floor's own suite + * does, and drives the mounted handler table. + * + * ## The cases + * + * - HELD: under both postures, all six routes answer `403` with the gate's + * `code` and `message` and no `data`, and the service is never reached (the + * publish, and both federation writes, included). The caller holds every + * capability the family asks for, so the refusal is the gate's alone. + * - UN-HELD: the same caller and grants, without a posture, get each route's + * own success status, and the service runs. + * - ALLOW-LISTED: the one allow-listed route this composition mounts, the + * discovery document, answers a held session as it answers an un-held one. + * - OUTAGE (publish): an active gate whose session re-read fails answers the + * REST seam's `503 SERVICE_UNAVAILABLE`, through the door's existing rethrow. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { isAuthGateAllowlisted } from '@objectstack/core'; +import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; +import { createRestApiPlugin } from './rest-api-plugin.js'; +import { bindTestSecurityCatalog } from './__tests__/security-catalog.testkit.js'; + +const BASE = '/api/v1'; +const DS = 'pg_main'; +const USER = 'u_gate_pin'; + +const PASSWORD_EXPIRED = { code: 'PASSWORD_EXPIRED', message: 'You must change your password before continuing.' }; +const MFA_REQUIRED = { + code: 'MFA_REQUIRED', + message: 'Multi-factor authentication is required. Please set up an authenticator app to continue.', +}; + +/** Session bearer → the posture that session's user carries. */ +const SESSIONS: Record = { + 'sess-held-pw': PASSWORD_EXPIRED, + 'sess-held-mfa': MFA_REQUIRED, + 'sess-member': undefined, + // Its identity read succeeds, its gate re-read fails (see `bootDirectMounts`). + 'sess-outage': undefined, +}; +const HELD = [ + { token: 'sess-held-pw', gate: PASSWORD_EXPIRED }, + { token: 'sess-held-mfa', gate: MFA_REQUIRED }, +] as const; + +const MANIFEST = { id: 'com.acme.gate_pin', version: '1.0.0' }; + +/** The six direct-mounted routes, each with its success status and the service method it reaches. */ +const ROUTES = [ + { method: 'POST', url: `${BASE}/packages/publish`, ok: 200, service: 'package', call: 'publish', body: { manifest: MANIFEST, metadata: {} } }, + { method: 'GET', url: `${BASE}/datasources/${DS}/external/tables`, ok: 200, service: 'external-datasource', call: 'listRemoteTables' }, + { method: 'POST', url: `${BASE}/datasources/${DS}/external/tables/customers/draft`, ok: 200, service: 'external-datasource', call: 'generateObjectDraft' }, + { method: 'POST', url: `${BASE}/datasources/${DS}/external/tables/customers/import`, ok: 201, service: 'external-datasource', call: 'importObject' }, + { method: 'POST', url: `${BASE}/datasources/${DS}/external/refresh-catalog`, ok: 200, service: 'external-datasource', call: 'refreshCatalog' }, + { method: 'POST', url: `${BASE}/datasources/${DS}/external/validate`, ok: 200, service: 'external-datasource', call: 'validateDatasource' }, +] as const; + +/** Every capability the six routes ask for between them. */ +const GRANTS = ['manage_metadata', 'manage_platform_settings']; + +type Handler = (req: any, res: any) => any; + +function createRecordingServer() { + const table = new Map(); + const on = (method: string) => vi.fn((path: string, handler: Handler) => { + table.set(`${method} ${path}`, handler); + }); + return { + table, + get: on('GET'), post: on('POST'), put: on('PUT'), delete: on('DELETE'), patch: on('PATCH'), + use: vi.fn(), listen: vi.fn().mockResolvedValue(undefined), close: vi.fn().mockResolvedValue(undefined), + }; +} + +function resolveRoute(table: Map, method: string, url: string) { + const urlSegs = url.split('/'); + for (const [key, handler] of table) { + const [m, pattern] = key.split(' '); + if (m !== method) continue; + const patSegs = pattern!.split('/'); + if (patSegs.length !== urlSegs.length) continue; + const params: Record = {}; + let ok = true; + for (let i = 0; i < patSegs.length; i++) { + if (patSegs[i]!.startsWith(':')) params[patSegs[i]!.slice(1)] = urlSegs[i]!; + else if (patSegs[i] !== urlSegs[i]) { ok = false; break; } + } + if (ok) return { handler, params }; + } + return undefined; +} + +async function bootDirectMounts() { + const server = createRecordingServer(); + const services: Record = { + 'external-datasource': { + listRemoteTables: vi.fn(async () => [{ name: 'customers' }]), + generateObjectDraft: vi.fn(async () => ({ name: 'customers' })), + importObject: vi.fn(async () => ({ name: 'customers' })), + refreshCatalog: vi.fn(async () => ({ tables: {} })), + validateAll: vi.fn(async () => ({ results: [] })), + validateDatasource: vi.fn(async (name: string) => ({ results: [{ datasource: name, ok: true }] })), + }, + package: { list: async () => [], publish: vi.fn(async () => ({ success: true })) }, + }; + + /** Session reads per request for the outage token; reset by `call`. */ + const outage = { reads: 0 }; + const auth = { + isAuthGateActive: () => true, + api: { + getSession: async ({ headers }: { headers: Headers }) => { + const token = headers?.get?.('authorization')?.replace(/^Bearer /, '') ?? ''; + if (!(token in SESSIONS)) return null; + // The identity read is the request's first; REST's gate re-read is its second. + if (token === 'sess-outage' && ++outage.reads > 1) throw new Error('session backend unavailable'); + const authGate = SESSIONS[token]; + return { user: { id: USER, ...(authGate ? { authGate } : {}) }, session: { userId: USER } }; + }, + }, + }; + + const SET = 'ps_gate_pin'; + const engine = { + find: async (object: string, query: any) => { + if (object === 'sys_user_permission_set') { + return query?.where?.user_id === USER + ? [{ id: 'ups_1', user_id: USER, permission_set_id: SET, permission_set: 'gate_pin_caller', organization_id: null }] + : []; + } + if (object === 'sys_permission_set') { + const ids: string[] = query?.where?.id?.$in ?? []; + const names: string[] = query?.where?.name?.$in ?? []; + return ids.includes(SET) || names.includes('gate_pin_caller') + ? [{ id: SET, name: 'gate_pin_caller', system_permissions: JSON.stringify(GRANTS), object_permissions: '{}' }] + : []; + } + return []; + }, + }; + bindTestSecurityCatalog(engine, { permissions: [{ name: 'gate_pin_caller', systemPermissions: [...GRANTS] }] }); + + const protocolServices = new Map([['package', services.package]]); + const protocol = new ObjectStackProtocolImplementation( + { registry: { getObject: () => undefined, getRegisteredTypes: () => [] } } as any, + () => protocolServices, + ); + const all: Record = { 'http.server': server, protocol, auth, objectql: engine, ...services }; + const ctx = { + registerService: vi.fn(), + getService: vi.fn((name: string) => { + if (name in all) return all[name]; + throw new Error(`Service '${name}' not found`); + }), + getServices: vi.fn(() => new Map(Object.entries(all))), + hook: vi.fn(), + trigger: vi.fn().mockResolvedValue(undefined), + logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() }, + getKernel: vi.fn(), + }; + await createRestApiPlugin(undefined as any).start!(ctx as any); + + async function call(route: { method: string; url: string; body?: unknown }, token?: string) { + outage.reads = 0; + const entry = resolveRoute(server.table, route.method, route.url); + expect(entry, `${route.method} ${route.url} must be mounted for this pin to mean anything`).toBeDefined(); + let body: any; + let statusCode = 200; + const res: any = { + status: (c: number) => { statusCode = c; return res; }, + json: (b: any) => { body = b; }, + header: () => res, + send: () => {}, + }; + await entry!.handler( + { + params: entry!.params, + query: {}, + body: route.body ?? {}, + method: route.method, + path: route.url, + headers: { host: 'example.test', ...(token ? { authorization: `Bearer ${token}` } : {}) }, + }, + res, + ); + return { statusCode, body }; + } + + function calls(route: (typeof ROUTES)[number]): number { + return (services[route.service][route.call] as ReturnType).mock.calls.length; + } + + return { call, calls, services }; +} + +describe('ADR-0069 at REST\'s direct mounts (the real composition)', () => { + for (const { token, gate } of HELD) { + it(`HELD (${gate.code}): all six routes answer 403 with the gate's code and message, and no service runs`, async () => { + const { call, calls } = await bootDirectMounts(); + for (const route of ROUTES) { + const { statusCode, body } = await call(route, token); + expect(statusCode, `${route.method} ${route.url}`).toBe(403); + expect(body?.success, route.url).toBe(false); + expect(body?.error?.code, route.url).toBe(gate.code); + expect(body?.error?.message, route.url).toBe(gate.message); + expect(body, route.url).not.toHaveProperty('data'); + expect(calls(route), `${route.call} ran for a held session`).toBe(0); + } + }); + } + + it('UN-HELD: the same caller and grants get every route\'s own success status, and each service runs', async () => { + const { call, calls } = await bootDirectMounts(); + for (const route of ROUTES) { + const { statusCode, body } = await call(route, 'sess-member'); + expect(statusCode, `${route.method} ${route.url}: ${JSON.stringify(body)}`).toBe(route.ok); + expect(body?.success, route.url).toBe(true); + expect(calls(route), route.call).toBe(1); + } + }); + + it('UN-HELD: an anonymous caller is refused 401 as before, and no service runs', async () => { + const { call, calls } = await bootDirectMounts(); + for (const route of ROUTES) { + const { statusCode, body } = await call(route); + expect(statusCode, route.url).toBe(401); + expect(body?.error?.code, route.url).toBe('UNAUTHENTICATED'); + expect(calls(route)).toBe(0); + } + }); + + it('ALLOW-LISTED: the discovery document answers a held session exactly as it answers an un-held one', async () => { + const { call } = await bootDirectMounts(); + const discovery = { method: 'GET', url: `${BASE}/discovery` }; + expect(isAuthGateAllowlisted(discovery.url)).toBe(true); + const held = await call(discovery, 'sess-held-pw'); + const unheld = await call(discovery, 'sess-member'); + expect(held.statusCode).toBe(200); + expect(held).toEqual(unheld); + }); + + it('OUTAGE (publish): an active gate whose session re-read fails answers 503 SERVICE_UNAVAILABLE, and nothing is published', async () => { + const { call, calls } = await bootDirectMounts(); + const { statusCode, body } = await call(ROUTES[0], 'sess-outage'); + expect(statusCode).toBe(503); + expect(body?.error?.code).toBe('SERVICE_UNAVAILABLE'); + expect(calls(ROUTES[0])).toBe(0); + }); +}); diff --git a/packages/rest/src/external-datasource-routes.ts b/packages/rest/src/external-datasource-routes.ts index 2177ecaf4d2..6d6e073a14d 100644 --- a/packages/rest/src/external-datasource-routes.ts +++ b/packages/rest/src/external-datasource-routes.ts @@ -8,8 +8,10 @@ import { ANONYMOUS_DENY_STATUS, ANONYMOUS_DENY_CODE, ANONYMOUS_DENY_MESSAGE, + type AuthGate, type PluginContext, } from '@objectstack/core'; +import type { ErrorCode } from '@objectstack/spec/api'; import type { IExternalDatasourceService, IHttpServer, @@ -125,6 +127,12 @@ export interface ExternalDatasourceRoutesOptions { userId?: string | null; isSystem?: boolean; systemPermissions?: string[]; + /** + * ADR-0069: the session's authentication-policy posture, as the + * `RestServer`'s resolver lifts it (`ExecutionContext.authGate`). Present + * only for a session the policy holds; the floor refuses on it. + */ + authGate?: AuthGate; } | undefined>; } @@ -186,7 +194,8 @@ export function registerExternalDatasourceRoutes( /** * [#9686 authentication, #9901 capability] The admission floor for this whole - * family. Answers `401 UNAUTHENTICATED` or `403 PERMISSION_DENIED` and + * family. Answers `401 UNAUTHENTICATED`, `403 PERMISSION_DENIED` or, for a + * session the ADR-0069 policy holds, `403` with the gate's own code, and * returns `true` when the caller must be refused, so every handler opens with * `if (await refuseFederationRequest(req, res, '')) return;`. * @@ -295,7 +304,7 @@ export function registerExternalDatasourceRoutes( kind: 'read' | 'write', ): Promise => { let authz: - | { userId?: string | null; isSystem?: boolean; systemPermissions?: string[] } + | { userId?: string | null; isSystem?: boolean; systemPermissions?: string[]; authGate?: AuthGate } | undefined; try { authz = await options.resolveExecutionContext?.(req); @@ -304,6 +313,27 @@ export function registerExternalDatasourceRoutes( // that could not be read are not grants. authz = undefined; } + // ADR-0069: a session the authentication policy HOLDS (an expired + // password, enforced MFA not yet enrolled) is gated out of every protected + // resource until it remediates, and this family is one: live remote-schema + // introspection, and two routes that create metadata. The same resolution + // carries the decision: REST's resolver lifts the session's posture onto + // `authGate` through `normalizeAuthGate` (`@objectstack/core`), the field + // `enforceAuth` reads. Asked first, as `enforceAuth` asks it, and answered + // as it answers: `403` with the gate's own `code` and `message`, in this + // surface's envelope. A held caller is told what to remediate, and its + // capabilities are not consulted. + // + // No path is consulted. This registrar's base is the host's argument, and + // nothing here is a remediation or bootstrap route, so the allow-list has + // nothing to exempt. `code` is declared as a bare string on the envelope's + // `authGate`. Its one producer (plugin-auth's `customSession` enrichment) + // writes only `PASSWORD_EXPIRED` and `MFA_REQUIRED`, both members of the + // vocabulary. + if (authz?.authGate) { + sendError(res, 403, authz.authGate.code as ErrorCode, authz.authGate.message); + return true; + } if (shouldDenyAnonymous({ userId: authz?.userId, isSystem: authz?.isSystem, method: req?.method })) { sendError(res, ANONYMOUS_DENY_STATUS, ANONYMOUS_DENY_CODE, ANONYMOUS_DENY_MESSAGE); return true; diff --git a/packages/rest/src/package-routes.ts b/packages/rest/src/package-routes.ts index 3b609e347fd..ae534aeb4a1 100644 --- a/packages/rest/src/package-routes.ts +++ b/packages/rest/src/package-routes.ts @@ -1,6 +1,7 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. -import { IHttpServer, shouldDenyAnonymous, ANONYMOUS_DENY_STATUS, ANONYMOUS_DENY_CODE, ANONYMOUS_DENY_MESSAGE, rethrowAuthzStoreUnavailable } from '@objectstack/core'; +import { IHttpServer, shouldDenyAnonymous, ANONYMOUS_DENY_STATUS, ANONYMOUS_DENY_CODE, ANONYMOUS_DENY_MESSAGE, rethrowAuthzStoreUnavailable, type AuthGate } from '@objectstack/core'; +import type { ErrorCode } from '@objectstack/spec/api'; import type { PackageService } from '@objectstack/service-package'; // The declared envelope is written in ONE place for the whole platform (#3973), // and so (#8016) is the rule that reads an HTTP answer off a THROWN error. @@ -63,6 +64,26 @@ async function refusePackageRequest( const ctx = options.resolveExecutionContext ? await options.resolveExecutionContext(req).catch(rethrowAuthzStoreUnavailable) : undefined; + // ADR-0069: a session the authentication policy HOLDS (an expired password, + // enforced MFA not yet enrolled) is gated out of every protected resource + // until it remediates, and publishing a package is one. The context already + // carries the decision: REST's resolver lifts the session's posture onto + // `authGate` through `normalizeAuthGate` (`@objectstack/core`), the field + // `enforceAuth` reads. Asked first, as `enforceAuth` asks it, and answered + // as it answers: `403` with the gate's own `code` and `message`, in this + // surface's envelope. A held caller is told what to remediate, and its + // capabilities are not consulted. + // + // No path is consulted. This registrar's base is the host's argument, and + // the publish route is no remediation or bootstrap route, so the allow-list + // has nothing to exempt here. `code` is declared as a bare string on the + // envelope's `authGate`. Its one producer (plugin-auth's `customSession` + // enrichment) writes only `PASSWORD_EXPIRED` and `MFA_REQUIRED`, both members + // of the vocabulary. + if (ctx?.authGate) { + sendError(res, 403, ctx.authGate.code as ErrorCode, ctx.authGate.message); + return true; + } // Anonymous-deny floor. This direct-mount surface DECLARES the wrapped // BaseResponseSchema envelope — every other body here goes through // sendOk/sendError, and `check:route-envelope` pins this module at ZERO @@ -340,6 +361,12 @@ export interface PackageRoutesOptions { userId?: string | null; isSystem?: boolean; systemPermissions?: string[]; + /** + * ADR-0069: the session's authentication-policy posture, as the + * `RestServer`'s resolver lifts it (`ExecutionContext.authGate`). Present + * only for a session the policy holds; the door refuses on it. + */ + authGate?: AuthGate; } | undefined>; } diff --git a/packages/runtime/src/dispatcher-plugin.ts b/packages/runtime/src/dispatcher-plugin.ts index 8a13ba1444a..c6811fe0a99 100644 --- a/packages/runtime/src/dispatcher-plugin.ts +++ b/packages/runtime/src/dispatcher-plugin.ts @@ -1,7 +1,7 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. -import { Plugin, PluginContext, IHttpServer, ANONYMOUS_DENY_BODY, ANONYMOUS_DENY_STATUS } from '@objectstack/core'; -import { looksLikeInternalErrorLeak, INTERNAL_ERROR_MESSAGE, resolveThrownHttpError, serverFaultProvenance, declaredRefusalMessage, demotedDeclaredCode, logServerFault, describeFaultRequest } from '@objectstack/types'; +import { Plugin, PluginContext, IHttpServer, ANONYMOUS_DENY_BODY, ANONYMOUS_DENY_STATUS, AuthzStoreUnavailableError } from '@objectstack/core'; +import { looksLikeInternalErrorLeak, INTERNAL_ERROR_MESSAGE, resolveThrownHttpError, serverFaultProvenance, declaredRefusalMessage, demotedDeclaredCode, logServerFault, describeFaultRequest, inProcessSessionReadInput } from '@objectstack/types'; import { DispatcherErrorCode } from '@objectstack/spec/api'; import type { IAuthService, IMetadataService, Logger } from '@objectstack/spec/contracts'; import type { CounterStore } from '@objectstack/plugin-auth/rate-limit-storage'; @@ -168,6 +168,56 @@ function safeGetService(ctx: PluginContext, name: string): T | undefined { } } +/** + * ADR-0069: the session USER the declared-endpoint fallback asks the + * authentication-policy gate about (`EndpointPolicyContext.resolveGatedSessionUser`). + * + * It mirrors REST's posture read (`RestServer.computeExecCtx`) case for case: + * + * - No gate feature is active, or the probe is absent or throws: the answer + * is `undefined` and no session is read. The gate costs nothing while it + * is off, and a host whose probe faults never declared a gate. + * - The gate is active: a dedicated session read. The answer is its user, or + * `undefined` when there is no session. The API-key caller and the + * anonymous caller have none, and no policy holds them. + * - The gate is active and the read FAILS: the branded authorization-store + * outage, answered `503 SERVICE_UNAVAILABLE` by the fallback's error exit. + * It is never `undefined`: an enforcement the deployment declared active + * must not vanish into "no session" (#13906, decision 2 option B). + * + * The principal lookup beside it (`resolveSessionPrincipalId`) swallows every + * failure as anonymous, which is right for keying a rate-limit bucket and + * wrong here. That is why this is a read of its own, as it is on REST. + */ +async function readGatedSessionUser( + authService: IAuthService | undefined, + headers: Record, +): Promise { + let active = false; + try { + active = authService?.isAuthGateActive?.() === true; + } catch { + active = false; + } + if (!authService || !active) return undefined; + try { + const api = authService.api ?? (await authService.getApi?.()); + // A provider that mounts no session API has no session to hold. + if (!api || typeof api.getSession !== 'function') return undefined; + const webHeaders = new Headers(); + for (const [name, value] of Object.entries(headers)) { + if (value != null) webHeaders.set(name, Array.isArray(value) ? value.join(',') : String(value)); + } + // [#22258] The in-process session-read rule: a cookie request reads + // without renewal, because this door's response never carries a + // renewed cookie. + const session: any = await api.getSession(inProcessSessionReadInput(webHeaders)); + return session?.user; + } catch (err) { + throw new AuthzStoreUnavailableError('auth_gate', err); + } +} + /** * Route definition emitted by service plugins (e.g. AIServicePlugin) via hooks. * Minimal interface — matches the shape produced by `buildAIRoutes()`. @@ -1980,6 +2030,14 @@ export function createDispatcherPlugin(config: DispatcherPluginConfig = {}): Plu safeGetService(ctx, 'auth'), headers as Record, ), + // ADR-0069: the session the authentication-policy + // gate is asked about, read only while a gate feature + // is active. Same `auth` occupant as the lookup above. + resolveGatedSessionUser: (headers) => + readGatedSessionUser( + safeGetService(ctx, 'auth'), + headers as Record, + ), limiters: endpointLimiters, // The authored `server.trustProxy`, read from the // same declaration the server-level limiter reads. diff --git a/packages/runtime/src/endpoint-policy.auth-gate.pin.test.ts b/packages/runtime/src/endpoint-policy.auth-gate.pin.test.ts new file mode 100644 index 00000000000..11333b70cb9 --- /dev/null +++ b/packages/runtime/src/endpoint-policy.auth-gate.pin.test.ts @@ -0,0 +1,346 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * ADR-0069 at the declared-endpoint fallback: a session the authentication + * policy HOLDS (an expired password, enforced MFA not yet enrolled) is refused + * before a declared endpoint runs, with REST's answer. + * + * ## What is pinned, and where + * + * The fallback is the dispatcher plugin's `setFallbackHandler` seam, reached + * only by a request no registered route matched, so it never passes through + * `HttpDispatcher.enforceAuthGate`. Its policy chain (`applyEndpointPolicies`) + * now asks the gate, between the meter and `authRequired`, on a session read + * that runs only while a gate feature is active (`readGatedSessionUser` in + * `dispatcher-plugin.ts`). The first block boots that composition, a real + * LiteKernel with the real Hono transport and the real dispatcher plugin, with + * the services stubbed at the kernel boundary, and drives it over a socket: + * + * - HELD: under both postures, every declared endpoint answers `403` with the + * gate's `code` and `message` and no `data`, and the target never ran (no + * engine read, no flow run). That holds on the write, and on an + * `authRequired: false` endpoint too: the target runs as the caller's + * context, so a public endpoint would otherwise read as the held user. + * - UN-HELD: the same grants, without a posture, are served exactly as before. + * - ALLOW-LISTED: a held session's `/health` read is the un-held answer. + * - OUTAGE: an active gate whose session read fails answers `503 + * SERVICE_UNAVAILABLE`, and nothing runs, as REST answers the same window. + * - GATE OFF: with no gate feature active the posture is not read at all. + * + * ## The anti-bypass case (the second block) + * + * The gate is asked with NO request path, so the shared allow-list can grant + * no exemption. The allow-list is anchored at a mount boundary (#16839), so + * under the shipped prefix no declared path is allow-listed. A declared path + * sits under the dispatcher prefix the HOST chooses, though, and a prefix that + * lands on an allow-listed boundary (an `auth` segment) makes every declared + * path under it allow-listed. The second block drives the step under such a + * prefix, and first asserts that the path really is allow-listed, so the case + * cannot pass vacuously. Handing the declared path to the allow-list makes it + * red. + */ + +import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest'; +import { LiteKernel, Plugin, PluginContext, isAuthGateAllowlisted } from '@objectstack/core'; +import { HonoServerPlugin } from '@objectstack/plugin-hono-server'; +import { ApiEndpointSchema, type ApiEndpoint } from '@objectstack/spec/api'; +import type { ApiEndpointMatch, IHttpServer } from '@objectstack/spec/contracts'; + +import { createDispatcherPlugin } from './dispatcher-plugin.js'; +import { runAppEndpointStep } from './api-endpoint-step.js'; +import { createEndpointRateLimiterRegistry } from './endpoint-policy.js'; + +const PASSWORD_EXPIRED = { code: 'PASSWORD_EXPIRED', message: 'You must change your password before continuing.' }; +const MFA_REQUIRED = { + code: 'MFA_REQUIRED', + message: 'Multi-factor authentication is required. Please set up an authenticator app to continue.', +}; + +/** The posture each test user's session carries. Same grants for all three. */ +const POSTURES: Record = { + u_held_pw: PASSWORD_EXPIRED, + u_held_mfa: MFA_REQUIRED, + u_member: undefined, +}; +const HELD = [ + { user: 'u_held_pw', gate: PASSWORD_EXPIRED }, + { user: 'u_held_mfa', gate: MFA_REQUIRED }, +] as const; + +const READ: ApiEndpoint = ApiEndpointSchema.parse({ + name: 'gate_pin_tasks', + path: '/api/v1/apps/showcase/tasks', + method: 'GET', + type: 'object_operation', + target: 'showcase_task', + objectParams: { object: 'showcase_task', operation: 'find' }, +}); +const WRITE: ApiEndpoint = ApiEndpointSchema.parse({ + name: 'gate_pin_purge', + path: '/api/v1/apps/showcase/inquiries/purge', + method: 'POST', + type: 'flow', + target: 'showcase_inquiry_janitor', +}); +const OPEN: ApiEndpoint = ApiEndpointSchema.parse({ + name: 'gate_pin_open_tasks', + path: '/api/v1/apps/showcase/open-tasks', + method: 'GET', + type: 'object_operation', + target: 'showcase_task', + objectParams: { object: 'showcase_task', operation: 'find' }, + authRequired: false, +}); +/** The literal shape of the trap: an `auth` segment INSIDE a declared path. */ +const AUTH_SEGMENT: ApiEndpoint = ApiEndpointSchema.parse({ + name: 'gate_pin_auth_callback', + path: '/api/v1/apps/showcase/auth/callback', + method: 'GET', + type: 'object_operation', + target: 'showcase_task', + objectParams: { object: 'showcase_task', operation: 'find' }, +}); +const DECLARED = [READ, WRITE, OPEN, AUTH_SEGMENT]; + +const TASK_ROWS = [{ id: 'tsk_1', name: 'gate-pin-row-marker', status: 'open' }]; +const ADMIN_SET = { + id: 'ps-admin', + name: 'admin_full_access', + object_permissions: { '*': { viewAllRecords: true, modifyAllRecords: true } }, +}; + +/** What the kernel-boundary stubs observed. */ +const observed = { + gateActive: true, + /** When set, every session read fails (the outage case). */ + failReads: false, + taskReads: 0, + flowRuns: 0, +}; + +function fakeMetadataPlugin(): Plugin { + return { + name: 'com.objectstack.test.gate-pin-metadata', + version: '1.0.0', + init: async (ctx: PluginContext) => { + ctx.registerService('metadata', { + list: async () => [], + matchEndpoint: async (q: { path: string; method: string }): Promise => { + const hit = DECLARED.find( + (e) => e.path === q.path.replace(/\/$/, '') && e.method === q.method.toUpperCase(), + ); + return hit ? { endpoint: hit, params: {} } : undefined; + }, + }); + }, + }; +} + +function servicesPlugin(): Plugin { + return { + name: 'com.objectstack.test.gate-pin-services', + version: '1.0.0', + init: async (ctx: PluginContext) => { + ctx.registerService('auth', { + isAuthGateActive: () => observed.gateActive, + api: { + async getSession({ headers }: { headers: Headers }) { + if (observed.failReads) throw new Error('session backend unavailable'); + const uid = headers.get('x-test-user'); + if (!uid) return null; + const authGate = POSTURES[uid]; + return { user: { id: uid, ...(authGate ? { authGate } : {}) } }; + }, + }, + }); + ctx.registerService('objectql', { + async find(object: string, options: any) { + if (object === 'sys_user_permission_set') { + const uid = options?.where?.user_id; + return uid in POSTURES + ? [{ user_id: uid, permission_set_id: 'ps-admin', permission_set: 'admin_full_access', organization_id: null }] + : []; + } + if (object === 'sys_permission_set') { + const ids: string[] = options?.where?.id?.$in ?? []; + return ids.includes('ps-admin') ? [ADMIN_SET] : []; + } + if (object === 'showcase_task') { + observed.taskReads++; + return TASK_ROWS; + } + return []; + }, + }); + ctx.registerService('automation', { + async execute() { + observed.flowRuns++; + return { runId: 'run_1', status: 'completed' }; + }, + }); + }, + }; +} + +async function shutdown(kernel: LiteKernel | undefined) { + if (!kernel) return; + await Promise.race([kernel.shutdown(), new Promise((resolve) => setTimeout(resolve, 10_000))]); +} + +describe('ADR-0069 at the declared-endpoint fallback (real boot, real socket)', () => { + let kernel: LiteKernel; + let baseUrl: string; + + beforeAll(async () => { + kernel = new LiteKernel(); + kernel.use(new HonoServerPlugin({ port: 0, cors: false })); + kernel.use(fakeMetadataPlugin()); + kernel.use(servicesPlugin()); + kernel.use(createDispatcherPlugin({ prefix: '/api/v1', securityHeaders: false })); + await kernel.bootstrap(); + baseUrl = `http://127.0.0.1:${kernel.getService('http.server').getPort!()}`; + }, 30_000); + afterAll(() => shutdown(kernel), 30_000); + + beforeEach(() => { + observed.gateActive = true; + observed.failReads = false; + observed.taskReads = 0; + observed.flowRuns = 0; + }); + + function request(endpoint: ApiEndpoint, user?: string) { + return fetch(`${baseUrl}${endpoint.path}`, { + method: endpoint.method, + headers: { + ...(user ? { 'x-test-user': user } : {}), + ...(endpoint.method === 'POST' ? { 'content-type': 'application/json' } : {}), + }, + ...(endpoint.method === 'POST' ? { body: JSON.stringify({ params: { olderThanDays: 30 } }) } : {}), + }); + } + + for (const { user, gate } of HELD) { + for (const endpoint of [READ, WRITE, OPEN, AUTH_SEGMENT]) { + it(`HELD (${gate.code}): ${endpoint.method} ${endpoint.path} (authRequired: ${endpoint.authRequired}) is refused before the target runs`, async () => { + const res = await request(endpoint, user); + const text = await res.text(); + expect(res.status, text).toBe(403); + const body = JSON.parse(text) as { success?: boolean; data?: unknown; error?: { code?: string; message?: string } }; + expect(body.success).toBe(false); + expect(body.error?.code).toBe(gate.code); + expect(body.error?.message).toBe(gate.message); + expect(body).not.toHaveProperty('data'); + expect(text).not.toContain(TASK_ROWS[0]!.name); + expect(observed.taskReads, 'the target read the store').toBe(0); + expect(observed.flowRuns, 'the target ran the flow').toBe(0); + }); + } + } + + it('UN-HELD: the same grants without a posture are served on every endpoint, the write included', async () => { + for (const endpoint of [READ, OPEN, AUTH_SEGMENT]) { + const res = await request(endpoint, 'u_member'); + const text = await res.text(); + expect(res.status, `${endpoint.path}: ${text}`).toBe(200); + expect(text).toContain(TASK_ROWS[0]!.name); + } + expect(observed.taskReads).toBe(3); + const write = await request(WRITE, 'u_member'); + expect(write.status, await write.text()).toBe(200); + expect(observed.flowRuns).toBe(1); + }); + + it('UN-HELD: an anonymous caller is answered as before (401 on the default authRequired, served on the public endpoint)', async () => { + const denied = await request(READ); + expect(denied.status).toBe(401); + expect((await denied.json() as { error: { code: string } }).error.code).toBe('UNAUTHENTICATED'); + const open = await request(OPEN); + expect(open.status).toBe(200); + expect(observed.taskReads).toBe(1); + }); + + it('ALLOW-LISTED: a held session reads the dispatcher\'s /health exactly as an un-held one does', async () => { + expect(isAuthGateAllowlisted('/api/v1/health')).toBe(true); + const held = await fetch(`${baseUrl}/api/v1/health`, { headers: { 'x-test-user': 'u_held_pw' } }); + const unheld = await fetch(`${baseUrl}/api/v1/health`, { headers: { 'x-test-user': 'u_member' } }); + expect(held.status).toBe(200); + expect(held.status).toBe(unheld.status); + }); + + it('OUTAGE: an active gate whose session read fails answers 503 SERVICE_UNAVAILABLE, and nothing runs', async () => { + observed.failReads = true; + for (const endpoint of [OPEN, WRITE]) { + const res = await request(endpoint, 'u_member'); + const text = await res.text(); + expect(res.status, text).toBe(503); + const body = JSON.parse(text) as { success?: boolean; error?: { code?: string } }; + expect(body.success).toBe(false); + expect(body.error?.code).toBe('SERVICE_UNAVAILABLE'); + } + expect(observed.taskReads).toBe(0); + expect(observed.flowRuns).toBe(0); + }); + + it('GATE OFF: with no gate feature active the posture is never read, and the session is served', async () => { + observed.gateActive = false; + const res = await request(READ, 'u_held_pw'); + expect(res.status, await res.text()).toBe(200); + expect(observed.taskReads).toBe(1); + }); +}); + +describe('ADR-0069 anti-bypass: no declared path reaches the allow-list', () => { + /** A host prefix that lands on an allow-listed boundary (`…/auth/…`). */ + const HOST_PREFIX = '/api/v1/auth'; + const PATH = `${HOST_PREFIX}/apps/showcase/callback`; + const ENDPOINT: ApiEndpoint = ApiEndpointSchema.parse({ + name: 'gate_pin_host_prefix', + path: PATH, + method: 'GET', + type: 'object_operation', + target: 'showcase_task', + objectParams: { object: 'showcase_task', operation: 'find' }, + }); + + async function step(path: string, endpoint: ApiEndpoint, prefix: string) { + const callData = vi.fn(async () => ({ object: 'showcase_task', records: TASK_ROWS, total: 1 })); + const answer = await runAppEndpointStep({ + method: 'GET', + path, + prefix, + metadataService: { matchEndpoint: async () => ({ endpoint, params: {} }) }, + policy: { + headers: {}, + resolvePrincipalId: async () => 'u_held_pw', + resolveGatedSessionUser: async () => ({ id: 'u_held_pw', authGate: PASSWORD_EXPIRED }), + limiters: createEndpointRateLimiterRegistry({ resolveCache: async () => undefined }), + }, + execution: { + request: { method: 'GET', path, query: {}, headers: {}, body: undefined }, + deps: { callData }, + executionContext: { userId: 'u_held_pw', isSystem: false, positions: [], permissions: [] } as any, + }, + }); + return { answer, callData }; + } + + it('precondition: under this host prefix the declared path IS allow-listed, so the case is not vacuous', () => { + expect(isAuthGateAllowlisted(PATH)).toBe(true); + expect(isAuthGateAllowlisted(PATH, 'GET')).toBe(true); + }); + + it('a held session on that declared path is still refused 403 with the gate\'s code, and the target never runs', async () => { + const { answer, callData } = await step(PATH, ENDPOINT, HOST_PREFIX); + expect(answer?.status).toBe(403); + expect((answer?.body as { error?: { code?: string } }).error?.code).toBe('PASSWORD_EXPIRED'); + expect(callData).not.toHaveBeenCalled(); + }); + + it('the literal trap shape under the shipped prefix (an `auth` segment inside the declared path) is refused the same way', async () => { + const { answer, callData } = await step(AUTH_SEGMENT.path, AUTH_SEGMENT, '/api/v1'); + expect(answer?.status).toBe(403); + expect((answer?.body as { error?: { code?: string } }).error?.code).toBe('PASSWORD_EXPIRED'); + expect(callData).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/runtime/src/endpoint-policy.ts b/packages/runtime/src/endpoint-policy.ts index 7a4b1e03334..090bc8891dd 100644 --- a/packages/runtime/src/endpoint-policy.ts +++ b/packages/runtime/src/endpoint-policy.ts @@ -24,6 +24,13 @@ * ("Response cache TTL in seconds") is how a runtime dialect is born. A header * is the part of the meaning the vocabulary actually fixes. * + * ## …and one platform floor that is not a key: the ADR-0069 gate + * + * A session the authentication policy holds is refused here as on every other + * protected seam, with `evaluateAuthGate` (`@objectstack/core`) as the one + * decision and REST's `403` as the answer. It runs between the meter and + * `authRequired`; see ② in {@link applyEndpointPolicies}. + * * ## Why this is a pure function over explicit deps * * No service lookup happens in here — the caller passes what it resolved. That @@ -62,7 +69,9 @@ import { ANONYMOUS_DENY_CODE, ANONYMOUS_DENY_MESSAGE, ANONYMOUS_DENY_STATUS, + evaluateAuthGate, shouldDenyAnonymous, + type AuthGate, } from '@objectstack/core'; import { createLazyCounterStore, type CounterStore } from '@objectstack/plugin-auth/rate-limit-storage'; import type { ApiEndpoint } from '@objectstack/spec/api'; @@ -188,6 +197,26 @@ export interface EndpointPolicyContext { * normal outcome, never an error. */ resolvePrincipalId?: (headers: HeaderBag) => Promise; + /** + * ADR-0069: the session USER the authentication-policy gate is asked about. + * + * Resolves `undefined` when no gate feature is active (no session is read + * then) or when there is no session to hold, which is the API-key and the + * anonymous caller. Otherwise it resolves the session's user, whose + * `authGate` the policy decides on. + * + * It THROWS the branded authorization-store outage + * (`AuthzStoreUnavailableError`, `@objectstack/core`) when an active gate + * cannot read the session. That throw is not caught here: it reaches the + * dispatcher's error exit as a `matchEndpoint` throw does, and is answered + * `503 SERVICE_UNAVAILABLE`. That is REST's answer in the same window + * (`RestServer.computeExecCtx`). + * + * Optional for the reason {@link resolvePrincipalId} is: without a session + * reader no session can be held. The composed runtime threads both + * (`dispatcher-plugin`). + */ + resolveGatedSessionUser?: (headers: HeaderBag) => Promise; /** Per-endpoint limiters. Required: an armed budget with nowhere to count is a wiring bug, not a config. */ limiters: EndpointRateLimiterRegistry; /** `server.trustProxy` — believe forwarded headers when keying by address. */ @@ -296,6 +325,17 @@ function anonymousDenial(): EndpointPolicyVerdict { return { verdict: 'deny', status, body }; } +/** + * The ADR-0069 refusal this seam answers: REST's answer (`403` with the gate's + * own `code` and `message`, as `enforceAuth` writes it), in this seam's + * envelope. It is the body `HttpDispatcher.enforceAuthGate` writes for the same + * session on every other dispatcher route. + */ +function authGateDenial(gate: AuthGate): EndpointPolicyVerdict { + const { status, body } = apiErrorResponse({ code: gate.code, httpStatus: 403, message: gate.message }); + return { verdict: 'deny', status, body }; +} + /** * Run the policy chain for one matched endpoint. * @@ -359,26 +399,51 @@ export async function applyEndpointPolicies(input: EndpointPolicyInput): Promise } } - // ── ② authRequired ────────────────────────────────────────────────── + // ── ② the ADR-0069 authentication-policy gate ───────────────────────── + // A session the policy HOLDS (an expired password, enforced MFA not yet + // enrolled) is gated out of every protected resource until it remediates, + // and a declared endpoint is one WHATEVER its `authRequired`: the target + // runs as the caller's execution context, so on an `authRequired: false` + // endpoint a held session would still read and write as its own user. The + // two model seams answer the same way on their public routes (REST's + // public-audience read still runs `enforceAuth` for a signed-in caller; the + // dispatcher gates before any domain resolves). Asked before the anonymous + // floor, as `enforceAuth` asks it. A held session is signed in, so the + // order between the two changes no answer. + // + // ⛔ No request path is passed, so the allow-list can grant no exemption: an + // empty path earns none (the predicate is fail-closed on it, #7898). No + // app-declared endpoint is a remediation or bootstrap route, and a declared + // path sits under the dispatcher prefix the HOST chooses, so handing it in + // would let a prefix that lands on an allow-listed boundary exempt every + // endpoint under it. This is the same reasoning as ③'s. + if (input.resolveGatedSessionUser) { + const gate = evaluateAuthGate(await input.resolveGatedSessionUser(input.headers ?? {}), ''); + if (gate) return authGateDenial(gate); + } + + // ── ③ authRequired ────────────────────────────────────────────────── // The key arrives MATERIALIZED (`ApiEndpointSchema` defaults it to `true`), // so there is no "omitted" state to read here: forgetting the key gets the // safe answer, and `authRequired: false` — the only way to open an endpoint // — is a visible line in a diff. // // ⚠️ The request PATH is deliberately not passed to `shouldDenyAnonymous`. - // Its optional path argument exempts control-plane paths, and - // `isAuthGateAllowlisted` treats ANY path containing an `/auth/` segment as - // one. An app-declared `/api/v1/apps//auth/callback` would therefore - // exempt itself from its own `authRequired: true` — an authoring-time - // bypass of the platform's default deny. Omitting the argument means no - // exemption can be reached from a declared path at all. + // Its optional path argument exempts the allow-listed control-plane routes + // (`isAuthGateAllowlisted`), anchored at a mount boundary since #16839, and + // a declared path sits under the dispatcher prefix the HOST chooses. A + // prefix that lands on an allow-listed boundary (an `auth` segment) would + // therefore let every endpoint under it exempt itself from its own + // `authRequired: true`, an authoring-time bypass of the platform's default + // deny. Omitting the argument means no exemption can be reached from a + // declared path at all. if (endpoint.authRequired !== false) { if (shouldDenyAnonymous({ userId: principalId, isSystem: false, method })) { return anonymousDenial(); } } - // ── ③ cacheTtlSeconds ────────────────────────────────────────────────────── + // ── ④ cacheTtlSeconds ────────────────────────────────────────────────────── const cacheControl = computeCacheControl(endpoint, method, logger); return {