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
|
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