Repository navigation
fix(core): drop a session's unbacked organization claim under a wall-enforcing posture - #15794
Conversation
📓 Docs Drift CheckThis PR changes 1 package(s): 8 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 3 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 24 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 6fdb217fe4fbbee264069c69435fd4153ca3185c && git checkout 6fdb217fe4fbbee264069c69435fd4153ca3185c
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 16978315c4dfb388129f1c3955b056cc8aec0326 4733750e32b02dab083309f88f49ef7038755784 && git checkout -B drift-repro 16978315c4dfb388129f1c3955b056cc8aec0326 && git merge --no-ff 4733750e32b02dab083309f88f49ef7038755784
node scripts/docs-audit/affected-docs.mjs --json 16978315c4dfb388129f1c3955b056cc8aec0326
|
…ganization, not the raw session claim (objectstack-ai#20477) (objectstack-ai#20491) Fixes objectstack-ai#20477 Clause-②: no ## What was wrong (H0, measured before any edit) The runtime dispatcher's nine organization-scoped `/packages` doors take the caller's organization from one source, `HttpDispatcher.resolveActiveOrganizationId`. That source returned the auth session's `activeOrganizationId` as stored. `resolveAuthzContext` vets that claim onto the execution context as `tenantId`, and under a wall-enforcing posture it drops a claim that no membership backs (maintainer ruling B on objectstack-ai#15409, implemented by PR objectstack-ai#15794). The dispatcher never read the vetted value. Measured on unmodified `origin/main` source (`851af0c27`), with real identity resolution (`dispatch()` → `resolveRequestScope` → `resolveExecutionContext` → `resolveAuthzContext`) under an `isolated` posture. The subject is a member removed from `org_alpha` whose session still names it. They hold the same permission set as a current member, so RBAC cannot separate the arms. | Door | Transport | What it did with `org_alpha`'s rows | |---|---|---| | `GET /packages/:id/commits` (read) | `dispatch()`, and the plugin's explicit mount over a real socket | 200, served `org_alpha`'s commit `cmt_alpha` | | `POST /packages/:id/discard-drafts` (write) | both | 200, deleted `org_alpha`'s draft | | `DELETE /packages/:id` (write) | both | 200, deleted `org_alpha`'s row | All nine doors handed the protocol `org_alpha` on both transports: 20 of 20 subject cases red, 56 of 56 controls green. So reach is served, and the p0 grade stands. Every effect stayed inside the test's own in-memory stack. ## The fix The fix is in the one source, not at the nine sites. `resolveActiveOrganizationId` now returns `context.executionContext.tenantId` through `metaCallerOrganizationId` from `@objectstack/rest`. That is the helper `RestServer` and the dispatcher's `/meta` doors already share (objectstack-ai#20408). - `@objectstack/rest` is already a dependency of `@objectstack/runtime` (`domains/meta.ts` imports from it), so no dependency edge is added. - There is no second vetting path: `packages/core` is untouched. - `domains/packages.ts` is unchanged. The `domain-handler-registry.ts` edit is only the dep's contract comment. ## Hypotheses - **H1 (every caller has the vetted context): holds.** - The dep's only caller is `domains/packages.ts`, at 9 sites (`git grep` at HEAD). - Both HTTP entries reach the domain through `dispatch()`: the `createHonoApp` catch-all and `createDispatcherPlugin`'s explicit mounts. `dispatch()` runs `resolveRequestScope`, which writes `executionContext` before any domain handler. Only the declared liveness route (`/health`) skips it, and that route reads no organization. - The domain's first statement is the anonymous-deny floor. It refuses a context with no resolved principal before any of the nine sites is reached. - So no call site runs without a resolved context. - **H2 (controls unchanged): holds.** - A current member reaches their own organization on every door, on both transports. - The ex-member, switched to an organization they belong to, reaches that one and never the one they left. - An anonymous caller gets `401 ANONYMOUS_DENY` before any protocol call. - A control asserts the resolver really dropped the ex-member's claim: its `Session organization claim dropped` line names `org_alpha`. So the green subject cannot come from a rig that never presented the stale claim. - **H3 (ablation): holds, in the direction predicted.** - Starting from the committed fix, `scripts/ablation-replace.mjs` put the original raw-session body back, verbatim from `BASE` (anchor 1 → 0, blob `d6ad749b8ced` → `0414fe725ad2`). - Predicted before the run: exactly the 20 left-organization cases red and the 56 controls green, plus the migrated seed-apply §0 control red. - Measured: 21 failed and 65 passed of 86, and the red set is exactly those 21 names. - The tool proved the restore: the blob after restore equals the blob at HEAD (`d6ad749b8ced`), and `git diff HEAD` is empty. ## The pins `packages/runtime/src/domains/packages-vetted-org-source.test.ts` has 76 cases. It covers each of the nine doors × both transports × four callers: a current member, the ex-member switched to a real membership, an anonymous caller, and the ex-member with the stale claim. It adds the claim-drop control and the uninstall refusal. - The subject cases assert two things: the protocol is handed no organization, and the left organization's partition is neither read (no `alpha` in the answer) nor written (its partition is byte-identical afterwards). - The ex-member's uninstall gets the protocol's refusal, `400 TENANT_SCOPE_REQUIRED`, and nothing is deleted. The protocol double copies that refusal from `metadata-protocol`'s `deletePackage`. ## One existing test migrated `packages-seed-apply-org-scope.test.ts` passed its organization in through a stubbed `getSession`, which is the raw source this PR retires. After the fix, every measurement in that file had silently moved to its one-rung branch, and only its §0 control went red. The organization now lands on the execution context's `tenantId`, where `dispatch()` puts it, and the dead session stub is removed. ## Behaviour notes - **API-key callers.** The doors now use the organization the key is bound to. The raw read found no session for a key, because API keys resolve in `@objectstack/core`, not in better-auth. So these doors used to get no organization for a key caller. This matches `RestServer` and the landed `/meta` doors. It is reasoned from source, not measured with a key. - **The ex-member after the fix** is exactly a session with no active organization, so the doors reach the env-wide package state. The landed `/meta` sibling does the same: its pin lands the ex-member's `PUT` env-wide on both transports. ## Verification Run on the final head `1e0553b290`, after merging `origin/main` at `3062e5001`. - **Build:** `pnpm --workspace-concurrency=2 --filter '@objectstack/runtime^...' --filter @objectstack/runtime build` exited 0. - **Tests:** the `@objectstack/runtime` `local` project ran 285 files: 4160 passed, 1 skipped. `test:repo` ran 3 files: 575 passed. - **Typecheck:** exited 0, and `check:test-typecheck` is OK. `tsc --listFiles` confirms both touched test files are in the test program. - **Lint:** `pnpm lint` exited 0. - **Citations:** `node scripts/check-issue-citations.mjs --base origin/main` reports that every added citation resolves. - **Derived gates:** `dispatch-gates --commands` derived 61 commands, and 60 of them ran with exit 0. The `--ran` reconciliation reads 61 derived, 60 run, 1 NOT-MEASURED, 0 UNRUN. - The NOT-MEASURED one is `pnpm check:dual-build-cjs-loads`, which exited 3 with `PREREQUISITE NOT MET` because the whole workspace's `dist/` is not built in this worktree. It is not a pass. This diff touches no manifest, export or build config. **Declared narrowing — verification ran UNLOCKED.** `scripts/pm/os-verify-lock.sh` could not take the shared verify lock on this host: no usable `flock`. The shared verify lock is declared Linux-only (`flock` is util-linux, and a stock macOS does not ship it), so the command below was run directly, without the lock — a declared narrowing, not a silent one. No serialization guarantee held for this run, nor for any sibling agent in this container while it ran. pnpm --filter @objectstack/runtime exec vitest run --project local --maxWorkers=2 Every build and test command listed above ran under the same declaration, and each printed this block with its own command. ## Acceptance notes - **Out of scope, found while measuring, reported for the seat to file (class a).** `DELETE /packages/:id` runs `registry.uninstallPackage(id)` before the protocol's org-scoped persistence call. - The protocol refuses any caller with no active organization (`400 TENANT_SCOPE_REQUIRED`, "Refusing to uninstall"). By then the package has already been removed from the live registry. - Measured through `dispatch()` with a real `SchemaRegistry` and the real `ObjectStackProtocolImplementation`: `GET /packages/:id` answered 200 before the refused DELETE and 404 after it, while the stored rows were kept. - It is not fixed here: it is a different defect class from this card, in code this diff does not touch. - **ADR-0123 boundary, noted only.** ADR-0123 D2 refuses a caller with no organization when they write tenant-scoped data through the security middleware. The package verbs run as protocol calls, so that caller's publish-drafts, discard-drafts, revert, rollback and adopt-orphans reach the env-wide package state. - That is the existing behaviour for every session with no active organization, and the landed `/meta` sibling behaves the same way. - Whether ADR-0123's write refusal should also cover metadata-package verbs is a question for the maintainer. It is not a finding here. --- _Generated by [Claude Code](https://claude.ai/code/session_local_1d2a197c-c20e-4e90-9be8-413d4d432289)_ --------- Co-authored-by: Jack Zhuang <50353452+hotlong@users.noreply.github.com> Co-authored-by: Claude <noreply@anthropic.com>
Fixes #15409
Under a wall-enforcing tenancy posture, a session whose
activeOrganizationIdnames an organization the user has left now resolves with no active organization instead of that one. Option B of the maintainer's 2026-09-05 ruling: the claim is dropped, the principal is not refused.I verified the defect myself first, on this branch's merge base
Both halves of the card's diagnosis, re-measured on
origin/mainbefore writing a line of the repair:The claim is taken unread.
packages/core/src/security/resolve-authz-context.tsline 361 (pre-change):and line 371 puts it straight on the context. Nothing between them consults membership.
The only comparison is API-key-gated, and it is the only one. Re-ran the exhaustiveness grep rather than trusting the card:
One line, against 69 files that merely mention the key in this checkout. It opens on
keyPrincipal, so a session never reaches it.The premise holds in full.
The change
resolveAuthzContextgains the session arm of the question its API-key block already asks. When there is nokeyPrincipal, the transport supplied a wall-enforcing posture, and the session's claim is not ingrants.accessible_org_ids:warnat the decision point,ctx.tenantIdis dropped, andRe-resolved rather than field-edited, deliberately. "Resolves with no active organization" is an existing, well-defined state, and the honest way to reach it is to ask the same resolver for it. Editing
ctx.tenantIdalone would leave the envelope's other tenant-scoped derivations computed under the claim that was just rejected —org_user_ids, the fellow-org peer list Layer 1 scopes identity tables with, would still enumerate the members of the organization the user left. That residue is pinned by name in the unit suite. The second resolution is reached only by a request that presented an unbacked claim; a healthy request pays nothing.No second refusal mechanism was added, as ruled. Both halves of the fail-closed behaviour already shipped and this change simply arrives at them:
plugin-security/src/tenant-layer.ts,isolatedbranch:!organizationIdreturnsRLS_DENY_FILTERPERMISSION_DENIEDsecurity-plugin.ts, in its existing wordsNot A, and the difference is pinned from the outside: an API key is its organization binding, so it keeps refusing the principal outright (#15256 decision 1A, untouched). A session is a person who may hold memberships elsewhere — they stay signed in and can switch to an organization they are actually in.
Not C: no session revocation here. That is an event trigger and would cover exactly the removal paths someone remembered to wire; this test runs on every request, at the point the decision is made.
Observability, mirroring #15256's 2A — one
warn, server-side only:It names the
sys_sessionrow id, neversys_session.token— that column's own field comment records a replay-proven impersonation, so it is the one value the line must never carry. Pinned in both suites.The wire is unchanged: no new status code, no new error code, no reason on the wire. Pinned by a test that asserts the GET and POST bodies mention no reason, no session id and no dropped organization.
Scope
Untouched, as ruled:
activeOrganizationId's write path, better-auth configuration, session lifetime, revocation, and the API-key arm.The rig I used, and the one I did not
Neither
bootStack({ multiTenant: 'posture-only' })/SimulatedOrgScopingPluginnor the cloud-privatepackages/organizations. The pins are driven through the REST-level single-kernel harness that #15256's own repair is pinned with (single-kernel-isolated-api-key-matrix.test.ts), which supplies the posture directly through the tenancy service provider — and the posture is all this guard reads.resolveExecCtxis not stubbed, so the realcomputeExecCtxtoresolveAuthzContextchain runs; Layer 0 is modelled astenant-layer.tswrites it, including the deny half, rather than mocked away. The enterprise plugin's own refusals are not spoken for here and are not claimed.Pins
packages/rest/src/single-kernel-isolated-session-org-claim-matrix.test.ts— 13 cases, the measured scenario end to end:warnand the unchanged wire.notthe anonymous deny — and aftersetActiveOrganizationmoves the same session row toorg_beta(same id, same token, same cookie) they read and write there normally. This is the assertion that reddens if B is ever simplified into A.packages/core/src/security/resolve-authz-context.test.ts— 11 unit cases: the backed-claim control, the drop itself (the key is removed, not set to undefined), the surviving principal, theorg_user_idsresidue,groupbehaves the same,singledoes not, an unwired transport does not, a session with no claim is not a drop, a lapsed ADR-0091 membership does not back a claim, the log line, and the API-key arm.Ablation — both legs proven on disk, both rebuilt
This REST suite consumes
@objectstack/corethrough the workspace link (itsdist/), so both legs rebuild core; an unbuilt ablation stays green and certifies nothing.Mutate — the guard restored to base
6a3cc134c:Restore, proven the same way:
The mutation script carried a
trap ... EXIT INT TERMrestore throughout. §3's B-not-A cases stay green under the ablation by design: they assert the principal is not refused, which the leaky code also does not do. Their job is to redden if B becomes A, not if the guard is removed.Verification
All at
4733750e3, withorigin/mainmerged in (901773b21).pnpm --filter @objectstack/core --filter @objectstack/rest test— core 49 files / 1201 tests, rest 182 files / 3112 tests, all passing.pnpm --filter @objectstack/core --filter @objectstack/rest typecheck— exit 0. Both new/edited test files are genuinely inside theirtsconfig.test.jsonprograms, confirmed withtsc --listFiles(one hit each) rather than assumed;check:test-typecheckreports core at 4 pinned ledger errors, unchanged, and rest at 0.pnpm lint— the repo-wideeslint . --no-inline-config, exit 0 in 24s. Not narrowed.node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack— let it derive its own change set (4 paths, merge base901773b21), no stale-tree warning.Every derived family, run and green (exit 0 captured before any pipe):
check:authz-resolver(the one whose gate source is the edited file) ·check:changeset-gate-self-tests·check:cross-package-test-inputs·check:dispatcher-error-vocabulary·check:doc-authoring·check:kernel-hook-pairs·check:logger-receiver-detach·check:objectql-double-limit·check:objectui-changeset·check:org-identifier·check:page-declaration-shape·check:pm-half-states·check:published-files·check:slot-lookup·check:test-source-alias·check:type-source-resolution·check:where-matcher·check:query-options-erasure·check:type-check-coverage·check:engine-double-contract·check:driver-memory-census·check:nul-bytes·check:refd-timer-probe·check:watch-hint-literalTwo more needed the workspace built, so a full
turbo run buildoverpackages/*ran first (71 tasks, 2m11s) and both then answered:check:dual-build-cjs-loads— exit 0. Its first run wasPREREQUISITE NOT MET(exit 3), which is NOT MEASURED, not a pass; reported here only because it was re-run properly.check:type-check-debt— exit 0, 12 ledger entries re-measured, 140 raw errors, none above its recorded number.Also self-scanned the four changed files for raw control bytes (
grep -naPover the C0 set plus DEL) — no match.NOT MEASURED, with reasons
NOT RUNNABLE LOCALLYbecause their argv comes from the workflow ($RUNNER_TEMP,${{ matrix.shard }},$PROVENANCE):check-cross-package-test-inputs --union-into/--changed, threecheck-shard-attestationinvocations, twocheck-test-completenessinvocations, andpm/check-half-states --provenance. Their scripts document no default for the pinned flags, so there is no local invocation and none was invented. CI runs them.org-scopingservice has only one registrar and it is cloud-private, so this repo cannot speak for@objectstack/organizations' own refusals. The guard reads only the posture, which is why the pins are still meaningful — but the enterprise composition is not claimed.OS_MULTI_TENANT) remains unmeasured, exactly as the card and cloud#1982 carried it forward. Unknown, not safe.Declared narrowing — verification ran UNLOCKED
scripts/pm/os-verify-lock.shcould not take the shared verify lock on this host: no usableflock. The shared verify lock is declared Linux-only (flockis util-linux, and a stock macOS does not ship it), so the commands were run directly, without the lock — a declared narrowing, not a silent one. No serialization guarantee held for these runs, nor for any sibling agent in this container while they ran.Changeset
.changeset/session-unbacked-org-claim-dropped.md—@objectstack/corepatch, naming the observable change: a session whose active organization is no longer one the user belongs to now resolves with no active organization instead of that one's data.Generated by Claude Code