Skip to content
Merged
130 changes: 130 additions & 0 deletions .changeset/authz-read-failure-fails-loud.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
---
"@objectstack/core": minor
"@objectstack/types": minor
"@objectstack/metadata": patch
"@objectstack/rest": patch
"@objectstack/service-datasource": patch
"@objectstack/service-settings": patch
"@objectstack/service-storage": patch
"@objectstack/plugin-sharing": patch
"@objectstack/cloud-connection": patch
---

fix(core,rest,services)!: a permission-store read failure now fails LOUD instead of resolving as an authenticated caller holding zero capabilities (#13279)

**BREAKING** runtime behaviour change on the shared authorization resolver,
shipped as `minor` under the repo's launch-window convention.

`resolveAuthzContext`'s per-read helper `tryFind` answered a THROWN read exactly
the way it answered an EMPTY one: `[]`. So an outage of the permission store
resolved as a well-formed context for an authenticated principal holding no
capabilities, and the package-management door answered
`403 FORBIDDEN` — "Reading packages requires the `studio.access` or
`setup.access` capability." That answer was measured byte-identical
(`JSON.stringify` equal, against a control that separates two answers which do
differ) to what a caller who genuinely holds nothing receives. An administrator
was told they lack a capability, during an outage of the store that holds the
capability.

Maintainer ruling 2026-08-30, verbatim 「第一批其余同意」: `tryFind` 区分「无行」
与「读失败」,读失败 fail-loud —— 权限库不可达时不再解析为「已认证零能力」,而是
响亮拒绝(与真实能力拒绝的 403 可区分)。

Second maintainer ruling the same day (第 5 场总监席决裁批 #9, verbatim 「同意」),
after implementing the first one showed that "the read failed" is two facts:
采**选项 A** —— 把 `isMissingTableError` 从 `@objectstack/metadata` 迁至
`@objectstack/types`(core 已依赖),metadata 保留 re-export 兼容;`tryFind` 仅对
**未被判定为「表未 provision」**的读失败抛 `AuthzStoreUnavailableError`。

**What changed.** A permission-store read that is issued and throws now raises
`AuthzStoreUnavailableError`, which carries the EXISTING ADR-0112 wire code
`SERVICE_UNAVAILABLE` and status `503`. No code is added to the closed wire
vocabulary and no response envelope gains or loses a key — only which declared
code an outage selects. Doors that map thrown errors through
`resolveThrownHttpError` answer 503 with no per-door change.

**What did NOT change**, and is pinned:

- A reachable, genuinely EMPTY store (reads return no rows) still resolves to
zero capabilities.
- A genuine capability denial still answers `403 FORBIDDEN` with its message.
- An ABSENT engine (`ql` unwired, so no read is ever issued) still resolves to
an empty-but-valid envelope.
- Anonymous requests never reach the store, so an outage cannot make them loud.
- A REAL engine whose `sys_*` tables were never provisioned resolves to zero
capabilities, quietly — pinned to be byte-identical to the empty-store
envelope, in every dialect spelling and in the production wrapper shape where
the driver's phrase is on `cause` rather than the outer message.

**The boundary between the two kinds of read failure.** An earlier revision of
this changeset claimed "embedders without a data plane are unaffected". That
claim was too broad; it is retracted here, and the gap it named is now closed
rather than merely disclosed. A read also throws when the table was never
PROVISIONED — a real engine, wired and reachable, whose `sys_*` tables were
never created — and that is a supported deployment shape, not an outage. There
"zero capabilities" is the TRUE answer rather than a fabrication: nothing is
provisioned, so nothing was withheld. Only an UNREACHABLE store — the ruling's
own word 不可达 — leaves the capability set unknown, and only an unknown answer
may not be reported as a denial.

Treating the two alike was measured, not theorised: it turned four CI suites
red, all from `no such table` on `sys_user` / `sys_member` /
`sys_user_position` / `sys_user_permission_set`. Ordinary CRUD in
`@objectstack/client` answered `503`; batch validation errors that owe `400`
answered `503`, because authorization refused before validation ran; runtime
notifications answered `401` where authenticated callers must be served `200`;
and two `.integration.test.ts` noise guards reported that the driver and engine
diagnostics for `sys_position` stopped being emitted — the eager throw aborted
the resolution before that later read was ever issued, so a change made to stop
a failed read being silent had made two other channels silent.

`tryFind` therefore raises `AuthzStoreUnavailableError` only for a read failure
that is NOT positively identified as an unprovisioned table.

**`isMissingTableError` moved to `@objectstack/types`.** The classifier that
draws that boundary already existed and was already right — driver-code based
rather than prose-sniffing, documented so that "cannot say" never means "be
loud". It lived in `@objectstack/metadata`, which DEPENDS ON `@objectstack/core`,
so the resolver could not import it. Rather than keep a second copy of a
security-relevant predicate, the ruling relocated the one classifier to
`@objectstack/types` — the package core already depends on, and the repo's own
stated Home rule for a cross-package error predicate ("every consumer of the
question already depends on it, so adopting the predicate never adds an edge",
`packages/types/src/unique-violation.ts`). `@objectstack/metadata/errors` still
exports `isMissingTableError`, re-exported from the new home, so no consumer of
that published subpath changes.

Its sibling `isSchemaAlreadyExistsError` moved with it — the two are not two
modules but two signatures over one matcher, and separating them would have
meant re-rolling the matcher, which is the duplication the module exists to
prevent. Both are now exported from `@objectstack/types`; the metadata subpath
deliberately still publishes only `isMissingTableError`, which is the only one
anything imports through it.

⚠️ **Signed-off risk, recorded because it is load-bearing.** Gating loudness on
a driver-error predicate was approved with its false-positive direction stated:
mis-reading a genuine outage as "table not provisioned" silently restores the
quiet 403 this change removes, with no thrown error and no other failing test.
That direction is accepted, not overlooked — the predicate keys on driver codes,
SQLSTATEs and errnos first, excludes the known superstring traps up front, and
returns `false` for anything it does not positively recognise, so an
unrecognised outage stays loud by default. The risk is written beside the
predicate in `resolve-authz-context.ts` and both directions are pinned by name
in `authz-store-unavailable.test.ts`. ⛔ Do not widen `isMissingTableError` to
make a first boot quieter: every widening moves outages into the quiet branch.

**All-transport, not just REST.** Every transport authorizing through
`resolveAuthzContext` inherits this. Six of the eight production transports
wrapped the call in a fail-closed `catch` that would have re-silenced the
outage — measured, not assumed: with the resolver loud but the nets untouched,
the package door answered `401`, i.e. the outage merely changed disguises. Those
`catch` blocks now re-raise via `isAuthzStoreUnavailableError` and keep their
previous behaviour for every other fault. The transport set is rebuilt from
source and audited for set equality on every test run, so a transport added
later cannot inherit the old silence unnoticed.

Callers that treat any throw from `resolveAuthzContext` as "anonymous" should
re-raise `isAuthzStoreUnavailableError(err)` instead: degrading it restores the
disguise this removes.

<!-- adr-0087: not-required (runtime-interface-only packages/core/src/security/resolve-authz-context.ts#ResolvedAuthzContext, packages/core/src/security/authz-store-unavailable.ts#AuthzStoreUnavailableError) The breaking surface is runtime TypeScript in `@objectstack/core`'s security module and nothing else: `resolveAuthzContext` stops always-resolving and raises `AuthzStoreUnavailableError` when a permission-store read is issued and throws. NO metadata surface is touched in either direction. No Zod schema changes, no `packages/spec` declaration is added or removed, no authorable key moves, no stored row shape changes, and no object definition is edited — a customer's metadata app is byte-for-byte unaffected, so `objectstack migrate meta` has nothing to visit and there is no tombstone to mint. The wire vocabulary is likewise untouched: `SERVICE_UNAVAILABLE` is an EXISTING `StandardErrorCode` member that `HttpStatusErrorCodeMap` already maps to 503, so this change only selects a different DECLARED code for an outage rather than adding one. Both named symbols resolve at HEAD as exported declarations whose files are not `*.zod.ts`, are not under `packages/spec/src/contracts/`, are not object definitions and are not `z.input` projections; neither is referenced in code by any metadata surface (the `packages/spec` hits for `resolveAuthzContext` are comment prose describing the envelope, which this gate masks). The channel that reaches an affected consumer is therefore code review and this changeset, never the upgrade guide: a ledger entry could not express "your fail-closed catch should re-raise this error", because there is no metadata for a migration to rewrite. -->
14 changes: 7 additions & 7 deletions content/docs/permissions/system-context.mdx
Original file line numberDiff line numberDiff 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:1235`, `:1264`), and neither
cannot set it (`packages/rest/src/rest-server.ts:1238`, `:1267`), 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 DownExpand 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:1267` |
| 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:1270` |

### 2. Write pipeline and data integrity

Expand DownExpand Up@@ -134,7 +134,7 @@ The largest single consumer — **20 of the 109 sites**.
| 33 | `grant()` skips the enforcement + manage-shares assertions | Get: the rule evaluator can materialise through the public API. Note it is **not** a bare skip: the system branch asserts the grant is not *inert* instead (a grant on an object no verdict can consult is refused) | `plugin-sharing/src/sharing-service.ts:1179` |
| 34 | `revoke()` deletes directly, **before** the non-manual-source guard | Get: the evaluator can revoke its own grants. Lose: the `CONFLICT` guard that warns a rule-materialised grant will be silently re-granted on the next reconcile | `plugin-sharing/src/sharing-service.ts:1257` (guard at `:1282`) |
| 35 | `listShares()` skips the management gate | Get: full enumeration of who can see a record | `plugin-sharing/src/sharing-service.ts:1309` |
| 36 | `sys_record_share` reads are **not** self-scoped | Get: tenant-wide share listing without `manage_sharing` | `sharing-plugin.ts:1073` |
| 36 | `sys_record_share` reads are **not** self-scoped | Get: tenant-wide share listing without `manage_sharing` | `sharing-plugin.ts:1077` |
| 37 | Share-link policy `enabled` check bypassed; system callers re-enter under a system context | Get: link creation/resolution while the policy is off | `plugin-sharing/src/share-link-service.ts:413`, `:467`, `:471`, `:544`, `:574` |
| 38 | Sharing-rule provenance stamp skipped | Lose: the row is not marked as an admin customization — seeder / `defineRule` / boot reconcilers are "the package door" | `sharing-rule-provenance.ts:47` |
| 39 | Sharing-rule service write + delete paths return early | Lose: the manage-rules gate on the service surface, and the platform-global-rule delete guard | `sharing-rule-service.ts:157`, `:382` |
Expand All@@ -158,11 +158,11 @@ The largest single consumer — **20 of the 109 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:136` |
| 49 | Action `requiredPermissions` bypassed | runtime | Get: engine self-invocation runs any action | `action-execution.ts:399` |
| 50 | `manage_metadata` bypassed on metadata writes | runtime, rest | Get: schema writes without the capability | `domains/meta.ts:471`, `:874`, `rest-server.ts:4271`, `:5634`, `:5866`, `:6211`, `:6404` |
| 50 | `manage_metadata` bypassed on metadata writes | runtime, rest | Get: schema writes without the capability | `domains/meta.ts:471`, `:874`, `rest-server.ts:4282`, `:5645`, `:5877`, `:6222`, `:6415` |
| 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:246`, `external-datasource-routes.ts:302`, `package-routes.ts:92` |
| 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:246`, `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` |
| 54 | Package REST route capability gate bypassed | rest | Get: package read/write over REST without `manage_metadata` / `studio.access` / `setup.access` | `package-routes.ts:97` |
| 54 | Package REST route capability gate bypassed | rest | Get: package read/write over REST without `manage_metadata` / `studio.access` / `setup.access` | `package-routes.ts:102` |
| 55 | Package domain capability gates bypassed | runtime | Get: package management and package-inventory reads without the capability | `domains/packages.ts:95`, `:128` |
| 56 | Activation write / authoring refusals do not fire | runtime | Get: activation artifacts writable and authorable without the activation-authoring capability | `activation-gate.ts:139`, `:190` |
| 57 | Automation run-state read, flow-authoring write and unrelated-screen read all pass | runtime | Get: run state, flow writes and screen reads with no grant | `domains/automation.ts:254`, `:545`, `:635` |
Expand DownExpand 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:1451` (#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:273` |
| "A client can request it" | **No.** Never settable from inbound HTTP or from an action body | `rest-server.ts:1235`, `:1264`; `domains/actions.ts:404` |
| "A client can request it" | **No.** Never settable from inbound HTTP or from an action body | `rest-server.ts:1238`, `:1267`; `domains/actions.ts:404` |

---

Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -46,7 +46,7 @@
*/

import type { Plugin, PluginContext } from '@objectstack/core';
import { resolveAuthzContext } from '@objectstack/core';
import { resolveAuthzContext, isAuthzStoreUnavailableError } from '@objectstack/core';
import {
resolveTenancyPosture,
collectGlobalUniques,
Expand DownExpand Up@@ -1659,7 +1659,10 @@ export class MarketplaceInstallLocalPlugin implements Plugin {
userId: String(authz.userId),
systemPermissions: Array.isArray(authz.systemPermissions) ? authz.systemPermissions : [],
};
} catch {
} catch (err) {
// [#13279] `null` here means "nobody is authenticated", which is
// not what a permission-store outage established. Re-raised.
if (isAuthzStoreUnavailableError(err)) throw err;
return null;
}
};
Expand Down
Loading
Loading