Repository navigation
Description
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 serve boot of cloud's apps/objectos-ee — 44 plugins, the real cloud-private Organizations, Tenancy: isolated, SqlDriver(better-sqlite3) on a file database. A session cookie whose activeOrganizationId points at an organization the user has left still:
- reads that organization's rows —
GET 200, and - writes into it —
POST 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:
| removal path | result |
|---|---|
direct sys_member DELETE, session alive, no sign-out |
served: GET 200 total=3 all @org_alpha; POST 201 |
better-auth's own /organization/remove-member, driven by the org owner (returned 200, row really gone) |
served: GET 200 total=4 all @org_alpha; POST 201 |
⭐ The sharpest line, one principal, one run: get-session returned positions ["user","org_member"] while the membership was intact and ["user"] on the very next request after removal — while still serving org_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_beta member 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/main
packages/core/src/security/resolve-authz-context.ts sets 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:
if (postureEnforcesWall(posture) && !grants.accessible_org_ids.includes(keyPrincipal.tenantId))
Exhaustiveness measured, not assumed: a grep for a membership comparison over accessible_org_ids across 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 is keyPrincipal-gated.
better-auth does not vet it at the source (1.7.2, read at the rig's pin): setActiveOrganization writes only through the session's own token, and removeMember requires session.user.id === toBeRemovedMember.userId — so an admin removing someone else clears nothing. plugin-auth's defaultActiveOrg composes into session.create.before — create-time only. cloud's packages/organizations never reads activeOrganizationId or the session at all.
Scope
Enterprise surface, like #15256: the wall needs 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 framework origin/main that the gating is unchanged. Still unmeasured: the wiring where kernel is 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_ids is resolved for every principal, and ctx.tenantId is already set from the session claim. It is one condition away from covering sessions.
- A — refuse the principal outright, as the API-key arm does (empty context,
authRefusal: organization_membership_ended). For a browser session that means being bounced to re-authenticate. - B — do not refuse the principal; drop the unbacked claim, so the session resolves with no active organization rather than one it cannot justify.
- C — refuse at the session layer: revoke or re-point sessions when a membership ends.
⚠️ The code's own comment at that guard already argues against this shape: it must catch every removal path or it silently misses one.
四棱(业务立场;① 权重 ≥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.
Activity
os-warren commentedon 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:⚠️ The
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.OS_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
hotlong commentedon Sep 5, 2026
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.idle_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
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.hotlong commentedon Sep 5, 2026
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
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.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.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
auth-manager.ts:1626), and nothing shortens it on a membership change./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.["user","org_member"]→["user"]on the very request that was still served.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.
fix(core): drop a session's unbacked organization claim under a walle…
test(core,rest): pin the session organization-claim drop end to end (#…
hotlong commentedon Sep 5, 2026
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
hotlong commentedon Sep 5, 2026
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 itRestoration 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.test(plugin-sharing): the share-link admission matrix, its permanent …
fix(core): drop a session's unbacked organization claim under a wall-…
fix(plugin-sharing): the share-link admission seam derives the tenanc…
fix(runtime): the dispatcher's organization source reads the vetted e…
fix(runtime): the dispatcher's /packages doors scope to the vetted or…
fix(runtime): DELETE /packages/:id refuses an org-less uninstall befo…
fix(plugin-security,core): security/explain resolves the user it expl…