Repository navigation
fix(runtime, plugin-auth)!: the environment-membership gate and the organization slug guard fail closed when their own read faults - #21954
Conversation
…ganization slug guard fail closed when their own read faults Each guard now tells three answers apart: the read answered (unchanged), the object is not registered in this composition (declared: the guard does not apply, decided from the registry, never from a caught throw), and a registered read that cannot answer (refused with 503 SERVICE_UNAVAILABLE, never admitted and never as the guard's own 403). Claude-Session: https://claude.ai/code/session_01WMQprn46CND82KmY8sZWBu Co-authored-by: Claude <noreply@anthropic.com>
…usal The pins that stated "a read that throws lets the request through" now assert the 503 SERVICE_UNAVAILABLE refusal, beside the healthy-read controls (a member admitted, a non-member refused) and the declared non-applicability for an engine that does not register the guard's object. The membership gate's three answers are also driven through the real plugin route and its envelope, and the slug guard's composition question through the real ObjectQL registry. Claude-Session: https://claude.ai/code/session_01WMQprn46CND82KmY8sZWBu Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WMQprn46CND82KmY8sZWBu Co-authored-by: Claude <noreply@anthropic.com>
…ngine would Two fixtures answered the membership read from an engine whose registry registered nothing, which the real engine refuses with OBJECT_NOT_FOUND; they now register `sys_environment_member`. The metadata verb-routing fixture composes no ObjectQL engine at all, which the gate now refuses as an outage, so it switches the gate off as the dispatcher option documents for tests. Claude-Session: https://claude.ai/code/session_01WMQprn46CND82KmY8sZWBu Co-authored-by: Claude <noreply@anthropic.com>
…bjectQL's findOne contract The doubles that answer the registration question are engine doubles in the engine-double gate's sense, so their findOne routes through assertEngineFindOnePredicate, and the pinned ledger records the new pin (`check-engine-double-contract --write`). Claude-Session: https://claude.ai/code/session_01WMQprn46CND82KmY8sZWBu Co-authored-by: Claude <noreply@anthropic.com>
… any The slot-lookup ratchet refuses a new `: any` on a service lookup; the inferred type is the lookup's own. Claude-Session: https://claude.ai/code/session_01WMQprn46CND82KmY8sZWBu Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 2 package(s): 14 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 6 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 36 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 204d04fac6891ae9a27804406460eb62e69a1e07 && git checkout 204d04fac6891ae9a27804406460eb62e69a1e07
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 76fec88b16031211ffab296ea5be25470a9315ec 6966166a031352eecf55759c97896e34195f8bb4 && git checkout -B drift-repro 76fec88b16031211ffab296ea5be25470a9315ec && git merge --no-ff 6966166a031352eecf55759c97896e34195f8bb4
node scripts/docs-audit/affected-docs.mjs --json 76fec88b16031211ffab296ea5be25470a9315ec
|
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 own403.What changed
@objectstack/runtime—HttpDispatcher.enforceProjectMembership(the environment-membership gate)AuthzStoreUnavailableError('sys_environment_member', cause)out ofdispatch(). This is the same loud outage the identity step and the/keysand activation domain gates already raise for an authorization input they could not read. The transport answers503with a declaredSERVICE_UNAVAILABLEenvelope. The old catch logged at debug level and returnednull(admit).null(admit).sys_environment_member⇒ the gate does not apply and nothing is read. The question isql.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.403 PROJECT_MEMBERSHIP_REQUIRED.@objectstack/plugin-auth—organizationHooks.beforeUpdateOrganization(the organization slug guard)sys_organizationread or thesys_environmentread throws ⇒ better-authAPIError('SERVICE_UNAVAILABLE')(503), via the new module helperslugGuardReadFaultApiError. The driver's error ridescause. Both catches used toreturn, which ended the hook without refusing, so the slug changed.sys_environment(asked throughgetSchema) ⇒ the guard does not apply, and it is checked before either read. Nothing is read.return, now stated in code).403 FORBIDDEN, and anything else is allowed.The composition questions the triage asked to measure
All readings are on
objectstackat this branch's base,d16b9fbf.KernelResolverwritescontext.environmentId, and this repository registers none (git grepoverpackages/**findskernel-resolverread in three places and registered in none). The gate reaches its read only for a caller theauthservice signed in. This repository'sauthprovider,AuthPlugin, declaresdependencies = ['com.objectstack.engine.objectql'](auth-plugin.ts:291). So the no-engine branch refuses like any other fault.AuthManagercan.AuthPluginreadsctx.getService('data'), which throws when the service is unregistered, and the plugin hard-depends on ObjectQL. A standaloneAuthManagerruns on better-auth's in-memory store, and no engine there registerssys_environment. So the guard does not apply there, and the code says so.sys_environmentregistered in the open-source composition? No. No package in this repository defines it:git grepfinds only lookup-field references and the spec constantCLOUD_PROVIDED_OBJECT_NAMES, andplatform-objects/src/index.tssays thesys_environment*objects are cloud-only. Pinned against the real ObjectQL registry: an engine that holds exactlyauthIdentityObjectsanswersgetSchema('sys_environment') === undefined, and the guard then reads nothing. The fix decides this from registration, not from catching the throw.sys_environment_memberis also inCLOUD_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:context.kernelresolves registersys_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.503after 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.sys_environmentreadssys_environment200503, body{ message }403, body{ message }(control)200On the
503leg,handleRequestalso logs one server-side line (better-auth returned error: 503 …). The503body follows better-auth's native shape,{ message }, the same shape the guard's own403refusal uses. Nocodefield 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:PERMISSION_DENIED) is refused, asserted oncode,statusandobject;createDispatcherPluginon a realObjectKernel: member501(admitted, no automation service), non-member403 PROJECT_MEMBERSHIP_REQUIRED, read fault503 SERVICE_UNAVAILABLE(the envelope parses againstApiErrorSchema).packages/plugins/plugin-auth/src/auth-manager.org-slug-guard-system-context.test.ts, 10 tests:503and the environment read is never made;503, both on a registering engine and on one whose registry cannot be asked;sys_environmentreads nothing;authIdentityObjectsalone reads nothing, and a real engine fault (no driver) refuses503.http-dispatcher.membership-skip-boundary.test.tsandpackages-unscoped-environment-binding.test.tsanswered the membership read from a registry that registered nothing, which the real engine refuses withOBJECT_NOT_FOUND. They now registersys_environment_member.domains/meta-verb-fallthrough.test.tscomposes no ObjectQL engine at all, and the gate now refuses that composition. The gate is not that file's subject, so the file setsenforceProjectMembership: false, as the dispatcher option documents for tests.environmentId: 'platform'and an engine whose registry does not register the member object (for examplemeta-state-plural-tolerance) used to pass the gate through a swallowedTypeError. 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 scripttrapplus a HEAD-blob hash proof. The subjects are imported relatively (./http-dispatcher.js,./auth-manager), so nodist/leg applies. Ablation was run at head6966166a0, with the same red counts as an earlier run atb275f81b.return nullexpected 501 to be 503(a non-member admitted to the domain)return nullreturnthe slug change was let through …)returnThe 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
6966166a0pnpm --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 atb275f81b. Since thenauth-manager.tsis byte-identical, and the one changed test file was re-run at the head: 10/10.pnpm --filter @objectstack/runtime typecheckandpnpm --filter @objectstack/plugin-auth typecheck: both exit 0, includingcheck:test-typecheck.node scripts/pm/dispatch-gates.mjs --commandsderived 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 includecheck: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) andcheck-adr-0087-registration.check-changeset-no-major.mjs --base origin/main --eventwith this body'sClause-②line: exit 0. The level axis readsno (narrowing), and no moved package is gradedpatch..tsfiles witheslint --no-inline-config --format json: 0 errors and 0 warnings. That is 7 files linted and none ignored.eslint.config.mjsenables no type-aware linting (noparserOptions.project), so this diff cannot move a verdict on an untouched file. The fullpnpm lintis left to CI.scripts/engine-double-contract.pinned.jsonnow records the new pinnedfindOnedouble, written bycheck-engine-double-contract --write.Acceptance notes
enforceProjectMembershipare out of this card's scope and untouched. The session-read catch ("Auth resolution failed — do not block the request on RBAC") and theif (!userId) return nullfall-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 idrest-server.tsdocuments) is skipped byresolveRequestScope's helpers but not by this gate. If a host resolver ever writes it, the gate readssys_environment_memberfor an environment id that has no rows. That behaviour is unchanged here. Noted, not filed.503message is withheld by the transport's 5xx sanitizer (Internal server error). The failed read is named only server-side, on the error'sobject. This is the same as the identity step's tenancy outage today.patchchangeset. UnderClause-②: no (narrowing),check-changeset-no-major.mjsenforcesminorfor a package the diff moves. This was measured with apatchgrade in a throwaway worktree: exit 1 (enforce). So the changeset isminorfor both packages, carries the BREAKING banner, and records the ADR-0087 dispositionnot-required (no-migration-prescription).Generated by Claude Code