Skip to content

Commit d9d7edd

Browse files
fix(runtime,rest,cloud-connection)!: the declared-endpoint fallback, REST's direct mounts and cloud-connection's session doors refuse a session the ADR-0069 policy holds (#23190) (#23242)
Fixes #23190 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. ## What changed 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. The posture rides on the session user (`user.authGate`, from plugin-auth's `customSession` enrichment). REST's `enforceAuth` and `HttpDispatcher.enforceAuthGate` refuse such a session with `403` and the gate's `code`. Three groups of doors in `domain:cli` packages resolve a session on their own and never asked. This PR closes all three, in triage's order. Each consults the gate **once, at its own door**, using only existing `@objectstack/core` exports, and refuses through its own declared error envelope with the REST seam's answer: `403`, the gate's `code` and the gate's `message`. 1. **`@objectstack/runtime`, the declared-endpoint fallback** (`runAppEndpointStep` → `applyEndpointPolicies`, wired in `dispatcher-plugin.ts`). The policy chain gains one step, between the meter and `authRequired`: it asks `evaluateAuthGate` about the session user a new policy dependency returns (`EndpointPolicyContext.resolveGatedSessionUser`, package-internal). The dispatcher plugin wires that dependency to `readGatedSessionUser`, which mirrors REST's posture read (`RestServer.computeExecCtx`) case for case: no read while no gate feature is active; a dedicated session read while one is; and the branded authorization-store outage (`503 SERVICE_UNAVAILABLE`) when an active gate cannot read the session. The step runs on every declared endpoint, an `authRequired: false` one included, because the target runs as the caller's execution context. That is also how the two model seams treat their public routes: REST's public-audience read still runs `enforceAuth` for a signed-in caller, and the dispatcher gates before any domain resolves. 2. **`@objectstack/rest`, the direct mounts.** `registerPackageRoutes`'s publish door (`refusePackageRequest`) and the federation floor of `registerExternalDatasourceRoutes` (`refuseFederationRequest`) now refuse on the `authGate` that the `RestServer` resolver already lifts onto the context it hands them. Asked first, as `enforceAuth` asks it, so a held caller's capabilities are not consulted. The registrars' option types are package-internal; each gains the declared `authGate` field on the context it already received. 3. **`@objectstack/cloud-connection`, the plugins that resolve a session.** - `CloudConnectionPlugin` reads the session in one place, `sessionFromAuthService`, under both resolution modes (the host kernel's `auth` in single-environment mode, the environment's own kernel's in multi-tenant mode). The consult sits there, and each of the seven doors that resolve a session refuses on it before its own 401 and before any control-plane call. - `MarketplaceInstallLocalPlugin` reads it in one place too, the `getSession` bridge inside `resolveInstallPrincipal`. That bridge runs only when no API key admitted the caller. The consult sits after the shared resolver, on the same session it admitted. All five doors (the listing and the four mutating doors) answer a held session through one module-private refusal, after the 401 and before capabilities or the ledger. No new export, option, error code or spec key, and no copy of the allow-list predicate. The new names (`resolveGatedSessionUser`, `readGatedSessionUser`, `authGateDenial`, `ResolvedSession`, `InstallPrincipal`, `refuseHeldSession`) are all package-internal, and each package's `exports` map publishes `"."` alone. The emitted declarations are measured below: byte-identical for runtime and rest, and identical for cloud-connection except one docblock's prose. ### Request path: not passed, per site Every site asks the decision with **no request path**, so the shared allow-list can grant no exemption. The empty path is the predicate's declared fail-closed input (#7898). - **Declared-endpoint fallback:** the card's trap. The allow-list is anchored at a mount boundary (#16839), so under the shipped prefix no declared path is allow-listed (`/api/v1/apps/NS/auth/callback` is not). 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. Handing the path in would let such a host exempt every endpoint it serves. The pin's anti-bypass case drives exactly that shape, after first asserting the path really is allow-listed. The stale `authRequired` comment beside it, which still described the pre-#16839 unanchored match, now says this. - **REST direct mounts:** each registrar's base path is the host's argument, as for #23178's datasource-admin family. Neither route is a remediation or bootstrap route. - **Cloud-connection:** neither plugin mounts an allow-listed route (the pin's census asserts it for all 13 routes). ## The two model seams, measured | case | REST `enforceAuth` | `HttpDispatcher.enforceAuthGate` | the new sites | |---|---|---|---| | held session | `403`, flat `{ error: { code, message } }` | `403`, `{ success: false, error: { code, message, httpStatus } }` | `403` with the gate's `code` and `message`, in each door's envelope | | active gate, session read fails | `503 SERVICE_UNAVAILABLE` (the #13906 decision-2 window) | **fails open**: the request proceeds | `503` where the posture comes from a second read, as on REST: the fallback (its own read) and the publish door (it relays REST's resolver throw). The federation floor's catch-all answers that throw `401` (see the notes). Cloud-connection reads identity and posture in ONE read, so it has no such window: a failed read is the `401` it already gave | | API-key caller, or no session | not held | not held | not held: there is no session to hold | The dispatcher's held and outage rows were driven against `HttpDispatcher.dispatch` with a session whose read succeeds, then throws. The REST rows are the boot's control reading below plus REST's own pinned measurement (`auth-gate-allowlist-fault-window.measurement.test.ts`, CONTROL A). ## Measurement first (the card's last acceptance item) The showcase was booted the way `objectstack serve` composes it (`bootShowcase`, with the three cloud-connection plugins mounted as `serve` mounts them, pointed at a local control-plane stub). Every door was driven once anonymous, once by a signed-in admin, and once by the same admin held under each posture: an admin-issued force change (`PASSWORD_EXPIRED`) and enforced MFA, not enrolled, grace elapsed (`MFA_REQUIRED`). The before run had this PR's six site files restored to the base, `runtime` and `rest` rebuilt from them (the runtime dist was proven free of the change by `ablation-dist-preflight --absent`), and `cloud-connection` read from source, as that suite aliases it. The after run is the same boot at the fix (`875a7bd082`). Route-level readings are in the lane's private channel only. | Site group | Before | After | |---|---|---| | declared-endpoint fallback (the showcase's two `authRequired` endpoints, one a write) | reached: the held session got the un-held answer | refused, both postures | | REST direct mounts (`registerPackageRoutes` publish, `registerExternalDatasourceRoutes`) | reached | refused, both postures | | `CloudConnectionPlugin`, the seven session doors | reached | refused, both postures | | `MarketplaceInstallLocalPlugin`, all five doors | reached | refused, both postures | | `CloudConnectionPlugin` status read, `MarketplaceProxyPlugin` | **no session resolved**: held, un-held and anonymous got the same answer | unchanged (see below) | Controls on the same boot, in both runs: the REST data list and the dispatcher's notifications refused each held posture with the gate's code, and an allow-listed auth read answered `200`. No site was gated in front, so none is dropped. An anonymous caller and an un-held caller got the same answers before and after on every door. **Not changed: the two doors that resolve no session.** The card's acceptance places the consult "where they resolve their session". `CloudConnectionPlugin`'s status read and `MarketplaceProxyPlugin` (the public catalog, forwarded with no credential) resolve none, and both answered the held session exactly as they answer an anonymous caller. Nothing is read or written as the held user there. Gating them would add a session read to two public doors, only to refuse a caller who is served without the cookie anyway. This is raised to the seat as an open question rather than decided here. ## Pins One pin file per site group, beside its site. Each covers the card's three cases under both postures: - **Held:** the status, `error.code` and `error.message`, and no `data`. - **Un-held:** the same grants without a posture, served as before. - **Allow-listed:** the third case, per site. The pin files: - **`runtime`, `endpoint-policy.auth-gate.pin.test.ts`** (16 tests). This is a real LiteKernel with the real Hono transport and the real dispatcher plugin, driven over a socket, with the services stubbed at the kernel boundary. - Held, on four declared endpoints: a read, a write (a flow), an `authRequired: false` read, and the literal `.../auth/callback` shape. The engine was never read and the flow never ran. - Un-held, and anonymous: unchanged. - Allow-listed: the dispatcher's `/health` answers a held session as it answers an un-held one. - Outage: `503 SERVICE_UNAVAILABLE`, and nothing ran. - Gate off: the posture is never read. - Anti-bypass: the step under a host prefix whose declared path IS allow-listed, a precondition the case asserts first, still refuses the held session, and the target never runs. - **`rest`, `direct-mount-auth-gate.pin.test.ts`** (6 tests). This is the real composition (`createRestApiPlugin(...).start(ctx)`), so the posture reaches the doors through the `RestServer` resolver's real lift. - Held: all six routes refused, and no service ran. The caller holds every capability the family asks for, so the refusal is the gate's alone. - Un-held: each route's own success status, and each service ran. - Anonymous: `401`, unchanged. - Allow-listed: the discovery document, the one allow-listed route this composition mounts, answers held and un-held identically. - Outage: the publish door answers `503 SERVICE_UNAVAILABLE`. - **`cloud-connection`, `cloud-connection-auth-gate.pin.test.ts`** (12 tests). - `CloudConnectionPlugin`, in both resolution modes. Held: all seven session doors refused, nothing reached the control plane, and the credential was not cleared. Un-held: served. Anonymous: `401`. Census: eight routes, none allow-listed, and the status read reads no session. - `MarketplaceInstallLocalPlugin`. Held: all five doors refused, and no registry, schema, seed or delete effect fired. Un-held: every door served. Anonymous: `401`. Census: five routes, none allow-listed. ### Negative-pin ablation Each site group's consult was removed through `scripts/ablation-replace.mjs` in WRAP mode, after the fix was committed (tree `b1ac258406`). The two-file groups nest one WRAP inside the other. The group's pin file was run, then everything was restored. Each restore is proven by blob hash equal to HEAD and an empty `git diff HEAD`. The pins import their subjects from `src`, so no `dist/` leg applies. | Leg | Mutation (each anchor 1 → 0) | Result | Restore | |---|---|---|---| | fallback consult | the `authGateDenial` return replaced by `void gate` | `Tests 10 failed \| 6 passed (16)`: the 8 held boot cases and both anti-bypass cases | blob `090bc8891dd6` == HEAD | | fallback anti-bypass | the declared path handed to the allow-list (`endpoint.path` for `''`) | `Tests 1 failed \| 15 passed (16)`: the host-prefix case only. The literal `.../auth/callback` case stays green, as predicted, because the anchored allow-list does not exempt it | blob `090bc8891dd6` == HEAD | | REST direct mounts | both `authGate` refusals short-circuited | `Tests 2 failed \| 4 passed (6)`: both held cases | blobs `ae534aeb4a1d`, `6d6e073a14d3` == HEAD | | cloud-connection | both consults replaced by `null` | `Tests 6 failed \| 6 passed (12)`: the four `CloudConnectionPlugin` held cases and both install-local held cases | blobs `b3271971823f`, `42622829a2fb` == HEAD | Only held cases went red in each leg. The un-held, anonymous, allow-listed, outage, gate-off and census controls stayed green, so the pins measure the gate and nothing else. The outage case stays green under the fallback leg by construction: the read throws before the consult. ## Declarations, measured Each package was built twice on tree `b1ac258406`: once with this PR's six site files restored to the merge base (`75cada0ea6`), and once at HEAD. Hashes are sha256 prefixes. | Package | Merge base | HEAD | Result | |---|---|---|---| | `@objectstack/runtime` `dist/index.d.ts` / `.d.cts` | `1dbea2303158dba9`, 310748 bytes | `1dbea2303158dba9`, 310748 bytes | identical | | `@objectstack/rest` `dist/index.d.ts` / `.d.cts` | `991a8fc9cae955b5`, 200956 bytes | `991a8fc9cae955b5`, 200956 bytes | identical | | `@objectstack/cloud-connection` `dist/index.d.ts` / `.d.cts` | `d8619b84e1b3ed00`, 244655 bytes | `f09320554d95cabc`, 244855 bytes | declarations identical. The one hunk is two prose lines of the class's docblock becoming four, the note on the new `@objectstack/core` import | An earlier shape of the install-local refusal was a private class member, and the emitted declarations named it. It is a module-private function now, so no member is emitted. ## Responsibility No single change introduced the gap. The gate (ADR-0069 D1, `ce0b4f61e2`, 2026-06-28) was wired into the two seams that existed then, REST's `enforceAuth` and the dispatcher. The doors fall into two groups: - **Predate the gate:** the federation registrar (`2faf9f2746`, 2026-05-30), the three cloud-connection plugins (`c26264f62e` and `83a895c543`, 2026-05-23; `998c4e4eaa`, 2026-06-12), and `CloudConnectionPlugin`'s session resolution (`998c4e4eaa`). - **Built after the gate, beside it:** the identity floors were added later without the gate. That is the publish door's `refusePackageRequest` (`b295e4b36c`, 2026-08-09), the federation floor (`185c7bd51a`, 2026-08-18, and `9a1ed7a09e`, 2026-08-20), and install-local's `resolveInstallPrincipal` (`e0695b5823`, 2026-08-16). So did the declared-endpoint fallback (`2649ccbe19` and `a70cd0aebe`, 2026-08-04), which by design does not re-enter `dispatch()` and so never met `enforceAuthGate`. Each of the later changes ported the anonymous floor and the capability check from the seam it mirrored, and not the gate beside them. ## Verification (tree `faae1c0de1`) - `pnpm --filter … test`, all passed: - runtime: 367 files / 5221 tests (19 skipped). - rest: 278 / 5808 (326 skipped). - cloud-connection: 45 / 552. - `test:repo`: runtime 3 / 808; rest 5 / 213 (2 skipped). - `pnpm --filter … typecheck` exits 0 for all three. Each package's test program compiles its new pin file (`tsc -p tsconfig.test.json --listFilesOnly` names it), and `check:test-typecheck` holds both ledgers unchanged. - Gates were re-derived on this head (`dispatch-gates --commands`, 64 commands) and all run here, each exit 0. `--ran` answers: 64 derived, 64 run, 0 NOT-MEASURED, 0 UNRUN. That includes `check:adr-0087-registration` (one declared-breaking changeset with its disposition), `check-changeset-no-major`, `check:empty-changeset`, `check:route-envelope` and `check:nul-bytes`. - `check:dual-build-cjs-loads` first answered PREREQUISITE NOT MET for eight packages outside this diff. It measures, exit 0, since those were built (all cache hits). - ESLint, narrowed. The population is read from `eslint.config.mjs`, which never enables type-aware linting (no `parserOptions.project`, no typed rules), and whose per-file baselines this diff does not touch, so the diff cannot move a verdict on an untouched file. `eslint --no-inline-config --format json` over the 9 changed `.ts` files reports 9 files linted, none ignored, 0 errors and 0 warnings. - The pins were also run alone at `875a7bd082`; the ablations and the declaration check ran at `b1ac258406`. This head adds only two merges of `main`, neither touching a file of this diff. ## Acceptance notes - **The two model seams disagree on the outage window.** REST answers an active gate whose session read fails with `503`. The dispatcher's own `enforceAuthGate` fails open there, by its documented design. This PR's sites follow REST, as #23178's storage family did. The dispatcher's seam is not touched here. - **The federation floor answers any resolver throw `401`, the gate's outage included.** Its `catch` collapses every throw to "no identity", where the publish door rethrows the branded outage and answers `503`. That is the #13906 family's concern, and it predates this PR. It is noted, not changed: a held session is still refused there, and a failed read is refused too, only under the anonymous code. - `ExecutionContext.authGate.code` is declared as a bare string. Both REST doors narrow it to `ErrorCode` at their `sendError` call, as #23178's nested-envelope families do. Its one producer writes only `PASSWORD_EXPIRED` and `MFA_REQUIRED`. - `cloud-connection-plugin.ts`'s header asked the module to stay independent of `@objectstack/core` so the move into the open package stays mechanical. That move has landed, and the package already depends on core. The module keeps its structural types and now takes one value from core, the gate's decision, because a second copy of that decision is the defect it exists to prevent. The header says so. - Semver: `minor` for all three packages, with the BREAKING banner and the bang summary, plus ADR-0087 `not-required (no-migration-prescription)`, as #23178's half does. The diff narrows an accept set and moves no declaration, which is measured above. --- _Generated by [Claude Code](https://claude.ai/code/session_01VoSxBQujKLZKPwK2u5ehQ6)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 6f4c518 commit d9d7edd

10 files changed

Lines changed: 1325 additions & 28 deletions
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
---
2+
'@objectstack/runtime': minor
3+
'@objectstack/rest': minor
4+
'@objectstack/cloud-connection': minor
5+
---
6+
7+
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
8+
9+
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.
10+
11+
<!-- adr-0087: not-required (no-migration-prescription) A runtime authentication narrowing at three groups of doors, not a metadata change. No spec key, export, option, config field, response field or stored shape is removed, renamed or re-shaped, and a session the policy does not hold receives the same answers as before, so there is no tombstone and nothing for `objectstack migrate meta` to rewrite. What narrows is which sessions these doors serve: a session whose user carries the ADR-0069 posture (an expired password, or enforced MFA not yet enrolled) is now refused with the gate's 403 before the door's own logic runs. The other categories are closed on facts: the three packages publish (not unpublished); no ADR-0087 id covers these doors and this diff adds none (not registered / already-registered); and no published interface or type changes, the new fields living on package-internal types only (not runtime-interface-only / type-surface-only). -->
12+
13+
**BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes.
14+
15+
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:
16+
17+
- **`@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.
18+
- **`@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.
19+
- **`@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.
20+
21+
- **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.
22+
- **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.
23+
24+
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.

‎packages/cloud-connection/src/cloud-connection-auth-gate.pin.test.ts‎

Lines changed: 384 additions & 0 deletions
Large diffs are not rendered by default.

‎packages/cloud-connection/src/cloud-connection-plugin.ts‎

Lines changed: 48 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,10 @@
2323
* `apps/objectos` (multi-tenant) and `apps/objectos-ee` (single-environment)
2424
* — wire the same canonical implementation. cloud ADR-0008 Phase 2 moves this
2525
* surface into the open `@objectstack/cloud-connection` package; keep this
26-
* file dependency-light (structural Plugin types, no @objectstack/core) so
27-
* the move is mechanical.
26+
* file dependency-light (structural Plugin types) so the move is mechanical.
27+
* The one value it takes from `@objectstack/core` is the ADR-0069 gate's
28+
* decision, `evaluateAuthGate`: a second copy of a security decision is the
29+
* defect that function exists to prevent (see `sessionFromAuthService`).
2830
*
2931
* ## Environment & session resolution — two modes
3032
*
@@ -50,7 +52,7 @@
5052
* self-hosted runtimes).
5153
*/
5254

53-
// Minimal structural plugin contract — keep this module independent of
55+
// Minimal structural plugin contract — keep this module's TYPES independent of
5456
// @objectstack/core so the cloud ADR-0008 Phase 2 move to the open package stays
5557
// mechanical (mirrors the other plugins in this package).
5658
interface PluginContext {
@@ -78,12 +80,22 @@ interface Plugin {
7880
}
7981

8082
import { hostname } from 'node:os';
83+
import { evaluateAuthGate, type AuthGate } from '@objectstack/core';
8184
import { inProcessSessionReadInput } from '@objectstack/types';
8285
import { ConnectionCredentialStore } from './connection-credential-store.js';
8386
import { CLOUD_CONNECTION_UI_BUNDLE } from './cloud-connection-ui.js';
8487

8588
const CLOUD_CONNECTION_PREFIX = '/api/v1/cloud-connection';
8689

90+
/**
91+
* A signed-in caller of this surface, and the ADR-0069 posture its session
92+
* carries (`authGate`, present only for a session the policy holds).
93+
*/
94+
interface ResolvedSession {
95+
userId: string;
96+
authGate?: AuthGate;
97+
}
98+
8799
/**
88100
* The one message every `ENVIRONMENT_NOT_FOUND` refusal on this surface carries.
89101
*
@@ -201,17 +213,40 @@ export class CloudConnectionPlugin implements Plugin {
201213
manifest?.register?.(CLOUD_CONNECTION_UI_BUNDLE);
202214
} catch { /* no manifest service — headless kernel */ }
203215

204-
const sessionFromAuthService = async (authSvc: any, rawReq: Request): Promise<{ userId?: string } | null> => {
216+
const sessionFromAuthService = async (authSvc: any, rawReq: Request): Promise<ResolvedSession | null> => {
205217
const api = typeof authSvc?.getApi === 'function' ? await authSvc.getApi() : authSvc?.api ?? authSvc;
206218
// [#22258] The in-process session-read rule: a request carrying a
207219
// session cookie reads without renewal, because this route's
208220
// response never carries a renewed cookie.
209221
const session = await api?.getSession?.(inProcessSessionReadInput(rawReq.headers));
210222
const userId = session?.user?.id ? String(session.user.id) : undefined;
211-
return userId ? { userId } : null;
223+
if (!userId) return null;
224+
// ADR-0069: the ONE consult of the authentication-policy gate for
225+
// every door on this surface, asked of the session this read
226+
// returned (the posture rides its user, computed by plugin-auth's
227+
// `customSession` enrichment only while a gate feature is active).
228+
// Both modes read here: the host kernel's `auth` (single-env) and
229+
// the environment's own kernel's (multi-tenant).
230+
//
231+
// No request path is passed, so the allow-list can grant no
232+
// exemption (an empty path earns none, #7898). Nothing this plugin
233+
// mounts is a remediation or bootstrap route.
234+
const authGate = evaluateAuthGate(session?.user, '');
235+
return authGate ? { userId, authGate } : { userId };
212236
};
213237

214-
const resolveSession = async (environmentId: string, rawReq: Request): Promise<{ userId?: string } | null> => {
238+
/**
239+
* ADR-0069: a session the authentication policy HOLDS (an expired
240+
* password, enforced MFA not yet enrolled) is gated out of every
241+
* protected resource until it remediates. Every door that resolves
242+
* a session answers it this way, before its own 401 and before any
243+
* control-plane call: REST's answer (`403` with the gate's own
244+
* `code` and `message`), in this surface's envelope.
245+
*/
246+
const refuseHeldSession = (c: any, gate: AuthGate) =>
247+
c.json({ success: false, error: { code: gate.code, message: gate.message } }, 403);
248+
249+
const resolveSession = async (environmentId: string, rawReq: Request): Promise<ResolvedSession | null> => {
215250
try {
216251
if (this.cfg.singleEnvironment) {
217252
// Single-env: the host kernel owns auth.
@@ -294,6 +329,7 @@ export class CloudConnectionPlugin implements Plugin {
294329
return c.json({ success: false, error: { code: 'ENVIRONMENT_NOT_FOUND', message: ENVIRONMENT_NOT_FOUND_MESSAGE } }, 404);
295330
}
296331
const session = await resolveSession(environmentId ?? '', c.req.raw);
332+
if (session?.authGate) return refuseHeldSession(c, session.authGate);
297333
if (!session?.userId) return c.json({ success: false, error: { code: 'UNAUTHENTICATED', message: 'Sign in to this environment to connect a cloud account.' } }, 401);
298334
if (!cloudUrl) return c.json({ success: false, error: { code: 'CLOUD_UNCONFIGURED', message: 'No cloud control plane configured.' } }, 503);
299335
try {
@@ -349,6 +385,7 @@ export class CloudConnectionPlugin implements Plugin {
349385
return c.json({ success: false, error: { code: 'ENVIRONMENT_NOT_FOUND', message: ENVIRONMENT_NOT_FOUND_MESSAGE } }, 404);
350386
}
351387
const session = await resolveSession(environmentId ?? '', c.req.raw);
388+
if (session?.authGate) return refuseHeldSession(c, session.authGate);
352389
if (!session?.userId) return c.json({ success: false, error: { code: 'UNAUTHENTICATED', message: 'Sign in to this environment.' } }, 401);
353390
if (!cloudUrl) return c.json({ success: false, error: { code: 'CLOUD_UNCONFIGURED', message: 'No cloud control plane configured.' } }, 503);
354391

@@ -447,6 +484,7 @@ export class CloudConnectionPlugin implements Plugin {
447484
return c.json({ success: false, error: { code: 'ENVIRONMENT_NOT_FOUND', message: ENVIRONMENT_NOT_FOUND_MESSAGE } }, 404);
448485
}
449486
const session = await resolveSession(environmentId ?? '', c.req.raw);
487+
if (session?.authGate) return refuseHeldSession(c, session.authGate);
450488
if (!session?.userId) return c.json({ success: false, error: { code: 'UNAUTHENTICATED', message: 'Sign in to this environment.' } }, 401);
451489

452490
// Revoke cloud-side FIRST (the oscc_ bearer self-identifies;
@@ -495,6 +533,7 @@ export class CloudConnectionPlugin implements Plugin {
495533
// explicit env-admin role check; today only owners/admins obtain
496534
// a session via SSO-as-owner.)
497535
const session = await resolveSession(environmentId, c.req.raw);
536+
if (session?.authGate) return refuseHeldSession(c, session.authGate);
498537
if (!session?.userId) {
499538
return c.json({ success: false, error: { code: 'UNAUTHENTICATED', message: 'Sign in to this environment to install apps.' } }, 401);
500539
}
@@ -545,6 +584,7 @@ export class CloudConnectionPlugin implements Plugin {
545584
}
546585

547586
const session = await resolveSession(environmentId, c.req.raw);
587+
if (session?.authGate) return refuseHeldSession(c, session.authGate);
548588
if (!session?.userId) {
549589
return c.json({ success: false, error: { code: 'UNAUTHENTICATED', message: 'Sign in to this environment.' } }, 401);
550590
}
@@ -607,6 +647,7 @@ export class CloudConnectionPlugin implements Plugin {
607647
}
608648

609649
const session = await resolveSession(environmentId, c.req.raw);
650+
if (session?.authGate) return refuseHeldSession(c, session.authGate);
610651
if (!session?.userId) {
611652
return c.json({ success: false, error: { code: 'UNAUTHENTICATED', message: 'Sign in to this environment.' } }, 401);
612653
}
@@ -644,6 +685,7 @@ export class CloudConnectionPlugin implements Plugin {
644685
}
645686

646687
const session = await resolveSession(environmentId ?? '', c.req.raw);
688+
if (session?.authGate) return refuseHeldSession(c, session.authGate);
647689
if (!session?.userId) {
648690
return c.json({ success: false, error: { code: 'UNAUTHENTICATED', message: 'Sign in to this environment.' } }, 401);
649691
}

‎packages/cloud-connection/src/marketplace-install-local-plugin.ts‎

Lines changed: 63 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,10 @@ import type { Plugin, PluginContext } from '@objectstack/core';
112112
import {
113113
resolveAuthzContext,
114114
isAuthzStoreUnavailableError,
115+
// ADR-0069 — the authentication-policy gate's ONE decision, asked of the
116+
// session `resolveInstallPrincipal` reads (see `refuseHeldSession`).
117+
evaluateAuthGate,
118+
type AuthGate,
115119
// [#16013] The ADMISSION seam's posture derivation, in ONE call — the same
116120
// symbol `packages/rest`'s seam now imports, for the same reason. It reads
117121
// the posture IN FORCE off the kernel's `tenancy` service and carries
@@ -529,6 +533,42 @@ function organizationWallActive(ctx: PluginContext): boolean {
529533
const NO_ACTIVE_ORGANIZATION_CODE = 'PERMISSION_DENIED';
530534
const NO_ACTIVE_ORGANIZATION_STATUS = 403;
531535

536+
/**
537+
* The caller one install-local admission resolved: identity and grants off the
538+
* shared resolver, plus the two postures the doors read.
539+
*/
540+
interface InstallPrincipal {
541+
userId: string;
542+
systemPermissions: string[];
543+
/** [#15204 W2] The ADR-0095 posture rung the resolver derived. */
544+
posture?: unknown;
545+
/** [#15204 W2] The EFFECTIVE tenancy posture this admission resolved. */
546+
tenancyPosture?: TenancyPosture;
547+
/**
548+
* ADR-0069: the authentication-policy posture of the session this
549+
* admission resolved, present only for a session the policy holds.
550+
* `refuseHeldSession` refuses on it.
551+
*/
552+
authGate?: AuthGate;
553+
}
554+
555+
/**
556+
* ADR-0069: the refusal every install-local door gives a session the
557+
* authentication policy HOLDS (an expired password, enforced MFA not yet
558+
* enrolled), right after the 401 and before the caller's capabilities are
559+
* consulted or the ledger is read. Such a session is gated out of every
560+
* protected resource until it remediates, so it gets REST's answer: `403`
561+
* with the gate's own `code` and `message`, in this plugin's envelope. The
562+
* consult itself happens once, in `resolveInstallPrincipal`; this only
563+
* answers on it, in one place, so a door added later cannot copy a weaker
564+
* answer. `null` when the session is not held.
565+
*/
566+
function refuseHeldSession(c: any, principal: InstallPrincipal): Response | null {
567+
if (!principal.authGate) return null;
568+
const { code, message } = principal.authGate;
569+
return c.json({ success: false, error: { code, message } }, 403);
570+
}
571+
532572
/**
533573
* [#15204 W2] What an admitted mutating request carries past
534574
* `requireInstallCapability`: the acting user, plus the two inputs ADR-0131
@@ -1721,6 +1761,8 @@ export class MarketplaceInstallLocalPlugin implements Plugin {
17211761
// installed from timing or from a downstream storage error.
17221762
const principal = await this.resolveInstallPrincipal(c, ctx);
17231763
if (!principal) return this.refuseUnauthenticated(c);
1764+
const held = refuseHeldSession(c, principal);
1765+
if (held) return held;
17241766
const operator = principal.systemPermissions.includes(INSTALL_LOCAL_CAPABILITY);
17251767

17261768
const { entries, skipped } = this.readAll();
@@ -3280,14 +3322,7 @@ export class MarketplaceInstallLocalPlugin implements Plugin {
32803322
private resolveInstallPrincipal = async (
32813323
c: any,
32823324
ctx: PluginContext,
3283-
): Promise<{
3284-
userId: string;
3285-
systemPermissions: string[];
3286-
/** [#15204 W2] The ADR-0095 posture rung the resolver derived. */
3287-
posture?: unknown;
3288-
/** [#15204 W2] The EFFECTIVE tenancy posture this admission resolved. */
3289-
tenancyPosture?: TenancyPosture;
3290-
} | null> => {
3325+
): Promise<InstallPrincipal | null> => {
32913326
const headers = c?.req?.raw?.headers;
32923327
if (!headers) return null;
32933328
try {
@@ -3301,6 +3336,13 @@ export class MarketplaceInstallLocalPlugin implements Plugin {
33013336
let ql: IObjectQLEngine | undefined;
33023337
try { ql = ctx.getService<IObjectQLEngine>('objectql'); } catch { /* no data engine */ }
33033338

3339+
// The session user the bridge below read, kept so the ADR-0069 gate
3340+
// is asked about the SAME session the resolver admitted: one read,
3341+
// both decisions. The resolver calls the bridge only when no API
3342+
// key admitted the caller, so a key caller leaves it unset and no
3343+
// policy holds it.
3344+
let sessionUser: unknown;
3345+
33043346
// The better-auth session bridge — resolved lazily and defensively,
33053347
// exactly as the previous implementation did, then handed to the
33063348
// shared resolver instead of being trusted on its own.
@@ -3313,7 +3355,9 @@ export class MarketplaceInstallLocalPlugin implements Plugin {
33133355
}
33143356
// [#22258] The in-process session-read rule, as in
33153357
// `resolveActiveOrgId` above.
3316-
return await api?.getSession?.(inProcessSessionReadInput(h));
3358+
const session = await api?.getSession?.(inProcessSessionReadInput(h));
3359+
sessionUser = session?.user;
3360+
return session;
33173361
} catch {
33183362
return undefined;
33193363
}
@@ -3325,11 +3369,19 @@ export class MarketplaceInstallLocalPlugin implements Plugin {
33253369
const tenancyPosture = await this.resolveAdmissionTenancyPosture(ctx);
33263370
const authz = await resolveAuthzContext({ ql, headers, getSession, tenancyPosture });
33273371
if (!authz.userId) return null;
3372+
// ADR-0069: the ONE consult of the authentication-policy gate for
3373+
// all five doors, on the session read above (the posture rides its
3374+
// user, computed by plugin-auth's `customSession` enrichment only
3375+
// while a gate feature is active). No request path is passed, so
3376+
// the allow-list can grant no exemption (an empty path earns none,
3377+
// #7898); no install-local route is a remediation or bootstrap route.
3378+
const authGate = evaluateAuthGate(sessionUser, '');
33283379
return {
33293380
userId: String(authz.userId),
33303381
systemPermissions: Array.isArray(authz.systemPermissions) ? authz.systemPermissions : [],
33313382
posture: authz.posture,
33323383
tenancyPosture,
3384+
...(authGate ? { authGate } : {}),
33333385
};
33343386
} catch (err) {
33353387
// [commit 6a180e42d] `null` here means "nobody is authenticated", which is
@@ -3381,6 +3433,8 @@ export class MarketplaceInstallLocalPlugin implements Plugin {
33813433
): Promise<InstallAdmission | { ok: false; response: Response }> => {
33823434
const principal = await this.resolveInstallPrincipal(c, ctx);
33833435
if (!principal) return { ok: false, response: this.refuseUnauthenticated(c) };
3436+
const held = refuseHeldSession(c, principal);
3437+
if (held) return { ok: false, response: held };
33843438
if (!principal.systemPermissions.includes(INSTALL_LOCAL_CAPABILITY)) {
33853439
ctx.logger?.warn?.(
33863440
`[MarketplaceInstallLocal] refused ${action} for ${principal.userId} — `

0 commit comments

Comments
 (0)