diff --git a/.changeset/execctx-authz-input-seams-fail-closed.md b/.changeset/execctx-authz-input-seams-fail-closed.md new file mode 100644 index 0000000000..2019ba3c0e --- /dev/null +++ b/.changeset/execctx-authz-input-seams-fail-closed.md @@ -0,0 +1,40 @@ +--- +'@objectstack/rest': minor +--- + +REST no longer reads a FAILED authorization-input lookup as "this check does not apply" — the tenancy-posture and ADR-0069 auth-gate seams in `computeExecCtx` fail closed + +Two seams inside `RestServer.computeExecCtx` absorbed a FAILURE into the same `undefined` an +ABSENT wiring produces, and both feed authorization inputs. Unlike the sibling repairs in this +family — where an unknown was answered as a REFUSAL — these two pointed the other way: a failure +read as *permissive*, so a refusal was SKIPPED rather than produced. Driven on a real +`ObjectKernel`, each fault beside a positive control that is the same fixture with the one fault +removed: + +| wiring | before | after | +|:--|:--|:--| +| healthy `isolated` tenancy, ex-member's org-stamped API key | 401 refused | 401 — unchanged | +| `tenancy` never registered (supported no-tenancy composition) | 200 served | 200 — unchanged | +| `tenancy` registered and FAILED to construct | **200 served, full grants** | **503** | +| auth gate INACTIVE | admitted | admitted — unchanged | +| auth gate ACTIVE, healthy re-read, gated user | 403 | 403 — unchanged | +| `isAuthGateActive()` itself THROWS | admitted | admitted — unchanged | +| auth gate ACTIVE, session re-read FAILS | **admitted, no wire trace** | **503** | + +- **Tenancy posture.** Only the service registry's *branded* "never registered" rejection is + absorbed — the `isServiceNotRegisteredError` discriminator the shipped `objectQLProvider` + already uses one layer down. Every other rejection (a factory that threw, a scoped registration + resolved without a scope id, a circular service dependency) raises the same loud + `AuthzStoreUnavailableError` the data-engine seam raises, so the door answers a server-side + outage instead of serving the request. The classification is the registry's, never message text. + The WIRING fact is taken from the kernel's presence and never inferred from what the read + returned. +- **ADR-0069 auth gate.** Fails closed in one precisely measured window only: `isAuthGateActive()` + answered `true` **and** the gate's session re-read then failed. A gate the deployment declared + active no longer vanishes silently. The common inactive path, a probe that throws, and a + successful re-read carrying no gate all keep their existing behaviour. + +Boot behaviour is unchanged: no composition that starts today stops starting. Single-kernel REST +deployments — the wiring with no `kernel-manager` service — keep their current behaviour exactly, +including the fact that `computeExecCtx` reads no tenancy posture there. What that wiring actually +skips is being measured separately and is not changed here. diff --git a/content/docs/permissions/system-context.mdx b/content/docs/permissions/system-context.mdx index 0a598b835c..f5d5111a7a 100644 --- a/content/docs/permissions/system-context.mdx +++ b/content/docs/permissions/system-context.mdx @@ -64,7 +64,7 @@ not on any flag. ## How the flag is set `isSystem` is **server-constructed and never client-supplied**. Inbound HTTP -cannot set it (`packages/rest/src/rest-server.ts:1520`, `:1549`), and neither +cannot set it (`packages/rest/src/rest-server.ts:1524`, `:1553`), and neither can an action body (`packages/runtime/src/domains/actions.ts:404`). It is written by internal callers only, as an option on the engine call: @@ -103,7 +103,7 @@ that silently does not happen. | 14 | MCP stdio bridge skips the object API-exposure gate | mcp | Get: the bridge reaches objects whose `apiEnabled` / `apiMethods` would refuse an external caller | `stdio-data-bridge.ts:246` | | 15 | **Read-audit rows are not written** | plugin-audit | Lose: the "a person opened this record" trail. `sudo()` keeps the caller's `userId`, so this flag is the only thing separating a human read from a platform one | `read-audit.ts:556` | | 16 | Approval snapshot payload redaction skipped | plugin-approvals | Get: the whole snapshot on `find` / `findOne` — the audit/replay channel. Lose: field-visibility redaction over approval payloads | `payload-redaction-middleware.ts:115` | -| 17 | REST anonymous-deny seam satisfied | rest | Get: `enforceAuth` passes with no `userId`. Not reachable from the wire — `isSystem` is never set on an inbound request | `rest-server.ts:1552` | +| 17 | REST anonymous-deny seam satisfied | rest | Get: `enforceAuth` passes with no `userId`. Not reachable from the wire — `isSystem` is never set on an inbound request | `rest-server.ts:1556` | ### 2. Write pipeline and data integrity @@ -158,7 +158,7 @@ The largest single consumer — **17 of the 106 sites**. |:--|:---|:---|:---|:---| | 48 | Object API-exposure gate bypassed (`apiEnabled` / `apiMethods`) | runtime | Get: internal self-writes ignore exposure declarations — these govern **external** exposure, not engine self-writes | `action-execution.ts:138` | | 49 | Action `requiredPermissions` bypassed | runtime | Get: engine self-invocation runs any action | `action-execution.ts:401` | -| 50 | `manage_metadata` bypassed on metadata writes | runtime, rest | Get: schema writes without the capability | `domains/meta.ts:471`, `:874`, `rest-server.ts:4789`, `:6203`, `:6451`, `:6882`, `:7075` | +| 50 | `manage_metadata` bypassed on metadata writes | runtime, rest | Get: schema writes without the capability | `domains/meta.ts:471`, `:874`, `rest-server.ts:4888`, `:6302`, `:6550`, `:6981`, `:7174` | | 51 | The shared metadata-write verdict itself returns `allowed` | metadata-core | Get: the one function all of row 50's doors consult answers yes before any capability is examined | `meta-write-capability.ts:134` | | 52 | Anonymous-deny seam satisfied on the domain dispatchers and the package/federation routes | runtime, rest | Get: passes with no `userId` | `domains/actions.ts:411`, `domains/ai.ts:60`, `domains/automation.ts:989`, `domains/meta.ts:232`, `domains/security.ts:78`, `domains/packages.ts:326`, `external-datasource-routes.ts:302`, `package-routes.ts:97` | | 53 | MCP principal check satisfied | runtime | Get: MCP surface reachable with no user | `domains/mcp.ts:61` | @@ -199,7 +199,7 @@ assuming `isSystem` covers it is a documented source of bugs. | "It preserves a supplied `updated_at` / `updated_by`" | **No.** That is `preserveAudit`, a separate opt-in — and an UPDATE-path exemption only | `field.zod.ts:1537` (#3493 / #6640) | | "It stamps `created_by`" | **No.** Audit stamping reads `userId` from the context. A user-less system write stamps nothing — that is today's behaviour, not an error | `runtime-identity.ts:280`–`281` | | "It bypasses every guard" | **No.** The last-admin guard applies to **every** context, `isSystem` included — the deprovision path that actually locks an org out is the system one | `last-admin-guard.ts:299` | -| "A client can request it" | **No.** Never settable from inbound HTTP or from an action body | `rest-server.ts:1520`, `:1549`; `domains/actions.ts:404` | +| "A client can request it" | **No.** Never settable from inbound HTTP or from an action body | `rest-server.ts:1524`, `:1553`; `domains/actions.ts:404` | --- diff --git a/packages/rest/src/execctx-authz-input-seam-reachability.test.ts b/packages/rest/src/execctx-authz-input-seam-reachability.test.ts index a56a0af820..ec60cd0775 100644 --- a/packages/rest/src/execctx-authz-input-seam-reachability.test.ts +++ b/packages/rest/src/execctx-authz-input-seam-reachability.test.ts @@ -5,17 +5,56 @@ * FAILURE into the same `undefined` an ABSENT wiring produces — and both feed * AUTHORIZATION inputs: the tenancy posture and the ADR-0069 auth gate. * - * ## ⛔ This is a MEASUREMENT file. It repairs nothing and proposes nothing. + * ## This file WAS a measurement. The ruling landed, and the pins are re-aimed. * - * #13906 records a code reading ("no severity asserted and no direction - * measured") and its dispatch order was explicit: the first deliverable is a - * READING, not a repair. Every assertion below pins what the tree DOES today, - * driven through the real supplier — including the permissive answers. - * ⛔ A pin on a permissive answer is a measurement, not an endorsement: - * whether any of it should CHANGE is a ruling this file deliberately does not - * take. If a later ruling repairs a seam, invert the pin IN PLACE and quote - * the superseded text beside it, as `package-door-execctx-fault-reachability` - * did for #13279/#13476. + * Phase 1 pinned what the tree DID — including the permissive answers — under + * a standing instruction: "if a later ruling repairs a seam, invert the pin IN + * PLACE and quote the superseded text beside it". ⭐ That is what happened, and + * every re-aimed assertion below carries its SUPERSEDED text in a comment, so + * the change is legible from this file alone rather than only from git. + * + * **Maintainer ruling, 2026-09-02** (director seat, summon #8; verbatim + * 「14324 等我发版,其他同意」 adopting the recommendation as presented): + * + * - **Decision 1 — tenancy posture: A.** Absorb ONLY the branded + * not-registered rejection (`isServiceNotRegisteredError`, the + * discriminator the shipped `objectQLProvider` already uses one layer + * down); every unbranded failure fails closed instead of collapsing into + * the absent-posture path. ⛔ Option B (wiring a tenancy provider into the + * single-kernel path) was NOT taken. ⭐ The 2026-09-02 ruling also carried + * a B′ half — a BOOT refusal of a wall-enforcing posture on the + * single-kernel wiring — and the **2026-09-04 ruling WITHDREW it**; see + * the note below. + * - **Decision 2 — ADR-0069 auth gate: B.** Fail closed in the measured + * window ONLY: `isAuthGateActive()` answered `true` AND the gate re-read + * then failed. ⛔ The common inactive path, and a probe that throws, are + * untouched. + * + * ⚠️ Both halves are runtime authorization behaviour on the MANUAL FLOOR (the + * 2026-08-28 negative-boundary ruling); the ruling above is the control. ⛔ Do + * not widen either repair on the strength of this file — the narrowness + * controls below exist precisely to make widening fail. + * + * **Maintainer ruling, 2026-09-04** (live chat, verbatim + * 「按照你的建议,你帮我跟进处理15020」, recorded on the card): + * decision 1 narrows to **A alone**; **B′ is WITHDRAWN** and its plugin block + * and dedicated test file are removed. It supersedes only the B′ half of the + * 2026-09-02 ruling — A and decision 2 stand exactly as ruled there. + * + * Why: the only registrar of a `kernel-manager` service in this repository was + * B′'s own narrowness control, so the refusal fired on every real walled + * composition the open core can build (`os serve` under `isolated`, the + * ADR-0105 harness, seven dogfood suites), and its premise was false where it + * fired — a wall-enforcing effective posture REQUIRES `org-scoping`, which is + * exactly what keeps the platform's `organization_id` row policies standing + * (ADR-0105 D3). B′'s underlying question — what the single-kernel provider + * wiring actually skips — survives as its own measurement card, #15163. + * + * ⚠️ §3 below therefore measures 200 on the provider wiring, and that stays + * CORRECT and stays PINNED: withdrawing B′ returns that wiring to `main`'s + * behaviour, and neither ruling changed `computeExecCtx`, which still never + * reads a posture there. ⛔ That 200 is the subject of #15163, not a + * regression introduced here. * * ## Why this card is not its siblings, and why the direction matters * @@ -123,18 +162,40 @@ describe('[#13906] §0 — the two seams are LIVE on today\'s tree, by symbol', const body = computeExecCtxBody(SOURCE); const sites = enumerateAbsorbSites(body); - it('KNOWN POSITIVE: the tenancy-posture seam still absorbs to undefined', () => { - // The card's quoted shape, re-derived: the posture read sits in a try - // whose catch assigns undefined, and it dereferences the local `kernel`. + it('REPAIRED [decision 1 A]: the tenancy-posture seam keeps not-registered apart from failed', () => { + // SUPERSEDED PIN, quoted so the change is legible rather than lost. + // Before the 2026-09-02 ruling this asserted the collapse: + // expect(body).toMatch(/catch\s*\{\s*\n\s*tenancyPosture = undefined;/); + // i.e. EVERY rejection became `undefined`. It now must not match, because + // only the branded not-registered rejection may take that path. expect(body).toMatch(/tenancyPosture = effectiveTenancyPosture\(await kernel\.getServiceAsync\('tenancy'\)/); - expect(body).toMatch(/catch\s*\{\s*\n\s*tenancyPosture = undefined;/); - expect(sites.some((s) => s.includes('tenancyPosture = undefined') || /catch/.test(s))).toBe(true); + expect(body).not.toMatch(/catch\s*\{\s*\n\s*tenancyPosture = undefined;/); + // The discriminator is the REGISTRY's brand, never message text (#13905). + expect(body).toMatch(/if \(!isServiceNotRegisteredError\(err\)\) \{\s*\n\s*throw new AuthzStoreUnavailableError\('tenancy', err\);/); + // ⛔ And the WIRING fact is asked of `kernel`'s presence AND of the async + // accessor's — never inferred from the returned value (the #13476 + // discipline this repair inherits). The accessor half matters on its own: + // a `KernelBase`-shaped host (`LiteKernel`) has no `getServiceAsync`, and + // without this guard the dereference would raise an unbranded `TypeError` + // and turn that host shape into a 503. + expect(body).toMatch(/let tenancyPosture;\s*\n\s*if \(kernel && typeof kernel\.getServiceAsync === 'function'\) \{/); }); - it('KNOWN POSITIVE: the auth-gate seam still absorbs to undefined', () => { - expect(body).toMatch(/isAuthGateActive === 'function' && authService\.isAuthGateActive\(\)/); - // The comment-only swallow, verbatim class: gate is best-effort. - expect(body).toMatch(/catch\s*\{\s*\/\*\s*gate is best-effort/); + it('REPAIRED [decision 2 B]: the auth-gate seam fails closed in the measured window only', () => { + // SUPERSEDED PIN, quoted: + // expect(body).toMatch(/catch\s*\{\s*\/\*\s*gate is best-effort/); + // the comment-only swallow that made a FAILED re-read of an ACTIVE gate + // indistinguishable from gate-off. That swallow is gone. + expect(body).not.toMatch(/catch\s*\{\s*\/\*\s*gate is best-effort/); + expect(body).toMatch(/isAuthGateActive === 'function'\s*\n?\s*&& authService\.isAuthGateActive\(\) === true/); + // The re-read is loud, and it is the RAW api call — ⛔ not the swallowing + // `getSession` closure, which would re-collapse the very same two facts. + expect(body).toMatch(/gatedSession = await api\.getSession\(\{ headers \}\);/); + expect(body).toMatch(/throw new AuthzStoreUnavailableError\('auth_gate', err\);/); + // ⛔ NARROWNESS CONTROL: the probe-throws leg must STAY absorbed — a host + // whose probe faults never declared a gate. If this ever flips, the repair + // has widened past the window the ruling named. + expect(body).toMatch(/\} catch \{\s*\n\s*gateActive = false;\s*\n\s*\}/); }); it('KNOWN NEGATIVE: the engine seam #13476 repaired is NOT flagged as absorb', () => { @@ -409,14 +470,21 @@ describe('[#13906] §2 — the Layer 0 ex-member refusal, and what a failed post expect(authz.userId).toBeUndefined(); }); - it('⚠️ MEASURED PERMISSIVE: tenancy REGISTERED AND FAILING (factory throws) → the refusal is SKIPPED and the ex-member key is served 200', async () => { + it('REPAIRED [decision 1 A]: tenancy REGISTERED AND FAILING (factory throws) → 503 outage, no longer a served 200', async () => { + // SUPERSEDED PIN, quoted — this is the row the card was filed for: + // expect(captured.status).toBe(200); + // expect(captured.body?.success).toBe(true); + // A tenancy service that could not be CONSTRUCTED skipped the Layer 0 + // refusal, so an ex-member's org-stamped key was SERVED with full grants. + // // ONE condition changed from the positive control: the tenancy service - // fails to construct — the registry's own unbranded rejection reaches the - // seam's catch and the posture becomes `undefined`. + // fails to construct — the registry's own UNBRANDED rejection. const kernel = kernelWith({ ql: qlWith({ memberships: MEMBER_ROWS }), tenancy: 'factory-throws' }); const captured = await drive(mount(serverWith(viaKernelManager(kernel))), { 'x-api-key': RAW_EXMEMBER_KEY }); - expect(captured.status).toBe(200); - expect(captured.body?.success).toBe(true); + expect(captured.status).toBe(503); + // ⭐ And it answers as an OUTAGE, not as a permission denial — the + // distinction #13279 ruled on and this repair reuses rather than reinvents. + expect(captured.body?.success).not.toBe(true); }); it('⚠️ MEASURED PERMISSIVE (mechanism): with the posture absent the resolver ADMITS the ex-member as a full principal', async () => { @@ -435,19 +503,28 @@ describe('[#13906] §2 — the Layer 0 ex-member refusal, and what a failed post expect(authz.tenantId).toBe('org_A'); }); - it('THE COLLAPSE: "registered and failed" and "never registered" answer byte-identically at the door', async () => { + it('THE COLLAPSE IS ENDED: "registered and failed" and "never registered" no longer answer alike', async () => { + // SUPERSEDED PIN, quoted — the card's subject in one assertion: + // expect(a).toEqual(b); + // expect(a.status).toBe(200); const failed = kernelWith({ ql: qlWith({ memberships: MEMBER_ROWS }), tenancy: 'factory-throws' }); const absent = kernelWith({ ql: qlWith({ memberships: MEMBER_ROWS }), tenancy: 'unregistered' }); const a = await drive(mount(serverWith(viaKernelManager(failed))), { 'x-api-key': RAW_EXMEMBER_KEY }); const b = await drive(mount(serverWith(viaKernelManager(absent))), { 'x-api-key': RAW_EXMEMBER_KEY }); - // The `unregistered` half is the SUPPORTED single-tenant shape (no wall - // exists, an org-stamped key still working is by design). The `failed` - // half rides the same answer. That equality is the card's subject. - expect(a).toEqual(b); - expect(a.status).toBe(200); + expect(a).not.toEqual(b); + // Registered and FAILED ⇒ the outage it is. + expect(a.status).toBe(503); + // ⭐ THE OTHER HALF, and the one that keeps this repair honest: the + // `unregistered` leg is the SUPPORTED no-tenancy composition (no wall + // exists; an org-stamped key working there is by design) and it must be + // completely UNCHANGED. A repair that made this 503 too would have broken + // every single-tenant embedder — that is why the brand, not the catch, is + // the discriminator. + expect(b.status).toBe(200); + expect(b.body?.success).toBe(true); }); - it('SIBLING REFUSAL, same gate: the org-less-key refusal under `isolated` is also skipped when the probe fails', async () => { + it('SIBLING REFUSAL, same gate [decision 1 A]: the org-less-key refusal under `isolated` is no longer skipped when the probe FAILS', async () => { // `resolveApiKeyAdmission` refuses an org-less key under a walled, // non-union posture (`organization_required`) — also only when the // posture is PRESENT. Note api-key.ts DOCUMENTS absent-posture-admit as a @@ -459,9 +536,15 @@ describe('[#13906] §2 — the Layer 0 ex-member refusal, and what a failed post const refused = await drive(mount(serverWith(viaKernelManager(healthy))), { 'x-api-key': RAW_ORGLESS_KEY }); expect(refused.status).toBe(ANONYMOUS_DENY_STATUS); + // SUPERSEDED PIN, quoted: expect(admitted.status).toBe(200); + // The org-less key was ADMITTED when the posture probe failed. api-key.ts + // documents absent-posture-admit as deliberate for THIS refusal — and that + // reasoning still holds for a tenancy service that is genuinely ABSENT. + // What it never meant to cover is a service that FAILED, which is the only + // leg this repair moves. const failing = kernelWith({ ql: qlWith({ memberships: MEMBER_ROWS }), tenancy: 'factory-throws' }); - const admitted = await drive(mount(serverWith(viaKernelManager(failing))), { 'x-api-key': RAW_ORGLESS_KEY }); - expect(admitted.status).toBe(200); + const nowLoud = await drive(mount(serverWith(viaKernelManager(failing))), { 'x-api-key': RAW_ORGLESS_KEY }); + expect(nowLoud.status).toBe(503); }); }); @@ -570,7 +653,7 @@ describe('[#13906] §4 — auth gate: failed probe vs inactive gate', () => { .toEqual({ authGate: inactive.ctx?.authGate, blocked: inactive.blocked, wire: inactive.state }); }); - it('⚠️ MEASURED: a FAILED session re-read under an ACTIVE gate is indistinguishable from the inactive gate — the gated user is NOT blocked', async () => { + it('REPAIRED [decision 2 B]: a FAILED session re-read under an ACTIVE gate is now a 503 outage, not a silent admission', async () => { // The transient class: identity resolution succeeds (first read), the // gate's re-read fails (second read) — a session-backend fault between // the two reads of one request. @@ -585,21 +668,36 @@ describe('[#13906] §4 — auth gate: failed probe vs inactive gate', () => { }, }, }; - const inactive = await driveGate(gateWiring({ - isAuthGateActive: () => false, - api: { getSession: async () => ({ user: GATED_USER }) }, + // SUPERSEDED PINS, quoted — the measured fail-OPEN this card was filed for: + // expect(rereadFails.ctx?.authGate).toBeUndefined(); + // expect(rereadFails.blocked).toBe(false); + // expect({ authGate, blocked, wire }).toEqual({ …inactive… }); + // A gate the deployment DECLARED active vanished silently, deep-equal to + // gate-off, and the gated user was admitted with no trace on the wire. + await expect(driveGate(gateWiring(auth))).rejects.toMatchObject({ + code: 'SERVICE_UNAVAILABLE', + status: 503, + object: 'auth_gate', + }); + // The gate DID probe — the counter is what proves the re-read was reached + // and is the anti-vacuity control for this leg: without it a repair that + // never probed at all would also "pass". + expect(reads).toBeGreaterThan(1); + }); + + it('NARROWNESS CONTROL [decision 2 B]: a SUCCESSFUL re-read carrying no gate still admits — only the FAILED re-read is loud', async () => { + // The window the ruling named has two conditions, and this is the leg that + // proves the second one is really required rather than approximated. An + // ACTIVE gate whose re-read SUCCEEDS but returns a user with no gate is + // not a failure — that user is simply not gated — and must still be served. + // ⛔ A repair keyed on "active gate + no authGate value" instead of + // "active gate + the re-read THREW" would refuse this caller. It does not. + const r = await driveGate(gateWiring({ + isAuthGateActive: () => true, + api: { getSession: async () => ({ user: { id: 'u_ungated' } }) }, })); - const rereadFails = await driveGate(gateWiring(auth)); - expect(reads).toBeGreaterThan(1); // the gate DID probe — and its failure vanished - expect(rereadFails.ctx?.userId).toBe('u_gated'); - expect(rereadFails.ctx?.authGate).toBeUndefined(); - expect(rereadFails.blocked).toBe(false); - expect({ authGate: rereadFails.ctx?.authGate, blocked: rereadFails.blocked, wire: rereadFails.state }) - .toEqual({ authGate: inactive.ctx?.authGate, blocked: inactive.blocked, wire: inactive.state }); - // This user's SESSION says the policy gate applies (user.authGate is set, - // the gate is ACTIVE) — the block was lost to the re-read failure, and - // the wire carries no trace. The code comment names the design - // best-effort; whether that stands is a ruling, recorded on #13906, and - // deliberately not taken here. + expect(r.ctx?.userId).toBe('u_ungated'); + expect(r.ctx?.authGate).toBeUndefined(); + expect(r.blocked).toBe(false); }); }); diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index 08cdde8c77..3381a87e8e 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -10,6 +10,10 @@ import { // takes the same loud answer rather than the quiet 403 it used to wear. AuthzStoreUnavailableError, effectiveTenancyPosture, + // [#13906] The REGISTRY's own "never registered" brand — the discriminator + // that lets the tenancy seam absorb the supported no-tenancy composition + // while every other rejection stays loud. Never message text (#13905). + isServiceNotRegisteredError, assembleExecutionContext, normalizeAuthGate, type AuthGate, shouldDenyAnonymous, ANONYMOUS_DENY_BODY, ANONYMOUS_DENY_STATUS, // [#7678] ADR-0090 D5/D9 suggested-binding `?status=` vocabulary — the one @@ -2346,13 +2350,70 @@ export class RestServer { }; // [#8287] The EFFECTIVE tenancy posture, from the kernel's `tenancy` // service — the same source plugin-security reconciles for the Layer 0 - // wall, so API-key admission and the wall agree. Absent ⇒ undefined ⇒ - // no posture-conditional refusal (behaviour unchanged). + // wall, so API-key admission and the wall agree. + // + // [#13906 — maintainer ruling 2026-09-02, decision 1 option A] The + // facts this seam used to answer with one `undefined` are now kept + // apart. Measured on a real `ObjectKernel` with a healthy `isolated` + // tenancy and an EX-MEMBER's org-stamped API key, the wiring + // differing ONLY in the tenancy service's health: + // + // | `tenancy` service | before | after | + // |:--|:--|:--| + // | healthy, wall-enforcing | 401 refused | 401 — unchanged | + // | never registered (supported) | 200 served | 200 — unchanged | + // | registered and FAILED to build | **200 served** | **503** | + // + // ⇒ the direction here was PERMISSIVE, and that is what makes this + // card's family different from its siblings: a tenancy service that + // could not be CONSTRUCTED skipped the Layer 0 + // `organization_membership_ended` refusal (and its + // `organization_required` sibling), so a FAILURE read as "this check + // does not apply". #13476 and #13904 answered an unknown with a + // REFUSAL; this one answered it with ADMISSION. + // + // The classification is the REGISTRY's, never message text — the + // same discriminator the shipped `objectQLProvider` already uses one + // layer down (`isServiceNotRegisteredError`, #13905): "never + // registered" is branded and stays quiet; every other rejection (a + // factory that threw, a scoped registration resolved without a scope + // id, a circular service dependency) is unbranded, and the set is + // closed with a LOUD default. + // + // ⚠️ The WIRING fact is taken from `kernel`'s PRESENCE, asked here + // once, and never inferred from what the read returned — the #13476 + // discipline. Without that guard the single-kernel provider path + // (where `kernel` is `undefined`) would raise a `TypeError` from the + // dereference and every embedder on that wiring would take the loud + // answer. That path carries no posture at all; its half of this + // ruling (decision 1 option B′) is refused at BOOT instead — see + // `rest-api-plugin.ts`. + // + // ⚠️ The ASYNC ACCESSOR's presence is part of the wiring fact, for + // the same reason the shipped `objectQLProvider` splits on it: a + // `KernelBase`-shaped host (`LiteKernel`) has no `getServiceAsync` + // at all, so dereferencing it would raise a `TypeError` — unbranded, + // and therefore LOUD — turning "this host shape has no async + // registry" into an outage. Such a host also has no service + // factories (`registerServiceFactory` throws "not supported"), so + // absence is the only fault it could report anyway. It keeps the + // previous quiet answer, unchanged. let tenancyPosture; - try { - tenancyPosture = effectiveTenancyPosture(await kernel.getServiceAsync('tenancy') as any); - } catch { - tenancyPosture = undefined; + if (kernel && typeof kernel.getServiceAsync === 'function') { + try { + tenancyPosture = effectiveTenancyPosture(await kernel.getServiceAsync('tenancy') as any); + } catch (err) { + // Registered and unable to answer. The posture is an + // authorization INPUT, so admission was never decided — the + // same loud answer `wiredEngineOrLoud` gives the engine seam, + // carried to the door by the same nets. + if (!isServiceNotRegisteredError(err)) { + throw new AuthzStoreUnavailableError('tenancy', err); + } + // Never registered ⇒ the supported no-tenancy composition: + // quiet `undefined`, no posture-conditional refusal. + tenancyPosture = undefined; + } } const authz = await resolveAuthzContext({ ql, headers, getSession, tenancyPosture }); // [#6216] The anonymous contract IS the shared assembler's default @@ -2383,13 +2444,51 @@ export class RestServer { // (`{ code, message }`), so this is where the declaration is met — // a gate with a blank message no longer rides into a 403 body as // `undefined`. + // + // [#13906 — maintainer ruling 2026-09-02, decision 2 option B] The + // gate is best-effort NO LONGER in one precisely measured window: + // `isAuthGateActive()` answered `true` AND the gate's session + // re-read then FAILED. Measured before the repair, same fixture, + // the wiring differing only in how the gate faulted: + // + // | gate wiring | before | after | + // |:--|:--|:--| + // | INACTIVE (the common, correct case)| admitted | admitted — unchanged | + // | ACTIVE, healthy re-read, gated user| 403 code+message | 403 — unchanged | + // | `isAuthGateActive()` THROWS | admitted | admitted — unchanged | + // | ACTIVE, re-read FAILS | **admitted** | **503** | + // + // ⇒ an enforcement the deployment DECLARED active used to vanish + // with no wire trace, deep-equal to gate-off. A declared promise + // that disappears silently is the fail-OPEN this card measured. + // + // ⛔ The probe-throws row stays absorbed DELIBERATELY, and it is the + // narrowness the ruling asked for: a host whose probe faults never + // answered `true`, so it never declared a gate, and refusing on it + // would block deployments that never asked for one. let authGate: AuthGate | undefined; + let gateActive = false; try { - if (typeof authService.isAuthGateActive === 'function' && authService.isAuthGateActive()) { - const gatedSession: any = await getSession(headers).catch(() => undefined); - authGate = normalizeAuthGate(gatedSession?.user) ?? undefined; + gateActive = typeof authService.isAuthGateActive === 'function' + && authService.isAuthGateActive() === true; + } catch { + gateActive = false; + } + if (gateActive) { + let gatedSession: any; + try { + // ⛔ NOT the `getSession` closure above, and not its + // `.catch(() => undefined)`: both convert a THROW into the + // same `undefined` a gate-less user produces, which is + // precisely the collapse being repaired. A session that + // RESOLVES carrying no gate is not a failure — that user is + // simply not gated, and still admits. + gatedSession = await api.getSession({ headers }); + } catch (err) { + throw new AuthzStoreUnavailableError('auth_gate', err); } - } catch { /* gate is best-effort — never break context resolution */ } + authGate = normalizeAuthGate(gatedSession?.user) ?? undefined; + } // [#6216 — maintainer ruling 2026-08-08, Option A] The assembly of // the ExecutionContext itself is now the SINGLE shared one diff --git a/packages/rest/src/ui-view-environment-ownership.test.ts b/packages/rest/src/ui-view-environment-ownership.test.ts index 79eaa58c75..db8e905f0a 100644 --- a/packages/rest/src/ui-view-environment-ownership.test.ts +++ b/packages/rest/src/ui-view-environment-ownership.test.ts @@ -116,9 +116,28 @@ function kernelManagerFor(spec: Record) { } if (name === 'objectql') return emptyQl() as T; if (name === 'protocol') return protocolFor(entry?.schema ?? null) as T; - // `i18n`, `tenancy`, `settings`, … — absent, which every - // caller in `computeExecCtx` treats as best-effort. - throw new Error(`no ${name} service`); + // `i18n`, `tenancy`, `settings`, … — ABSENT from this + // environment's kernel. + // + // [#13906] Absence is spelled as a RESOLVED `undefined`, + // ⛔ no longer as a bare `Error`. The seam contract names + // that spelling itself (`wiredEngineOrLoud`: "a provider + // that RESOLVES `undefined` still means no engine, quietly + // — that is the seam contract declaring absence, not + // failing"), and the double now says the fact it always + // MEANT rather than one the registry never produces for + // absence: a real `getServiceAsync` rejects for an + // unregistered name with a BRANDED rejection (#13905), and + // reserves the bare, unbranded rejection for a service that + // IS registered and FAILED TO CONSTRUCT. + // + // Under the old collapse the difference was invisible, so + // the inaccuracy was free. It is not free now: the tenancy + // seam classifies an unbranded rejection as the outage it + // is, and this double was claiming every absent service had + // broken. ⛔ Do not "restore" the throw — that reintroduces + // a fake reporting a fault it does not have. + return undefined as T; }, } as any; },