Skip to content

fix(runtime, plugin-auth)!: the environment-membership gate and the organization slug guard fail closed when their own read faults - #21954

Merged
objectstack-fleet[bot] merged 6 commits into
mainfrom
claude/issue-21941-access-guards-fail-closed
Oct 6, 2026
Merged

objectstack-fleet[bot] merged 6 commits into
mainfrom
claude/issue-21941-access-guards-fail-closed

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

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

claude added 6 commits October 6, 2026 04:45
…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>
…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>
@github-actions github-actions Bot added size/l documentation Improvements or additions to documentation tests tooling labels Oct 6, 2026
@github-actions

github-actions Bot commented Oct 6, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/plugin-auth, @objectstack/runtime, touching 9 documentable anchor(s).

14 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/api/client-sdk.mdx (via SERVICE_UNAVAILABLE (literal, a string literal in slugGuardReadFaultApiError))
  • content/docs/api/environment-routing.mdx (via enforceProjectMembership (symbol, a method of class HttpDispatcher))
  • content/docs/api/error-catalog.mdx (via SERVICE_UNAVAILABLE (literal, a string literal in slugGuardReadFaultApiError))
  • content/docs/api/index.mdx (via SERVICE_UNAVAILABLE (literal, a string literal in slugGuardReadFaultApiError))
  • content/docs/api/wire-format.mdx (via PROJECT_MEMBERSHIP_REQUIRED (literal, a string literal in enforceProjectMembership))
  • content/docs/automation/flows.mdx (via sys_organization (literal, a string literal in buildPluginList))
  • content/docs/deployment/cli.mdx (via sys_organization (literal, a string literal in buildPluginList))
  • content/docs/deployment/seed-tenancy-repair.mdx (via sys_organization (literal, a string literal in buildPluginList))
  • content/docs/permissions/administrator-guide.mdx (via sys_organization (literal, a string literal in buildPluginList))
  • content/docs/protocol/kernel/config-resolution.mdx (via sys_organization (literal, a string literal in buildPluginList))
  • content/docs/protocol/kernel/error-handling.mdx (via SERVICE_UNAVAILABLE (literal, a string literal in slugGuardReadFaultApiError))
  • content/docs/protocol/objectql/schema.mdx (via sys_organization (literal, a string literal in buildPluginList))
  • content/docs/ui/actions.mdx (via sys_environment (literal, a string literal in buildPluginList))
  • content/docs/ui/apps.mdx (via sys_environment (literal, a string literal in buildPluginList))

⛔ 6 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/implementation-status.mdx (via sys_organization (literal, a string literal in buildPluginList))
  • content/docs/releases/v17/17-0.mdx (via PROJECT_MEMBERSHIP_REQUIRED (literal, a string literal in enforceProjectMembership), SERVICE_UNAVAILABLE (literal, a string literal in slugGuardReadFaultApiError))
  • content/docs/releases/v17/17-1.mdx (via sys_organization (literal, a string literal in buildPluginList))
  • content/docs/releases/v17/17-3.mdx (via SERVICE_UNAVAILABLE (literal, a string literal in slugGuardReadFaultApiError))
  • content/docs/releases/v17/17-4.mdx (via SERVICE_UNAVAILABLE (literal, a string literal in slugGuardReadFaultApiError), sys_organization (literal, a string literal in buildPluginList))
  • content/docs/releases/v17/17-5.mdx (via sys_organization (literal, a string literal in buildPluginList))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 36 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 76fec88b16031211ffab296ea5be25470a9315ec → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 204d04fac6891ae9a27804406460eb62e69a1e07 — the merge of head 6966166a031352eecf55759c97896e34195f8bb4 into base 76fec88b16031211ffab296ea5be25470a9315ec, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 76fec88b16031211ffab296ea5be25470a9315ec → pass the list as
args.docs, on the commit named under Which tree this was computed on.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

2 participants