Repository navigation
Commit 80f9f7e
fix(runtime, plugin-auth)!: the environment-membership gate and the organization slug guard fail closed when their own read faults (#21954)
Fixes #21941
Clause-②: no (narrowing)
Two access guards used to let a request through when their own read
faulted. Each now tells three answers apart: the read answered
(unchanged), the object is not registered in this composition (the guard
does not apply, decided from the registry), and a registered read that
cannot answer (refused with `503 SERVICE_UNAVAILABLE`). This follows the
triage direction in the card's triage comment: fail closed the
platform's own way, not as allowed and not as the guard's own `403`.
## What changed
### `@objectstack/runtime` — `HttpDispatcher.enforceProjectMembership`
(the environment-membership gate)
- **The read throws** ⇒ the gate throws
`AuthzStoreUnavailableError('sys_environment_member', cause)` out of
`dispatch()`. This is the same loud outage the identity step and the
`/keys` and activation domain gates already raise for an authorization
input they could not read. The transport answers `503` with a declared
`SERVICE_UNAVAILABLE` envelope. The old catch logged at debug level and
returned `null` (admit).
- **No ObjectQL engine resolves on the request's kernel** ⇒ refused the
same way. It used to return `null` (admit).
- **The request engine's registry does not register
`sys_environment_member`** ⇒ the gate does not apply and nothing is
read. The question is `ql.registry.getObject(...)`, the same lookup the
engine's verbs make before refusing an unregistered name. An engine
whose registry cannot be asked is read as before, and a fault on that
read refuses.
- Healthy reads keep their answers byte for byte: a member is admitted
(and cached), and a non-member gets `403 PROJECT_MEMBERSHIP_REQUIRED`.
### `@objectstack/plugin-auth` —
`organizationHooks.beforeUpdateOrganization` (the organization slug
guard)
- **The `sys_organization` read or the `sys_environment` read throws** ⇒
better-auth `APIError('SERVICE_UNAVAILABLE')` (`503`), via the new
module helper `slugGuardReadFaultApiError`. The driver's error rides
`cause`. Both catches used to `return`, which ended the hook without
refusing, so the slug changed.
- **The engine does not register `sys_environment`** (asked through
`getSchema`) ⇒ the guard does not apply, and it is checked before either
read. Nothing is read.
- **No data engine** ⇒ the guard does not apply (unchanged `return`, now
stated in code).
- Healthy reads are unchanged: a slug change while an active environment
references the organization is still `403 FORBIDDEN`, and anything else
is allowed.
## The composition questions the triage asked to measure
All readings are on `objectstack` at this branch's base, `d16b9fbf`.
- **Can a composition that serves environment-scoped doors lack
ObjectQL?** No, for any composition built from this repository. Only a
host `KernelResolver` writes `context.environmentId`, and this
repository registers none (`git grep` over `packages/**` finds
`kernel-resolver` read in three places and registered in none). The gate
reaches its read only for a caller the `auth` service signed in. This
repository's `auth` provider, `AuthPlugin`, declares `dependencies =
['com.objectstack.engine.objectql']` (`auth-plugin.ts:291`). So the
no-engine branch refuses like any other fault.
- **Can a composition that mounts the organization-update door lack a
data engine?** Only a standalone `AuthManager` can. `AuthPlugin` reads
`ctx.getService('data')`, which throws when the service is unregistered,
and the plugin hard-depends on ObjectQL. A standalone `AuthManager` runs
on better-auth's in-memory store, and no engine there registers
`sys_environment`. So the guard does not apply there, and the code says
so.
- **Is `sys_environment` registered in the open-source composition?**
No. No package in this repository defines it: `git grep` finds only
lookup-field references and the spec constant
`CLOUD_PROVIDED_OBJECT_NAMES`, and `platform-objects/src/index.ts` says
the `sys_environment*` objects are cloud-only. Pinned against the real
ObjectQL registry: an engine that holds exactly `authIdentityObjects`
answers `getSchema('sys_environment') === undefined`, and the guard then
reads nothing. The fix decides this from registration, not from catching
the throw.
- **`sys_environment_member`** is also in `CLOUD_PROVIDED_OBJECT_NAMES`.
The membership gate therefore asks the same registry question.
### Not measured: the cloud composition
`NOT MEASURED: the cloud composition's membership gate, reason: this
session was refused attaching objectstack-ai/cloud.` Two things there
decide how this lands, and only the cloud tree can answer them:
1. Does the per-environment engine that `context.kernel` resolves
register `sys_environment_member`? `packages/client/CHANGELOG.md`
(11.0.0) records cloud#533 as retiring that object. If it is not
registered there, this PR changes nothing in that composition: the gate
used to admit through the caught throw and now admits by declaration.
2. If the engine does register it, does the read succeed? If that read
faults on every request, every signed-in, non-platform-organization
request on an environment-scoped door answers `503` after this lands.
That is the triage's direction ("a real fault on a registered read
refuses"), but it would be a visible change in that deployment. The
cloud seat should confirm it before release.
## The HTTP door for the slug guard
This was measured with a throwaway test that drove the real better-auth
organization-update endpoint through `AuthManager.handleRequest`, over a
memory engine double. The test was not committed.
| engine | answer | slug after | `sys_environment` reads |
|---|---|---|---|
| does not register `sys_environment` | `200` | changed | 0 |
| registers it, read faults | `503`, body `{ message }` | unchanged | 1
|
| registers it, one active environment | `403`, body `{ message }`
(control) | unchanged | 1 |
| registers it, no environment | `200` | changed | 1 |
On the `503` leg, `handleRequest` also logs one server-side line
(`better-auth returned error: 503 …`). The `503` body follows
better-auth's native shape, `{ message }`, the same shape the guard's
own `403` refusal uses. No `code` field is added.
## Tests
The pins PR #21939 added now assert the refusal. Each superseded
assertion is quoted in place.
-
`packages/runtime/src/http-dispatcher.membership-system-context.test.ts`,
13 tests:
- a read that throws (plain, on a registered engine, and an engine-side
`PERMISSION_DENIED`) is refused, asserted on `code`, `status` and
`object`;
- no engine is refused;
- an unregistered object reads nothing;
- a healthy registered read still admits a member and refuses a
non-member;
- on the wire, through `createDispatcherPlugin` on a real
`ObjectKernel`: member `501` (admitted, no automation service),
non-member `403 PROJECT_MEMBERSHIP_REQUIRED`, read fault `503
SERVICE_UNAVAILABLE` (the envelope parses against `ApiErrorSchema`).
-
`packages/plugins/plugin-auth/src/auth-manager.org-slug-guard-system-context.test.ts`,
10 tests:
- an organization read fault refuses `503` and the environment read is
never made;
- an environment read fault refuses `503`, both on a registering engine
and on one whose registry cannot be asked;
- healthy registered reads still refuse and allow;
- an unregistered `sys_environment` reads nothing;
- with the real ObjectQL registry, `authIdentityObjects` alone reads
nothing, and a real engine fault (no driver) refuses `503`.
- Fixture triage, three runtime files. They did not pin the defect:
- `http-dispatcher.membership-skip-boundary.test.ts` and
`packages-unscoped-environment-binding.test.ts` answered the membership
read from a registry that registered nothing, which the real engine
refuses with `OBJECT_NOT_FOUND`. They now register
`sys_environment_member`.
- `domains/meta-verb-fallthrough.test.ts` composes no ObjectQL engine at
all, and the gate now refuses that composition. The gate is not that
file's subject, so the file sets `enforceProjectMembership: false`, as
the dispatcher option documents for tests.
- Fixtures with `environmentId: 'platform'` and an engine whose registry
does not register the member object (for example
`meta-state-plural-tolerance`) used to pass the gate through a swallowed
`TypeError`. They now pass by the registry's answer. The outcome is the
same.
### Ablation
Each negative pin was ablated: the fail-open answer was put back, the
pin turned red, and the file was restored. The mutation went through
`scripts/ablation-replace.mjs`: the anchor must hit, and the blob change
and restore are verified on disk, with a script `trap` plus a HEAD-blob
hash proof. The subjects are imported relatively
(`./http-dispatcher.js`, `./auth-manager`), so no `dist/` leg applies.
Ablation was run at head `6966166a0`, with the same red counts as an
earlier run at `b275f81b`.
| leg | mutation | red |
|---|---|---|
| A1 | membership read catch → `return null` | 4/13: the three
read-fault pins, and the wire pin `expected 501 to be 503` (a non-member
admitted to the domain) |
| A2 | no engine → `return null` | 1/13 |
| B1 | organization read catch → `return` | 2/10 (`the slug change was
let through …`) |
| B2 | environment read catch → `return` | 2/10 |
The first A2 attempt was a no-op. Its replacement re-contained the
anchor, the tool refused it (anchor 1 → 1), and no test ran. A2 was
redone with a different replacement.
### Results at head `6966166a0`
- `pnpm --filter @objectstack/runtime exec vitest run --project local
--maxWorkers=2`: 330 files, 4662 passed, 19 skipped, 0 failed.
- `pnpm --filter @objectstack/plugin-auth exec vitest run
--maxWorkers=2`: 126 files, 2612 passed, 10 skipped, 0 failed. This ran
at `b275f81b`. Since then `auth-manager.ts` is byte-identical, and the
one changed test file was re-run at the head: 10/10.
- `pnpm --filter @objectstack/runtime typecheck` and `pnpm --filter
@objectstack/plugin-auth typecheck`: both exit 0, including
`check:test-typecheck`.
- `node scripts/pm/dispatch-gates.mjs --commands` derived 75 gate
families from this diff, all run at this head, all exit 0. Reconciled
with `--ran`: 75 derived, 75 run, 0 NOT-MEASURED, every exit code
recorded. These include `check:dispatcher-error-vocabulary`,
`check:auth-mount-ledger`, `check-system-context-census`,
`check-tenant-audit-census`, `check-platform-object-tenancy-census`,
`check:doc-authoring`, `check:issue-citations`, `check:nul-bytes`,
`check:engine-double-contract`, `check:slot-lookup`,
`check:dual-build-cjs-loads` (106 require entry points across 66
packages load) and `check-adr-0087-registration`.
- `check-changeset-no-major.mjs --base origin/main --event` with this
body's `Clause-②` line: exit 0. The level axis reads `no (narrowing)`,
and no moved package is graded `patch`.
- Lint, narrowed to the 7 changed `.ts` files with `eslint
--no-inline-config --format json`: 0 errors and 0 warnings. That is 7
files linted and none ignored. `eslint.config.mjs` enables no type-aware
linting (no `parserOptions.project`), so this diff cannot move a verdict
on an untouched file. The full `pnpm lint` is left to CI.
- One ledger row was added: `scripts/engine-double-contract.pinned.json`
now records the new pinned `findOne` double, written by
`check-engine-double-contract --write`.
## Acceptance notes
- **Two neighbouring fail-opens in `enforceProjectMembership` are out of
this card's scope and untouched.** The session-read catch ("Auth
resolution failed — do not block the request on RBAC") and the `if
(!userId) return null` fall-through both remain. Fixing either in place
is not mechanical: the catch also covers a composition with no auth
wired, which needs the registry's classified lookup. It is also
unmeasured whether either is reachable through a public door, because
the identity step reads the same session first, so this is read-only
inference. Noted here, not filed.
- **`environmentId: 'platform'`** (the reserved virtual id
`rest-server.ts` documents) is skipped by `resolveRequestScope`'s
helpers but not by this gate. If a host resolver ever writes it, the
gate reads `sys_environment_member` for an environment id that has no
rows. That behaviour is unchanged here. Noted, not filed.
- **The wire `503` message is withheld** by the transport's 5xx
sanitizer (`Internal server error`). The failed read is named only
server-side, on the error's `object`. This is the same as the identity
step's tenancy outage today.
- **Changeset grade.** The dispatch asked for a `patch` changeset. Under
`Clause-②: no (narrowing)`, `check-changeset-no-major.mjs` enforces
`minor` for a package the diff moves. This was measured with a `patch`
grade in a throwaway worktree: exit 1 (`enforce`). So the changeset is
`minor` for both packages, carries the **BREAKING** banner, and records
the ADR-0087 disposition `not-required (no-migration-prescription)`.
---
_Generated by [Claude
Code](https://claude.ai/code/session_01WMQprn46CND82KmY8sZWBu)_
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent 6209964 commit 80f9f7e
9 files changed
Lines changed: 475 additions & 61 deletions
File tree
- .changeset
- packages
- plugins/plugin-auth/src
- runtime/src
- domains
- scripts
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
Lines changed: 135 additions & 7 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
18 | 18 | | |
19 | 19 | | |
20 | 20 | | |
21 | | - | |
22 | | - | |
23 | | - | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
24 | 28 | | |
25 | 29 | | |
26 | 30 | | |
| 31 | + | |
27 | 32 | | |
| 33 | + | |
28 | 34 | | |
29 | 35 | | |
30 | 36 | | |
| |||
172 | 178 | | |
173 | 179 | | |
174 | 180 | | |
175 | | - | |
176 | | - | |
| 181 | + | |
| 182 | + | |
| 183 | + | |
| 184 | + | |
| 185 | + | |
| 186 | + | |
| 187 | + | |
| 188 | + | |
| 189 | + | |
| 190 | + | |
| 191 | + | |
| 192 | + | |
| 193 | + | |
| 194 | + | |
| 195 | + | |
| 196 | + | |
| 197 | + | |
| 198 | + | |
| 199 | + | |
| 200 | + | |
| 201 | + | |
| 202 | + | |
| 203 | + | |
| 204 | + | |
| 205 | + | |
| 206 | + | |
| 207 | + | |
| 208 | + | |
| 209 | + | |
| 210 | + | |
| 211 | + | |
| 212 | + | |
| 213 | + | |
| 214 | + | |
| 215 | + | |
| 216 | + | |
| 217 | + | |
| 218 | + | |
| 219 | + | |
| 220 | + | |
| 221 | + | |
| 222 | + | |
| 223 | + | |
| 224 | + | |
| 225 | + | |
177 | 226 | | |
178 | 227 | | |
179 | 228 | | |
180 | 229 | | |
181 | 230 | | |
182 | 231 | | |
183 | 232 | | |
184 | | - | |
| 233 | + | |
| 234 | + | |
185 | 235 | | |
186 | 236 | | |
187 | 237 | | |
188 | | - | |
| 238 | + | |
| 239 | + | |
| 240 | + | |
| 241 | + | |
| 242 | + | |
| 243 | + | |
| 244 | + | |
| 245 | + | |
| 246 | + | |
| 247 | + | |
| 248 | + | |
| 249 | + | |
| 250 | + | |
| 251 | + | |
189 | 252 | | |
190 | 253 | | |
191 | 254 | | |
192 | 255 | | |
193 | 256 | | |
194 | 257 | | |
195 | 258 | | |
| 259 | + | |
| 260 | + | |
| 261 | + | |
| 262 | + | |
| 263 | + | |
| 264 | + | |
| 265 | + | |
| 266 | + | |
| 267 | + | |
| 268 | + | |
| 269 | + | |
| 270 | + | |
| 271 | + | |
| 272 | + | |
| 273 | + | |
| 274 | + | |
| 275 | + | |
| 276 | + | |
| 277 | + | |
| 278 | + | |
| 279 | + | |
| 280 | + | |
| 281 | + | |
| 282 | + | |
| 283 | + | |
| 284 | + | |
| 285 | + | |
| 286 | + | |
| 287 | + | |
| 288 | + | |
| 289 | + | |
| 290 | + | |
| 291 | + | |
| 292 | + | |
| 293 | + | |
| 294 | + | |
| 295 | + | |
| 296 | + | |
| 297 | + | |
| 298 | + | |
| 299 | + | |
| 300 | + | |
| 301 | + | |
| 302 | + | |
| 303 | + | |
| 304 | + | |
| 305 | + | |
| 306 | + | |
| 307 | + | |
| 308 | + | |
196 | 309 | | |
| 310 | + | |
| 311 | + | |
| 312 | + | |
| 313 | + | |
| 314 | + | |
| 315 | + | |
| 316 | + | |
| 317 | + | |
| 318 | + | |
| 319 | + | |
| 320 | + | |
| 321 | + | |
| 322 | + | |
| 323 | + | |
| 324 | + | |
197 | 325 | | |
198 | 326 | | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
1275 | 1275 | | |
1276 | 1276 | | |
1277 | 1277 | | |
| 1278 | + | |
| 1279 | + | |
| 1280 | + | |
| 1281 | + | |
| 1282 | + | |
| 1283 | + | |
| 1284 | + | |
| 1285 | + | |
| 1286 | + | |
| 1287 | + | |
| 1288 | + | |
| 1289 | + | |
| 1290 | + | |
| 1291 | + | |
| 1292 | + | |
| 1293 | + | |
| 1294 | + | |
| 1295 | + | |
| 1296 | + | |
| 1297 | + | |
| 1298 | + | |
| 1299 | + | |
1278 | 1300 | | |
1279 | 1301 | | |
1280 | 1302 | | |
| |||
3334 | 3356 | | |
3335 | 3357 | | |
3336 | 3358 | | |
| 3359 | + | |
| 3360 | + | |
| 3361 | + | |
| 3362 | + | |
| 3363 | + | |
| 3364 | + | |
| 3365 | + | |
| 3366 | + | |
| 3367 | + | |
| 3368 | + | |
| 3369 | + | |
| 3370 | + | |
| 3371 | + | |
| 3372 | + | |
| 3373 | + | |
| 3374 | + | |
| 3375 | + | |
| 3376 | + | |
| 3377 | + | |
| 3378 | + | |
| 3379 | + | |
| 3380 | + | |
| 3381 | + | |
| 3382 | + | |
3337 | 3383 | | |
3338 | 3384 | | |
3339 | 3385 | | |
3340 | 3386 | | |
3341 | | - | |
3342 | | - | |
3343 | | - | |
| 3387 | + | |
3344 | 3388 | | |
3345 | 3389 | | |
3346 | 3390 | | |
| |||
3349 | 3393 | | |
3350 | 3394 | | |
3351 | 3395 | | |
3352 | | - | |
3353 | | - | |
| 3396 | + | |
| 3397 | + | |
3354 | 3398 | | |
3355 | 3399 | | |
3356 | 3400 | | |
| |||
3362 | 3406 | | |
3363 | 3407 | | |
3364 | 3408 | | |
3365 | | - | |
3366 | | - | |
| 3409 | + | |
| 3410 | + | |
3367 | 3411 | | |
3368 | 3412 | | |
3369 | 3413 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
129 | 129 | | |
130 | 130 | | |
131 | 131 | | |
132 | | - | |
| 132 | + | |
| 133 | + | |
| 134 | + | |
| 135 | + | |
| 136 | + | |
| 137 | + | |
133 | 138 | | |
134 | 139 | | |
135 | 140 | | |
| |||
Lines changed: 8 additions & 1 deletion
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
51 | 51 | | |
52 | 52 | | |
53 | 53 | | |
54 | | - | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
55 | 62 | | |
56 | 63 | | |
57 | 64 | | |
| |||
0 commit comments