Skip to content
40 changes: 40 additions & 0 deletions .changeset/execctx-authz-input-seams-fail-closed.md
Original file line number Diff line number Diff line change
@@ -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.
8 changes: 4 additions & 4 deletions content/docs/permissions/system-context.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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` |
Expand Down Expand Up @@ -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` |

---

Expand Down
Loading
Loading