Skip to content

fix(core): drop a session's unbacked organization claim under a wall-enforcing posture - #15794

Merged
hotlong merged 4 commits into
mainfrom
claude/issue-15409-session-unbacked-org-claim
Sep 5, 2026
Merged

fix(core): drop a session's unbacked organization claim under a wall-enforcing posture#15794
hotlong merged 4 commits into
mainfrom
claude/issue-15409-session-unbacked-org-claim

Conversation

@hotlong

@hotlong hotlong commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

Fixes #15409

Under a wall-enforcing tenancy posture, a session whose activeOrganizationId names 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/main before writing a line of the repair:

  1. The claim is taken unread. packages/core/src/security/resolve-authz-context.ts line 361 (pre-change):

    tenantId = tenantId ?? sessionData?.session?.activeOrganizationId;
    

    and line 371 puts it straight on the context. Nothing between them consults membership.

  2. The only comparison is API-key-gated, and it is the only one. Re-ran the exhaustiveness grep rather than trusting the card:

    $ grep -rn 'accessible_org_ids' packages --include='*.ts' | grep -v /dist/ | grep -v '\.test\.' | grep 'includes\|indexOf\|has('
    packages/core/src/security/resolve-authz-context.ts:410:    if (postureEnforcesWall(posture) && !grants.accessible_org_ids.includes(keyPrincipal.tenantId)) {
    

    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

resolveAuthzContext gains the session arm of the question its API-key block already asks. When there is no keyPrincipal, the transport supplied a wall-enforcing posture, and the session's claim is not in grants.accessible_org_ids:

  • one warn at the decision point,
  • ctx.tenantId is dropped, and
  • the grants envelope is re-resolved with no tenant.

Re-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.tenantId alone 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:

with no active organization who already owns it
reads resolve to nothing plugin-security/src/tenant-layer.ts, isolated branch: !organizationId returns RLS_DENY_FILTER
a tenant-scoped write is refused, 403 PERMISSION_DENIED ADR-0123 D2, in security-plugin.ts, in its existing words

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

[security] Session organization claim dropped (organization_membership_ended): session=ses_victim principal=u_victim organization=org_alpha. The session stays authenticated with NO active organization — the wire is unchanged.

It names the sys_session row id, never sys_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' }) / SimulatedOrgScopingPlugin nor the cloud-private packages/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. resolveExecCtx is not stubbed, so the real computeExecCtx to resolveAuthzContext chain runs; Layer 0 is modelled as tenant-layer.ts writes 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:

  • §1 controls, both directions: a current member reads its own organization and only that one, and writes a row read back from the store; no cookie is 401 on both verbs with nothing landing; a bogus cookie is 401 and is not a drop.
  • §2 the card's own case: the ex-member reads nothing from the organization they left (the two rows are still in the store, just not served) and the POST is 403 with nothing landing — asserted against the store, never the response body. Plus the single warn and the unchanged wire.
  • §3 B, not A: the ex-member's read is a 200, explicitly not the anonymous deny — and after setActiveOrganization moves the same session row to org_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.
  • §4 the API-key arm untouched: an ex-member's org-stamped key is still 401 outright, its POST lands nothing, and the session drop line does not fire for it.

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, the org_user_ids residue, group behaves the same, single does 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/core through the workspace link (its dist/), so both legs rebuild core; an unbuilt ablation stays green and certifies nothing.

Mutate — the guard restored to base 6a3cc134c:

on-disk blob = 671316e60e312ae3d4c1aaeba96cfc4360fa68a8 / base blob = 671316e60e312ae3d4c1aaeba96cfc4360fa68a8
src marker occurrences after mutation = 0  (was 1)
ablation-dist-preflight: marker absent from all 12 built files
core vitest exit = 1   Tests  5 failed | 81 passed (86)
rest vitest exit = 1   Tests  3 failed | 10 passed (13)
AssertionError: expected 2 to be +0            <- the ex-member reads org_alpha's rows again
AssertionError: expected 201 to be 403         <- and writes into it again
AssertionError: expected [] to have a length of 1 but got +0   <- and nothing is said about it

Restore, proven the same way:

on-disk blob after restore = 9928f2cfc4f4a4ecaa56c699214d0ee0388deafe / HEAD blob = 9928f2cfc4f4a4ecaa56c699214d0ee0388deafe
src marker occurrences after restore = 1
git diff HEAD -- target: empty
ablation-dist-preflight: marker present in 2 built files
tree: working tree clean against HEAD
core vitest exit = 0   Tests  86 passed (86)
rest vitest exit = 0   Tests  13 passed (13)

The mutation script carried a trap ... EXIT INT TERM restore 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, with origin/main merged 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 their tsconfig.test.json programs, confirmed with tsc --listFiles (one hit each) rather than assumed; check:test-typecheck reports core at 4 pinned ledger errors, unchanged, and rest at 0.
  • pnpm lint — the repo-wide eslint . --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 base 901773b21), 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-literal

