|
| 1 | +--- |
| 2 | +"@objectstack/core": minor |
| 3 | +"@objectstack/types": minor |
| 4 | +"@objectstack/metadata": patch |
| 5 | +"@objectstack/rest": patch |
| 6 | +"@objectstack/service-datasource": patch |
| 7 | +"@objectstack/service-settings": patch |
| 8 | +"@objectstack/service-storage": patch |
| 9 | +"@objectstack/plugin-sharing": patch |
| 10 | +"@objectstack/cloud-connection": patch |
| 11 | +--- |
| 12 | + |
| 13 | +fix(core,rest,services)!: a permission-store read failure now fails LOUD instead of resolving as an authenticated caller holding zero capabilities (#13279) |
| 14 | + |
| 15 | +**BREAKING** runtime behaviour change on the shared authorization resolver, |
| 16 | +shipped as `minor` under the repo's launch-window convention. |
| 17 | + |
| 18 | +`resolveAuthzContext`'s per-read helper `tryFind` answered a THROWN read exactly |
| 19 | +the way it answered an EMPTY one: `[]`. So an outage of the permission store |
| 20 | +resolved as a well-formed context for an authenticated principal holding no |
| 21 | +capabilities, and the package-management door answered |
| 22 | +`403 FORBIDDEN` — "Reading packages requires the `studio.access` or |
| 23 | +`setup.access` capability." That answer was measured byte-identical |
| 24 | +(`JSON.stringify` equal, against a control that separates two answers which do |
| 25 | +differ) to what a caller who genuinely holds nothing receives. An administrator |
| 26 | +was told they lack a capability, during an outage of the store that holds the |
| 27 | +capability. |
| 28 | + |
| 29 | +Maintainer ruling 2026-08-30, verbatim 「第一批其余同意」: `tryFind` 区分「无行」 |
| 30 | +与「读失败」,读失败 fail-loud —— 权限库不可达时不再解析为「已认证零能力」,而是 |
| 31 | +响亮拒绝(与真实能力拒绝的 403 可区分)。 |
| 32 | + |
| 33 | +Second maintainer ruling the same day (第 5 场总监席决裁批 #9, verbatim 「同意」), |
| 34 | +after implementing the first one showed that "the read failed" is two facts: |
| 35 | +采**选项 A** —— 把 `isMissingTableError` 从 `@objectstack/metadata` 迁至 |
| 36 | +`@objectstack/types`(core 已依赖),metadata 保留 re-export 兼容;`tryFind` 仅对 |
| 37 | +**未被判定为「表未 provision」**的读失败抛 `AuthzStoreUnavailableError`。 |
| 38 | + |
| 39 | +**What changed.** A permission-store read that is issued and throws now raises |
| 40 | +`AuthzStoreUnavailableError`, which carries the EXISTING ADR-0112 wire code |
| 41 | +`SERVICE_UNAVAILABLE` and status `503`. No code is added to the closed wire |
| 42 | +vocabulary and no response envelope gains or loses a key — only which declared |
| 43 | +code an outage selects. Doors that map thrown errors through |
| 44 | +`resolveThrownHttpError` answer 503 with no per-door change. |
| 45 | + |
| 46 | +**What did NOT change**, and is pinned: |
| 47 | + |
| 48 | +- A reachable, genuinely EMPTY store (reads return no rows) still resolves to |
| 49 | + zero capabilities. |
| 50 | +- A genuine capability denial still answers `403 FORBIDDEN` with its message. |
| 51 | +- An ABSENT engine (`ql` unwired, so no read is ever issued) still resolves to |
| 52 | + an empty-but-valid envelope. |
| 53 | +- Anonymous requests never reach the store, so an outage cannot make them loud. |
| 54 | +- A REAL engine whose `sys_*` tables were never provisioned resolves to zero |
| 55 | + capabilities, quietly — pinned to be byte-identical to the empty-store |
| 56 | + envelope, in every dialect spelling and in the production wrapper shape where |
| 57 | + the driver's phrase is on `cause` rather than the outer message. |
| 58 | + |
| 59 | +**The boundary between the two kinds of read failure.** An earlier revision of |
| 60 | +this changeset claimed "embedders without a data plane are unaffected". That |
| 61 | +claim was too broad; it is retracted here, and the gap it named is now closed |
| 62 | +rather than merely disclosed. A read also throws when the table was never |
| 63 | +PROVISIONED — a real engine, wired and reachable, whose `sys_*` tables were |
| 64 | +never created — and that is a supported deployment shape, not an outage. There |
| 65 | +"zero capabilities" is the TRUE answer rather than a fabrication: nothing is |
| 66 | +provisioned, so nothing was withheld. Only an UNREACHABLE store — the ruling's |
| 67 | +own word 不可达 — leaves the capability set unknown, and only an unknown answer |
| 68 | +may not be reported as a denial. |
| 69 | + |
| 70 | +Treating the two alike was measured, not theorised: it turned four CI suites |
| 71 | +red, all from `no such table` on `sys_user` / `sys_member` / |
| 72 | +`sys_user_position` / `sys_user_permission_set`. Ordinary CRUD in |
| 73 | +`@objectstack/client` answered `503`; batch validation errors that owe `400` |
| 74 | +answered `503`, because authorization refused before validation ran; runtime |
| 75 | +notifications answered `401` where authenticated callers must be served `200`; |
| 76 | +and two `.integration.test.ts` noise guards reported that the driver and engine |
| 77 | +diagnostics for `sys_position` stopped being emitted — the eager throw aborted |
| 78 | +the resolution before that later read was ever issued, so a change made to stop |
| 79 | +a failed read being silent had made two other channels silent. |
| 80 | + |
| 81 | +`tryFind` therefore raises `AuthzStoreUnavailableError` only for a read failure |
| 82 | +that is NOT positively identified as an unprovisioned table. |
| 83 | + |
| 84 | +**`isMissingTableError` moved to `@objectstack/types`.** The classifier that |
| 85 | +draws that boundary already existed and was already right — driver-code based |
| 86 | +rather than prose-sniffing, documented so that "cannot say" never means "be |
| 87 | +loud". It lived in `@objectstack/metadata`, which DEPENDS ON `@objectstack/core`, |
| 88 | +so the resolver could not import it. Rather than keep a second copy of a |
| 89 | +security-relevant predicate, the ruling relocated the one classifier to |
| 90 | +`@objectstack/types` — the package core already depends on, and the repo's own |
| 91 | +stated Home rule for a cross-package error predicate ("every consumer of the |
| 92 | +question already depends on it, so adopting the predicate never adds an edge", |
| 93 | +`packages/types/src/unique-violation.ts`). `@objectstack/metadata/errors` still |
| 94 | +exports `isMissingTableError`, re-exported from the new home, so no consumer of |
| 95 | +that published subpath changes. |
| 96 | + |
| 97 | +Its sibling `isSchemaAlreadyExistsError` moved with it — the two are not two |
| 98 | +modules but two signatures over one matcher, and separating them would have |
| 99 | +meant re-rolling the matcher, which is the duplication the module exists to |
| 100 | +prevent. Both are now exported from `@objectstack/types`; the metadata subpath |
| 101 | +deliberately still publishes only `isMissingTableError`, which is the only one |
| 102 | +anything imports through it. |
| 103 | + |
| 104 | +⚠️ **Signed-off risk, recorded because it is load-bearing.** Gating loudness on |
| 105 | +a driver-error predicate was approved with its false-positive direction stated: |
| 106 | +mis-reading a genuine outage as "table not provisioned" silently restores the |
| 107 | +quiet 403 this change removes, with no thrown error and no other failing test. |
| 108 | +That direction is accepted, not overlooked — the predicate keys on driver codes, |
| 109 | +SQLSTATEs and errnos first, excludes the known superstring traps up front, and |
| 110 | +returns `false` for anything it does not positively recognise, so an |
| 111 | +unrecognised outage stays loud by default. The risk is written beside the |
| 112 | +predicate in `resolve-authz-context.ts` and both directions are pinned by name |
| 113 | +in `authz-store-unavailable.test.ts`. ⛔ Do not widen `isMissingTableError` to |
| 114 | +make a first boot quieter: every widening moves outages into the quiet branch. |
| 115 | + |
| 116 | +**All-transport, not just REST.** Every transport authorizing through |
| 117 | +`resolveAuthzContext` inherits this. Six of the eight production transports |
| 118 | +wrapped the call in a fail-closed `catch` that would have re-silenced the |
| 119 | +outage — measured, not assumed: with the resolver loud but the nets untouched, |
| 120 | +the package door answered `401`, i.e. the outage merely changed disguises. Those |
| 121 | +`catch` blocks now re-raise via `isAuthzStoreUnavailableError` and keep their |
| 122 | +previous behaviour for every other fault. The transport set is rebuilt from |
| 123 | +source and audited for set equality on every test run, so a transport added |
| 124 | +later cannot inherit the old silence unnoticed. |
| 125 | + |
| 126 | +Callers that treat any throw from `resolveAuthzContext` as "anonymous" should |
| 127 | +re-raise `isAuthzStoreUnavailableError(err)` instead: degrading it restores the |
| 128 | +disguise this removes. |
| 129 | + |
| 130 | +<!-- 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. --> |
0 commit comments