Skip to content

Commit 919beca

Browse files
Trumpclaudehotlong
authored
fix(rest): keep "failed" and "not wired" apart at the two computeExecCtx authorization-input seams (#15020)
* fix(rest): keep failed and unwired apart at the two computeExecCtx authz-input seams Phase 2 of the #13906 measurement, implementing the maintainer ruling of 2026-09-02 (decision 1 = A + B', decision 2 = B). Both seams measured fail-OPEN: an absorbed FAILURE read as "this check does not apply", so an authorization refusal was skipped rather than produced. - tenancy posture (A): absorb only the registry's branded not-registered rejection; any other rejection raises AuthzStoreUnavailableError, the same loud answer wiredEngineOrLoud gives the engine seam. The wiring fact comes from `kernel`'s presence, never inferred from the returned value. - single-kernel wiring (B'): a configured wall-enforcing posture is refused loudly at boot, because that wiring never reads a posture at all. - ADR-0069 auth gate (B): fail closed in the measured window only — isAuthGateActive() answered true AND the session re-read then failed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza * test(rest): re-aim the #13906 pins onto the ruled behaviour, and drive B' The six phase-1 pins that recorded the PERMISSIVE answers go red by design under the repair; each is inverted IN PLACE with its superseded text quoted beside it, per the file's own standing instruction. Adds rest-api-plugin-tenancy-posture-boot-refusal.test.ts, which drives the ruling's own opening question for B' (can a walled posture be configured on the single-kernel wiring at all? yes) plus the refusal and four narrowness controls, and a narrowness control for decision 2 B. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza * fix(rest): guard the tenancy seam on the async accessor, and triage one double Two follow-ons from driving the repair against the whole @objectstack/rest suite: - LiteKernel/KernelBase-shaped hosts have no `getServiceAsync`, so the bare dereference would raise an unbranded TypeError and turn "this host shape has no async registry" into a 503. The wiring fact now includes the accessor's presence, mirroring the shipped objectQLProvider's split. - ui-view-environment-ownership.test.ts's kernel double spelled ABSENT services as a bare Error. The real registry brands the never-registered rejection and reserves the unbranded one for a service that failed to construct, so the double was claiming every absent service had broken. It now resolves undefined, the spelling the seam contract itself names for absence. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza * chore(rest): keep tracker ids out of runtime strings, re-anchor the census check:doc-authoring refuses `#NNNN` inside runtime prose (maintainer ruling 2026-08-12) — an operator reading a boot failure cannot resolve one. The ids move to adjacent source comments and the boot message asserts its REMEDY instead. check-system-context-census --fix re-anchors ten line citations in content/docs/permissions/system-context.mdx that this PR's insertions shifted. Pure line rot; no elevation behaviour changed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza * chore(docs): re-anchor the system-context census after merging main The merge deferred the generated `content/docs/permissions/system-context.mdx` to the merge driver (AGENTS.md §11); this commit discharges it by regenerating from the merged tree with the gate's own `--fix`. Line re-anchoring only — 20 anchors re-pointed, no elevation behaviour touched. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza * fix(rest): withdraw the single-kernel boot refusal (B′) Decision 1 narrows to A alone, per the maintainer ruling of 2026-09-04 recorded on the card; the measurement B′ was asking for moves to #15163. `RestApiPlugin` no longer refuses to start when a wall-enforcing tenancy posture is configured on a deployment with no `kernel-manager` service. `packages/rest/src/rest-api-plugin.ts` is now byte-identical to `main`. Why the refusal goes, from the CI triage on this branch: the only registrar of a `kernel-manager` service in this repository was B′'s own narrowness control test, so the refusal fired on every real walled composition the open core can build — the `os serve` process under an `isolated` posture, the ADR-0105 `bootStack multiTenant` harness, and seven dogfood suites — for `group` as well as `isolated`. Its premise was also false wherever 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), so the Layer 0 row wall the message claimed was unenforced was in fact standing in every case the refusal could reach. Decision 1 A (a registered-but-failed `tenancy` service answers 503 in `computeExecCtx`) and decision 2 B (an active ADR-0069 auth gate whose session re-read fails answers 503) are untouched — both stand exactly as ruled on 2026-09-02. - delete the boot-refusal block and its two now-unused imports (`effectiveTenancyPosture`, `postureEnforcesWall`); `isServiceNotRegisteredError` stays, used by the pre-existing objectql provider one layer down - delete `rest-api-plugin-tenancy-posture-boot-refusal.test.ts` — every one of its 2 refusal tests and 4 narrowness controls measures a behaviour that no longer exists - re-aim the phase-1 pin file's header: the ruling it records is now A alone, the dangling pointer to the deleted file is replaced by the withdrawal note, and §3's "the provider wiring still measures 200" reading is restated as CORRECT and PINNED — it is #15163's subject, not a regression introduced here - drop the B′ paragraphs from the changeset; the 503 rows stay ⛔ Not done, deliberately: B′ is not replaced by a warning, a softer refusal, an env escape hatch, or a narrowed condition. The ruling moved the question to a measurement card; the code now says nothing about single-kernel posture at boot. Part of #13906 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: Jack Zhuang <50353452+hotlong@users.noreply.github.com>
1 parent 476c373 commit 919beca

5 files changed

Lines changed: 322 additions & 66 deletions

File tree

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
---
2+
'@objectstack/rest': minor
3+
---
4+
5+
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
6+
7+
Two seams inside `RestServer.computeExecCtx` absorbed a FAILURE into the same `undefined` an
8+
ABSENT wiring produces, and both feed authorization inputs. Unlike the sibling repairs in this
9+
family — where an unknown was answered as a REFUSAL — these two pointed the other way: a failure
10+
read as *permissive*, so a refusal was SKIPPED rather than produced. Driven on a real
11+
`ObjectKernel`, each fault beside a positive control that is the same fixture with the one fault
12+
removed:
13+
14+
| wiring | before | after |
15+
|:--|:--|:--|
16+
| healthy `isolated` tenancy, ex-member's org-stamped API key | 401 refused | 401 — unchanged |
17+
| `tenancy` never registered (supported no-tenancy composition) | 200 served | 200 — unchanged |
18+
| `tenancy` registered and FAILED to construct | **200 served, full grants** | **503** |
19+
| auth gate INACTIVE | admitted | admitted — unchanged |
20+
| auth gate ACTIVE, healthy re-read, gated user | 403 | 403 — unchanged |
21+
| `isAuthGateActive()` itself THROWS | admitted | admitted — unchanged |
22+
| auth gate ACTIVE, session re-read FAILS | **admitted, no wire trace** | **503** |
23+
24+
- **Tenancy posture.** Only the service registry's *branded* "never registered" rejection is
25+
absorbed — the `isServiceNotRegisteredError` discriminator the shipped `objectQLProvider`
26+
already uses one layer down. Every other rejection (a factory that threw, a scoped registration
27+
resolved without a scope id, a circular service dependency) raises the same loud
28+
`AuthzStoreUnavailableError` the data-engine seam raises, so the door answers a server-side
29+
outage instead of serving the request. The classification is the registry's, never message text.
30+
The WIRING fact is taken from the kernel's presence and never inferred from what the read
31+
returned.
32+
- **ADR-0069 auth gate.** Fails closed in one precisely measured window only: `isAuthGateActive()`
33+
answered `true` **and** the gate's session re-read then failed. A gate the deployment declared
34+
active no longer vanishes silently. The common inactive path, a probe that throws, and a
35+
successful re-read carrying no gate all keep their existing behaviour.
36+
37+
Boot behaviour is unchanged: no composition that starts today stops starting. Single-kernel REST
38+
deployments — the wiring with no `kernel-manager` service — keep their current behaviour exactly,
39+
including the fact that `computeExecCtx` reads no tenancy posture there. What that wiring actually
40+
skips is being measured separately and is not changed here.

‎content/docs/permissions/system-context.mdx‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ not on any flag.
6464
## How the flag is set
6565

6666
`isSystem` is **server-constructed and never client-supplied**. Inbound HTTP
67-
cannot set it (`packages/rest/src/rest-server.ts:1520`, `:1549`), and neither
67+
cannot set it (`packages/rest/src/rest-server.ts:1524`, `:1553`), and neither
6868
can an action body (`packages/runtime/src/domains/actions.ts:404`). It is
6969
written by internal callers only, as an option on the engine call:
7070

@@ -103,7 +103,7 @@ that silently does not happen.
103103
| 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` |
104104
| 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` |
105105
| 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` |
106-
| 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` |
106+
| 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` |
107107

108108
### 2. Write pipeline and data integrity
109109

@@ -158,7 +158,7 @@ The largest single consumer — **17 of the 106 sites**.
158158
|:--|:---|:---|:---|:---|
159159
| 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` |
160160
| 49 | Action `requiredPermissions` bypassed | runtime | Get: engine self-invocation runs any action | `action-execution.ts:401` |
161-
| 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` |
161+
| 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` |
162162
| 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` |
163163
| 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` |
164164
| 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.
199199
| "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:1580` (#3493 / #6640) |
200200
| "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` |
201201
| "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` |
202-
| "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` |
202+
| "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` |
203203

204204
---
205205

0 commit comments

Comments
 (0)