Two more needed the workspace built, so a full turbo run build over packages/* ran first (71 tasks, 2m11s) and both then answered:

  • check:dual-build-cjs-loads — exit 0. Its first run was PREREQUISITE 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 -naP over the C0 set plus DEL) — no match.

NOT MEASURED, with reasons

  • Seven families the deriver itself marks NOT RUNNABLE LOCALLY because their argv comes from the workflow ($RUNNER_TEMP, ${{ matrix.shard }}, $PROVENANCE): check-cross-package-test-inputs --union-into/--changed, three check-shard-attestation invocations, two check-test-completeness invocations, and pm/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.
  • The enterprise arm. The wall's org-scoping service 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.
  • The kernel-bound / hosted arm (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.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 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/core patch, 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

hotlong and others added 4 commits September 5, 2026 15:57
…d posture (#15409)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…15409)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…on checked zone)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@github-actions github-actions Bot added size/l documentation Improvements or additions to documentation tests tooling labels Sep 5, 2026
@github-actions

github-actions Bot commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/core, touching 5 documentable anchor(s).

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

  • content/docs/kernel/runtime-services/sharing-service.mdx (via resolveAuthzContext (symbol, a top-level function))
  • content/docs/permissions/authorization.mdx (via resolveAuthzContext (symbol, a top-level function))
  • content/docs/permissions/index.mdx (via org_member (literal, a string literal in resolveAuthzContext))
  • content/docs/permissions/permission-metadata.mdx (via org_member (literal, a string literal in resolveAuthzContext))
  • content/docs/permissions/permission-sets.mdx (via org_member (literal, a string literal in resolveAuthzContext))
  • content/docs/permissions/permissions-matrix.mdx (via org_member (literal, a string literal in resolveAuthzContext))
  • content/docs/permissions/positions.mdx (via org_member (literal, a string literal in resolveAuthzContext))
  • content/docs/permissions/sharing-rules.mdx (via org_member (literal, a string literal in resolveAuthzContext))

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

  • content/docs/releases/v14.mdx (via resolveAuthzContext (symbol, a top-level function))
  • content/docs/releases/v16.mdx (via org_member (literal, a string literal in resolveAuthzContext))
  • content/docs/releases/v17.mdx (via resolveAuthzContext (symbol, a top-level function))

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 61 of 219 client-bound route-ledger rows — the other 158 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 158: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 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.

Coarse fallback — 24 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 16978315c4dfb388129f1c3955b056cc8aec0326packageMentionDocs.

Which tree this was computed on

This run read content/docs from 6fdb217fe4fbbee264069c69435fd4153ca3185c — the merge of head 4733750e32b02dab083309f88f49ef7038755784 into base 16978315c4dfb388129f1c3955b056cc8aec0326, 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 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

⚠️ 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 16978315c4dfb388129f1c3955b056cc8aec0326 → 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

1 participant