Repository navigation
[decision · p0] a SESSION whose activeOrganizationId points at a left organization reads AND writes that organization — measured through better-auth's own remove-member endpoint #15409
Description
Activity
- addedbugSomething isn't workingSomething isn't workingpriority:p0Critical: blocker, must ship before MVPCritical: blocker, must ship before MVP
on Sep 4, 2026 Maintainer ruling recorded — B: a session whose
activeOrganizationIdis not backed by a held membership resolves with no active organization; the principal is not refused, the unbacked claim is droppedDirector seat, summon #14, session
session_01LsEjuNMPitCHwEfYftZ1um(GitHubos-warren), 2026-09-04. Provenance: maintainer, live PM chat, decision batch #38 (this card item 1, presented with the recommendation B, fallback A), verbatim reply 「同意」.Ruled: B. In
packages/core/src/security/resolve-authz-context.ts, the tenant-claim-vs-membership test that today runs only for the API-key principal (keyPrincipal-gated, the singleaccessible_org_ids.includes(...)comparison in the framework) is applied to session principals as well — with a different consequence: where the API-key arm refuses the credential (organization_membership_ended), the session arm clears the active organization (tenantIdunset) and lets the request continue as a user with no active organization. Layer 0 is already fail-closed on that shape (!organizationId ⇒ RLS_DENY_FILTER), so the wall closes with no new refusal type. The user stays signed in and can switch to an organization they actually belong to. Not taken: A (refuse the whole session — bounces a user who is legitimately a member elsewhere), C (revoke/re-point sessions on membership end — must catch every removal path, and the guard's own comment records why that silently fails).Why (① ≥50%): the wall was comparing a stored self-assertion nobody verified; B removes the unverified input rather than adding a second enforcement shape, and reuses the existing fail-closed branch. ② is the largest pull on today's board: the ordinary "remove member" operation on every walled deployment, driven through better-auth's own endpoint, was measured to leave the ex-member reading and writing. ④: one condition, no new export, no new refusal class, no session-lifecycle machinery.
Execution:
domain:serviceslane, p0 — dispatch next, S–M. Deliverables: the session arm of the guard; a pin that drives the measured shape (membership removed via the org-owner path, next request resolves with no active org, GET/POST on the left org refused by Layer 0) plus the four controls the measurement used (intact member served, signed-out 401, bogus cookie 401, other-org member sees only its rows);get-sessionpositions and the resolved context stay consistent.⚠️ TheOS_MULTI_TENANTkernel-bound wiring is still unmeasured (refuses to boot ondriver-memory) — report it as NOT MEASURED, do not claim it.Clause-②: yes(a published authorization path changes what it resolves for a caller-supplied claim) ⇒needs:contract-reviewon the PR, review atCONTRACT_REVIEW_TIERbefore ready. Changeset:@objectstack/coreminor with a BREAKING-behaviour note (a session carrying a staleactiveOrganizationIdno longer reaches that organization; it resolves with none). Cross-link #15256 / PR #15365 (the API-key arm) and #15396 (the measurement).State transition, same stroke:
needs-user-decision→pm:queue.bug·priority:p0·security·domain:servicesunchanged. Ledger: director seat post #12708, batch #38.
Generated by Claude Code
Three facts found after this card was filed — they change the cost of one option and sharpen the recommendation
PM seat, session
6679d191-11f4-465b-b322-0e0409d76793. Measured onorigin/maintoday; ⛔ none of this changes the defect, only what it costs to close and how long it stays open.1. The exposure window is 7 days by default
auth-manager.ts:1626—expiresIn: this.config.session?.expiresIn || 60 * 60 * 24 * 7. Nothing shortens it on a membership change, so a removed member keeps reading and writing the organization until the session expires on its own — up to a week, unless the deployment configured something shorter.2. The framework's only membership check is still API-key-gated
Re-verified: a grep for a membership comparison over
accessible_org_idsacross all non-test, non-dist sources returns exactly one line —resolve-authz-context.ts:410,if (postureEnforcesWall(posture) && !grants.accessible_org_ids.includes(keyPrincipal.tenantId)). PR #15365 did not touch it. So the finding holds on the fixed tree.3. ⭐ Session revocation machinery already exists — which materially cheapens option C, and does not make it right
sys_session.revoked_at/revoke_reasonare declared and system-managed; revoking expires the session in place, so better-auth returns nothing on the next request and the Console's existing 401 → login redirect handles it — no client change.engine.ts:8041already writesrevoked_at.⚠️ But every existing reason is a timer: the field's own description enumeratesidle_timeout,absolute_max,concurrent_cap, …. None is event-driven. "Membership ended" would be the first authorization-event trigger on this mechanism, and that is precisely where the objection recorded beside the guard bites: an event trigger must catch every removal path — better-auth's endpoint, a direct row delete, a seed replay, a bulk operation, an admin tool, a control-plane operation — and a missed path fails open, silently.The asymmetry that decides it
A and B are evaluated on every request: the check runs at the point the decision is made, so it covers every removal path by construction and cannot be missed. C is a trigger that must fire: it covers exactly the paths someone remembered to wire.
⇒ For a security boundary, per-request evaluation beats event propagation. ⛔ C must not be the enforcement.
Recommendation, revised: B as the boundary, C later as the courtesy
- B is the control: a session whose
activeOrganizationIdis not backed by a membership resolves with no active organization. Layer 0 is already fail-closed on that (!organizationId ⇒ RLS_DENY_FILTER), so this closes the hole using a branch that exists. - B over A on business grounds: an API key is its organization binding, so refusing the whole credential is right there; a session is a person, who may hold legitimate memberships elsewhere. A logs them out of everything; B leaves them signed in and lets them switch to an organization they are actually in.
- C is worth doing afterwards, as UX, not as the wall: when an admin clicks "Remove member" they expect that person to be signed out, and the machinery for it is already built and needs no client change. ⛔ Filing it as a separate card, after B, keeps the enforcement in the place that cannot be missed.
⚠️ Recorded as a PM recommendation under the maintainer's delegation, not a ruling. The choice between A and B, and whether C follows, remains the maintainer's.- B is the control: a session whose
Maintainer ruling — B: drop the unbacked claim. C follows later as a separate card, and is ⛔ never the enforcement
Provenance. Maintainer, 2026-09-05, live chat with this session (
6679d191-11f4-465b-b322-0e0409d76793), replying to the business explanation and four-facet analysis presented in that chat with the recommendation "B as the boundary, C later as the courtesy". Verbatim, untranslated:同意
「同意」 adopts the recommendation as presented.
Ruled
- B is the control. A session whose
activeOrganizationIdis not backed by a membership, under a wall-enforcing posture, resolves with no active organization instead of the one it cannot justify. Layer 0 is already fail-closed on that (!organizationId ⇒ RLS_DENY_FILTER), so this closes the hole through a branch that already exists. - ⛔ Not A for sessions. An API key is its organization binding, so refusing the whole credential is right there and stays ruled that way ([decision · p0] an ex-member API key reads AND writes another organization's rows on the single-kernel wiring under
isolated— the wall compares against the caller's own unvetted claim #15256, decision 1A). A session is a person, who may hold legitimate memberships elsewhere; A would sign them out of everything. - ⛔ Not C as the enforcement. Session revocation on a membership change is an event trigger: it covers exactly the paths someone remembered to wire, and a missed path — a direct row delete, a seed replay, a bulk operation, a control-plane operation — fails open, silently. B is evaluated on every request, at the point the decision is made, so it covers every removal path by construction.
- C is filed separately and lands after B, as the thing an admin expects when they click "Remove member": the person is actually signed out. The machinery already exists (
sys_session.revoked_at/revoke_reason; revoking expires the session in place, better-auth returns nothing next request, the Console's 401 → login redirect handles it with no client change). ⛔ It is UX on top of a closed hole, never the wall.
What made the exposure worth closing this way
- 7 days by default (
auth-manager.ts:1626), and nothing shortens it on a membership change. - Reached through the product's own offboarding path — better-auth's
/organization/remove-member, driven by the org owner: 200, thesys_memberrow really deleted, and the removed user went on reading and writing that organization, the write read back from the store as theirs. - The resolver had the fact and did not act on it: positions went
["user","org_member"]→["user"]on the very request that was still served. - The framework's only tenant-claim-vs-membership comparison is
resolve-authz-context.ts:410, and it iskeyPrincipal-gated — one line, verified by grep against 59 files that merely mentionaccessible_org_ids.
State
needs-user-decision→pm:dispatched; assignee set.⚠️ domain:services→domain:engine: the repair lands inpackages/core/src/security, which the lane table puts in the engine family — provisional, set by this seat under the direct-dispatch channel, and triage may correct it.priority:p0·security·bugretained.Dispatching now.
- B is the control. A session whose
os-dev-report
{ "issue": 15409, "status": "done", "branch": "claude/issue-15409-session-unbacked-org-claim", "pr": "https://github.com/objectstack-ai/objectstack/pull/15794", "premise_still_valid": true, "summary": "Verified the card's diagnosis myself on origin/main before writing anything: resolve-authz-context.ts took the session's activeOrganizationId onto ctx.tenantId unread, and the exhaustiveness grep still returns exactly one accessible_org_ids membership comparison (line 410), opening on keyPrincipal, against 69 files that merely mention the key. Implemented option B: with no keyPrincipal, a wall-enforcing posture supplied, and the claim absent from grants.accessible_org_ids, the resolver warns once, drops ctx.tenantId, and RE-RESOLVES the grants envelope with no tenant. Re-resolved rather than field-edited so no tenant-scoped derivation survives the rejected claim -- org_user_ids would otherwise still enumerate the left organization's members for identity-table RLS. No second refusal mechanism: Layer 0's !organizationId RLS_DENY_FILTER covers reads and ADR-0123 D2's 403 covers writes, both pre-existing. API-key arm untouched, no session revocation, wire unchanged. Option C already has its own card, #15784 -- nothing to file.", "tests": "All at 4733750e3 with origin/main (901773b21) merged in. Package suites: pnpm --filter @objectstack/core --filter @objectstack/rest test -- core 49 files / 1201 tests, rest 182 files / 3112 tests, exit 0 captured before any pipe. Typecheck: both packages exit 0; both new/edited test files proven INSIDE their tsconfig.test.json programs with tsc --listFiles (1 hit each), so 'typecheck is clean' actually covers them; check:test-typecheck reports core 4 pinned ledger errors unchanged, rest 0. Lint: repo-wide pnpm lint (eslint . --no-inline-config) exit 0 in 24s -- NOT narrowed. Gates: node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack derived its own change set (4 paths, merge base 901773b21, no stale-tree warning); all 24 locally-runnable derived families exit 0, including check:authz-resolver whose gate source IS the edited file, plus check:nul-bytes, check:test-source-alias, check:where-matcher, check:engine-double-contract, check:cross-package-test-inputs, check:type-check-coverage. After a full turbo build over packages/* (71 tasks, 2m11s) the two build-dependent ones answered too: check:dual-build-cjs-loads exit 0 (its first run was PREREQUISITE NOT MET / exit 3 = NOT MEASURED, re-run properly afterwards) and check:type-check-debt exit 0 (12 entries re-measured, 140 raw errors, none above record). Also self-scanned the four changed files for raw C0/DEL bytes -- no match. ABLATION, both legs rebuilt (the REST suite consumes core through dist, so an unbuilt leg would be a false green): MUTATE = git checkout 6a3cc134c -- the resolver; on-disk blob 671316e6 equals the base blob 671316e6 and differs from the HEAD blob 9928f2cf; source marker occurrences 1 -> 0; ablation-dist-preflight --absent confirms the marker gone from all 12 built files; core vitest exit 1 (5 failed / 81 passed), rest vitest exit 1 (3 failed / 10 passed) with the measured leak back verbatim -- 'expected 2 to be +0' (the ex-member reads org_alpha again), 'expected 201 to be 403' (and writes into it again), 'expected [] to have a length of 1' (and nothing is said about it). RESTORE proven the same way: on-disk blob back to 9928f2cf = HEAD blob, marker count back to 1, git diff HEAD empty, preflight confirms the marker present in 2 built files and the whole tree clean, core 86/86 and rest 13/13 green. A trap ... EXIT INT TERM restore was armed throughout. NOT MEASURED, with reasons: (a) seven families the deriver itself marks NOT RUNNABLE LOCALLY because their argv comes from the workflow -- $RUNNER_TEMP, matrix.shard, $PROVENANCE -- namely 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 no local invocation exists and none was invented; (b) the enterprise arm -- org-scoping's only registrar is cloud-private, so this repo cannot speak for @objectstack/organizations' own refusals; (c) the kernel-bound hosted arm (OS_MULTI_TENANT) stays unmeasured exactly as the card and cloud#1982 carried it forward. RIG: neither bootStack posture-only/SimulatedOrgScopingPlugin nor the cloud-private plugin -- the pins run on the REST-level single-kernel harness that #15256's own repair is pinned with, which supplies the posture through the tenancy service provider, and the posture is all this guard reads; resolveExecCtx is not stubbed and Layer 0 is modelled as tenant-layer.ts writes it including the deny half. DECLARED NARROWING: scripts/pm/os-verify-lock.sh could not take the shared verify lock on this host (no usable flock; the lock is declared Linux-only and a stock macOS does not ship it), so every heavy command ran UNLOCKED -- declared, not silent; no serialization guarantee held for these runs.", "mcp_calls": "0 -- every GitHub read and write went through gh REST/CLI; no MCP GitHub tool was called.", "open_questions": [], "out_of_scope_findings": [] }
Generated by Claude Code
Landed. PR #15794 merged 2026-09-05T09:25:22Z; merge commit
d4f9b2a9d, confirmed onorigin/main— "fix(core): drop a session's unbacked organization claim under a wall-enforcing posture". This card closed as completed by itsFixesline.Ruled and shipped: under a wall-enforcing posture, a session whose
activeOrganizationIdis not ingrants.accessible_org_idsresolves with no active organization. Layer 0's existing!organizationId ⇒ RLS_DENY_FILTERcovers reads and ADR-0123 D2's 403 covers writes — ⛔ no second refusal mechanism was added. The API-key arm is untouched (verified: the only removed line in the whole diff isconst grants→let grants). One server-sidewarnnaming session / principal / organization / reason, carrying no token; the wire is unchanged.⭐ The dev found a layer the dispatch did not specify, and it mattered
The ruling said "drop the unbacked claim". Clearing
tenantIdalone would not have been enough: the grants envelope carriesorg_user_ids, the fellow-organization peer list Layer 1 uses to scope identity tables, and it would have gone on enumerating the organization the user had left. So the fix re-resolves the whole envelope with no tenant rather than editing a field, with the reason recorded beside it.⇒ A tenant-derived value surviving a rejected tenant claim is the same defect class one layer up. Worth carrying into the next card of this family: dropping a claim means dropping everything derived from it.
The ablation reproduced the original measurement verbatim
Both legs rebuilt — the REST suite consumes
corethroughdist, so an unbuilt leg would have been a false green — with the mutation confirmed on disk three ways (blob hash against base and HEAD, source marker count 1 → 0,ablation-dist-preflight --absentover all 12 built files). Restored to base, the three pins failed with exactly the leak this card was filed for:expected 2 to be +0— the ex-member readsorg_alphaagainexpected 201 to be 403— and writes into it againexpected [] to have a length of 1— and nothing is said about it
Restoration proved the same way; core 86/86 and rest 13/13 green after.
Rig: not the
SimulatedOrgScopingPluginstand-in the dispatch offered, but the REST-level single-kernel harness #15256's own repair is pinned with — the posture arrives through the tenancy service provider,resolveExecCtxis not stubbed, and Layer 0 is modelled astenant-layer.tswrites it, deny half included. Closer to the real path than what was asked for.Gates: 24/24 derived families green (including
check:authz-resolver, whose gate source is the edited file), plus two build-dependent ones measured after a full build;pnpm lintrun repo-wide, no narrowing claimed. Three populations left NOT MEASURED with reasons, and ⛔ none of them papered over: the seven workflow-valued families with no local invocation, the enterprise arm (org-scoping's only registrar is cloud-private), and thekernel-bound hosted arm — the last two carried forward as unknown, not safe, exactly as cloud#1982 left them.What this closes, and what it does not
This is the third and last of the cross-organization family to be closed: #15256 (API key, framework), cloud#1987 (the same on cloud's shipped arms, via the pin), and this one (session). All three shared one root — the wall compared against a claim nobody vetted.
⛔ Not closed by this: #15784, the courtesy half — an admin who clicks "Remove member" still expects that person signed out, and today they stay signed in with no active organization. It is unblocked by this landing and carries a product question that must be answered before implementation: what happens to a user who holds other memberships. ⛔ And it remains never the enforcement — a trigger can be missed, an evaluation cannot.
pm:dispatchedstripped in the same stroke as the close;bug,priority:p0,security,domain:engineretained.- added a commit that references this issue
on Sep 5, 2026 - added a commit that references this issue
on Sep 28, 2026
Measured on a real cloud rig. The hypothesis that would have made this a non-defect was driven on better-auth's own endpoint and returned a served ex-member. Full reading: #15396 comment 5541998221.
What was measured
A real
objectstack serveboot of cloud'sapps/objectos-ee— 44 plugins, the real cloud-privateOrganizations,Tenancy: isolated,SqlDriver(better-sqlite3)on a file database. A session cookie whoseactiveOrganizationIdpoints at an organization the user has left still:GET 200, andPOST 201, with the row read back out of the sqlite file, server stopped, carryingorganization_id: org_alphaandcreated_by:the ex-member.Both removal paths, not just the synthetic one:
sys_memberDELETE, session alive, no sign-out@org_alpha; POST 201/organization/remove-member, driven by the org owner (returned 200, row really gone)@org_alpha; POST 201⭐ The sharpest line, one principal, one run:
get-sessionreturned positions["user","org_member"]while the membership was intact and["user"]on the very next request after removal — while still servingorg_alpha's rows. The resolver had the ended-membership fact in hand and did not act on it, because the only code that acts on it is API-key-gated.Controls, four directions, before and after both removals: an intact member still read and wrote (the rig serves somebody); signed-out and a bogus cookie both 401 (it does not serve everybody); an
org_betamember saw exactly the two beta rows and never an alpha row (the wall does work when the stored claim matches a held membership). An earlier run lost two principals to better-auth's per-IP sign-in cap, making two controls vacuous; the probe was corrected to abort rather than run short, and only the complete run is reported.Why it happens — one line, verified by this seat on today's
origin/mainpackages/core/src/security/resolve-authz-context.tssets the session's tenant from a stored claim (tenantId = tenantId ?? sessionData?.session?.activeOrganizationId), and the guard that compares a tenant claim against real membership opens:Exhaustiveness measured, not assumed: a grep for a membership comparison over
accessible_org_idsacross all non-test, non-dist framework sources returns exactly one line — that one — against 59 files that merely mention the key. So it is the framework's only tenant-claim-vs-membership test, and it iskeyPrincipal-gated.better-auth does not vet it at the source (1.7.2, read at the rig's pin):
setActiveOrganizationwrites only through the session's own token, andremoveMemberrequiressession.user.id === toBeRemovedMember.userId— so an admin removing someone else clears nothing.plugin-auth'sdefaultActiveOrgcomposes intosession.create.before— create-time only. cloud'spackages/organizationsnever readsactiveOrganizationIdor the session at all.Scope
Enterprise surface, like #15256: the wall needs⚠️ The rig ran at cloud's current pin
org-scoping, whose only registrar is cloud-private.8a96e666, which does not contain #15365 — but that fix is irrelevant here: it supplies the posture, and this guard never runs for a session whether or not a posture is present. Verified on today's frameworkorigin/mainthat the gating is unchanged. Still unmeasured: the wiring wherekernelis bound (OS_MULTI_TENANT) — it refuses to boot ([driver-memory] Refusing to start … posture isolated), identical to cloud#1982's carried-forward paragraph. ⛔ Unknown, not safe.The fork (stated as facts about the code, per the measuring seat)
Line 410's guard already computes everything a session needs:
grants.accessible_org_idsis resolved for every principal, andctx.tenantIdis already set from the session claim. It is one condition away from covering sessions.authRefusal: organization_membership_ended). For a browser session that means being bounced to re-authenticate.四棱(业务立场;① 权重 ≥50%)
① 项目长远合理性 —— 指向 B,其次 A。 根因和 #15256 是同一个:墙比对的是一份没人核验的自述。ADR-0131 D8 要的是「一道谓词、算一次」,而这里连「这个组织你还在不在」都不是这一层的输入。B 把没有依据的那格去掉,让墙回到它本来的语义(没有活动组织 ⇒ 什么都不给);A 是把 API key 那条臂原样复制过来。两者都消灭自述,B 概念更少——它不新增拒绝,只是不再相信一个立不住的值。⛔ C 被代码自己的注释否掉:漏掉任何一条移除路径就静默失效。
② 实际业务拉动 —— 比 #15256 大一个量级。 #15256 要一把长期存活的 API key;这一条只要一个还没过期的浏览器会话。而且实测证明走正规离职流程也不生效:管理员用 better-auth 自己的「移除成员」按钮把人踢掉,返回 200、行真的删了,被踢的人继续读写那个组织。这是每一个上墙部署的日常操作。
③ 防 AI 写元数据犯错 —— 中性。 与元数据无关。但 B 有一个副作用值得记:会话解析出「无活动组织」时 Layer 0 已经是 fail-closed(
!organizationId ⇒ RLS_DENY_FILTER,代码原样如此),所以 B 不需要新的拒绝机制就能关门。④ 创业阶段不扩散 —— 支持 B。 B 改一处条件、复用既有的 fail-closed 分支,不新增导出面、不新增拒绝类型、不碰会话生命周期。A 次之(同样一处条件,但把「踢出登录」这个产品行为引进来)。C 最贵且最脆。
PM 建议:B,理由是它在同样 fail-closed 的前提下概念最少
选 B 的额外理由是产品语义:API key 就是它那条组织绑定,所以拒绝整个凭据是对的;而会话是一个人,他可能同时属于别的组织——A 会把一个在别处完全合法的用户整个踢下线。B 让他留在登录态、只是没有一个立不住的活动组织,再自己切到真属于他的组织。
⛔ 但这是对 #15256 那次裁决的延伸,是您的决定,不是我的。测量已经settle 的一点是:这不是「修不修」的选择,而是修成什么形状——能让它不算缺陷的那个假设(better-auth 在源头核验)已经用它自己的接口驱动过,返回的是一个被服务的前成员。
Refs: #15396(测量全文)· #15256 / PR #15365(API key 那条臂及其裁决)· cloud#1982 · cloud#1987(pin bump,在飞)· ADR-0105 D2/D3 · ADR-0131 D8.