From 09bfe1131386fe04bd8f9faea092a51345a10244 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 09:11:37 +0000 Subject: [PATCH 1/4] fix(runtime,rest,cloud-connection)!: consult the ADR-0069 gate at the declared-endpoint fallback, the REST direct mounts and the cloud-connection session doors A session the authentication policy holds (an expired password, enforced MFA not yet enrolled) is now refused with 403 and the gate's own code at three door groups that resolved a session without asking the gate: - runtime: the declared-endpoint fallback's policy chain asks evaluateAuthGate, with no request path, before authRequired, on a dedicated session read that runs only while a gate feature is active and answers 503 when an active gate cannot read the session. - rest: the publish door and the federation floor read the authGate their resolved execution context already carries. - cloud-connection: CloudConnectionPlugin's session resolution and MarketplaceInstallLocalPlugin's admission consult the gate once and refuse before any door logic. Claude-Session: https://claude.ai/code/session_01VoSxBQujKLZKPwK2u5ehQ6 Co-authored-by: Claude --- .../src/cloud-connection-plugin.ts | 54 ++++++++++-- .../src/marketplace-install-local-plugin.ts | 84 ++++++++++++++++--- .../rest/src/external-datasource-routes.ts | 34 +++++++- packages/rest/src/package-routes.ts | 29 ++++++- packages/runtime/src/dispatcher-plugin.ts | 62 +++++++++++++- packages/runtime/src/endpoint-policy.ts | 81 ++++++++++++++++-- 6 files changed, 312 insertions(+), 32 deletions(-) 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..62ce9c5f235 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 `admitInstallCaller`). + 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,25 @@ 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. + * `admitInstallCaller` refuses on it. + */ + authGate?: AuthGate; +} + /** * [#15204 W2] What an admitted mutating request carries past * `requireInstallCapability`: the acting user, plus the two inputs ADR-0131 @@ -1719,8 +1742,9 @@ export class MarketplaceInstallLocalPlugin implements Plugin { // Before the ledger is touched, exactly as the mutating doors refuse // before any work: a refused caller must not be able to learn what is // installed from timing or from a downstream storage error. - const principal = await this.resolveInstallPrincipal(c, ctx); - if (!principal) return this.refuseUnauthenticated(c); + const admitted = await this.admitInstallCaller(c, ctx); + if (!admitted.ok) return admitted.response; + const { principal } = admitted; const operator = principal.systemPermissions.includes(INSTALL_LOCAL_CAPABILITY); const { entries, skipped } = this.readAll(); @@ -3280,14 +3304,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 +3318,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 +3337,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 +3351,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 @@ -3355,6 +3389,29 @@ export class MarketplaceInstallLocalPlugin implements Plugin { error: { code: 'UNAUTHENTICATED', message: 'Authentication required.' }, }, 401); + /** + * ADR-0069: the floor all five doors open with, the listing included. + * Nobody authenticated is `401` ({@link refuseUnauthenticated}). A session + * the authentication policy HOLDS (an expired password, enforced MFA not + * yet enrolled) 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, before its capabilities are + * consulted and before the ledger is read. One helper for the same reason + * the 401 is one literal: a door added later cannot copy a weaker answer. + */ + private admitInstallCaller = async ( + c: any, + ctx: PluginContext, + ): Promise<{ ok: true; principal: InstallPrincipal } | { ok: false; response: Response }> => { + const principal = await this.resolveInstallPrincipal(c, ctx); + if (!principal) return { ok: false, response: this.refuseUnauthenticated(c) }; + if (principal.authGate) { + const { code, message } = principal.authGate; + return { ok: false, response: c.json({ success: false, error: { code, message } }, 403) }; + } + return { ok: true, principal }; + }; + /** * [#8976] The shared refusal for the four mutating routes: 401 when nobody * is authenticated, 403 when somebody is but holds no authoring capability, @@ -3379,8 +3436,9 @@ export class MarketplaceInstallLocalPlugin implements Plugin { ctx: PluginContext, action: string, ): Promise => { - const principal = await this.resolveInstallPrincipal(c, ctx); - if (!principal) return { ok: false, response: this.refuseUnauthenticated(c) }; + const admitted = await this.admitInstallCaller(c, ctx); + if (!admitted.ok) return admitted; + const { principal } = admitted; if (!principal.systemPermissions.includes(INSTALL_LOCAL_CAPABILITY)) { ctx.logger?.warn?.( `[MarketplaceInstallLocal] refused ${action} for ${principal.userId} — ` 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.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 { From 875a7bd0821d86ed43b7c883130bfea03d89792f Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 09:18:54 +0000 Subject: [PATCH 2/4] test(runtime,rest,cloud-connection): pin the ADR-0069 refusal at each door group One pin file per site group. Each drives a held session under both postures (refused 403 with the gate's code and message, no effect), the same grants without a posture (served unchanged), and the allow-listed case: the dispatcher's /health and REST's discovery document answer a held session as an un-held one, and the fallback's anti-bypass case proves a declared path under an allow-listed host prefix is still refused. The fallback and the publish door also pin the 503 answer for an active gate whose session read fails. Claude-Session: https://claude.ai/code/session_01VoSxBQujKLZKPwK2u5ehQ6 Co-authored-by: Claude --- .../cloud-connection-auth-gate.pin.test.ts | 384 ++++++++++++++++++ .../src/direct-mount-auth-gate.pin.test.ts | 267 ++++++++++++ .../src/endpoint-policy.auth-gate.pin.test.ts | 346 ++++++++++++++++ 3 files changed, 997 insertions(+) create mode 100644 packages/cloud-connection/src/cloud-connection-auth-gate.pin.test.ts create mode 100644 packages/rest/src/direct-mount-auth-gate.pin.test.ts create mode 100644 packages/runtime/src/endpoint-policy.auth-gate.pin.test.ts 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..6490f328d9e --- /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 open with the same + * admission (`admitInstallCaller`). + * + * 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/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/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(); + }); +}); From 20d325c425682ba961145eccb5edbbae9b7a0eef Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 09:27:41 +0000 Subject: [PATCH 3/4] chore(changeset): the ADR-0069 narrowing at the runtime, rest and cloud-connection doors minor for each released package, BREAKING under the launch-window convention, with the ADR-0087 not-required disposition. Claude-Session: https://claude.ai/code/session_01VoSxBQujKLZKPwK2u5ehQ6 Co-authored-by: Claude --- .changeset/23190-auth-gate-cli-sites.md | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 .changeset/23190-auth-gate-cli-sites.md 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. From b1ac2584067cfdfe58c9d836038c7dd6f2edec1e Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 09:46:33 +0000 Subject: [PATCH 4/4] refactor(cloud-connection): answer a held install-local session through a module-private refusal The refusal moves out of the exported plugin class into a module-private function, so the emitted declarations gain no private member. Both door paths (the listing and the four mutating doors) still answer the 401 first and the held session second, on the one consult in resolveInstallPrincipal. Claude-Session: https://claude.ai/code/session_01VoSxBQujKLZKPwK2u5ehQ6 Co-authored-by: Claude --- .../cloud-connection-auth-gate.pin.test.ts | 4 +- .../src/marketplace-install-local-plugin.ts | 58 +++++++++---------- 2 files changed, 29 insertions(+), 33 deletions(-) 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 index 6490f328d9e..ee57748b85e 100644 --- a/packages/cloud-connection/src/cloud-connection-auth-gate.pin.test.ts +++ b/packages/cloud-connection/src/cloud-connection-auth-gate.pin.test.ts @@ -12,8 +12,8 @@ * (`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 open with the same - * admission (`admitInstallCaller`). + * (`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 diff --git a/packages/cloud-connection/src/marketplace-install-local-plugin.ts b/packages/cloud-connection/src/marketplace-install-local-plugin.ts index 62ce9c5f235..42622829a2f 100644 --- a/packages/cloud-connection/src/marketplace-install-local-plugin.ts +++ b/packages/cloud-connection/src/marketplace-install-local-plugin.ts @@ -113,7 +113,7 @@ import { resolveAuthzContext, isAuthzStoreUnavailableError, // ADR-0069 — the authentication-policy gate's ONE decision, asked of the - // session `resolveInstallPrincipal` reads (see `admitInstallCaller`). + // session `resolveInstallPrincipal` reads (see `refuseHeldSession`). evaluateAuthGate, type AuthGate, // [#16013] The ADMISSION seam's posture derivation, in ONE call — the same @@ -547,11 +547,28 @@ interface InstallPrincipal { /** * ADR-0069: the authentication-policy posture of the session this * admission resolved, present only for a session the policy holds. - * `admitInstallCaller` refuses on it. + * `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 @@ -1742,9 +1759,10 @@ export class MarketplaceInstallLocalPlugin implements Plugin { // Before the ledger is touched, exactly as the mutating doors refuse // before any work: a refused caller must not be able to learn what is // installed from timing or from a downstream storage error. - const admitted = await this.admitInstallCaller(c, ctx); - if (!admitted.ok) return admitted.response; - const { principal } = admitted; + 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(); @@ -3389,29 +3407,6 @@ export class MarketplaceInstallLocalPlugin implements Plugin { error: { code: 'UNAUTHENTICATED', message: 'Authentication required.' }, }, 401); - /** - * ADR-0069: the floor all five doors open with, the listing included. - * Nobody authenticated is `401` ({@link refuseUnauthenticated}). A session - * the authentication policy HOLDS (an expired password, enforced MFA not - * yet enrolled) 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, before its capabilities are - * consulted and before the ledger is read. One helper for the same reason - * the 401 is one literal: a door added later cannot copy a weaker answer. - */ - private admitInstallCaller = async ( - c: any, - ctx: PluginContext, - ): Promise<{ ok: true; principal: InstallPrincipal } | { ok: false; response: Response }> => { - const principal = await this.resolveInstallPrincipal(c, ctx); - if (!principal) return { ok: false, response: this.refuseUnauthenticated(c) }; - if (principal.authGate) { - const { code, message } = principal.authGate; - return { ok: false, response: c.json({ success: false, error: { code, message } }, 403) }; - } - return { ok: true, principal }; - }; - /** * [#8976] The shared refusal for the four mutating routes: 401 when nobody * is authenticated, 403 when somebody is but holds no authoring capability, @@ -3436,9 +3431,10 @@ export class MarketplaceInstallLocalPlugin implements Plugin { ctx: PluginContext, action: string, ): Promise => { - const admitted = await this.admitInstallCaller(c, ctx); - if (!admitted.ok) return admitted; - const { principal } = admitted; + 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} — `