Skip to content

[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

@hotlong

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, carrying organization_id: org_alpha and created_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. ⚠️ The rig ran at cloud's current pin 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

  1. os-warren commented on Sep 4, 2026

    @os-warren
    Collaborator

    Maintainer ruling recorded — B: a session whose activeOrganizationId is not backed by a held membership resolves with no active organization; the principal is not refused, the unbacked claim is dropped

    Director seat, summon #14, session session_01LsEjuNMPitCHwEfYftZ1um (GitHub os-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 single accessible_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 (tenantId unset) 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:services lane, 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-session positions and the resolved context stay consistent. ⚠️ The OS_MULTI_TENANT kernel-bound wiring is still unmeasured (refuses to boot on driver-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-review on the PR, review at CONTRACT_REVIEW_TIER before ready. Changeset: @objectstack/core minor with a BREAKING-behaviour note (a session carrying a stale activeOrganizationId no 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:services unchanged. Ledger: director seat post #12708, batch #38.


    Generated by Claude Code

  2. hotlong commented on Sep 5, 2026

    @hotlong
    ContributorAuthor

    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 on origin/main today; ⛔ 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_ids across 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_reason are 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:8041 already writes revoked_at.

    ⚠️ But every existing reason is a timer: the field's own description enumerates 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

    • B is the control: a session whose activeOrganizationId is 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.

  3. hotlong commented on Sep 5, 2026

    @hotlong
    ContributorAuthor

    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 activeOrganizationId is 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, the sys_member row 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 is keyPrincipal-gated — one line, verified by grep against 59 files that merely mention accessible_org_ids.

    State

    needs-user-decision → pm:dispatched; assignee set. ⚠️ domain:services → domain:engine: the repair lands in packages/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 · bug retained.

    Dispatching now.

  4. self-assigned this
    on Sep 5, 2026
  5. hotlong commented on Sep 5, 2026

    @hotlong
    ContributorAuthor

    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

  6. hotlong commented on Sep 5, 2026

    @hotlong
    ContributorAuthor

    Landed. PR #15794 merged 2026-09-05T09:25:22Z; merge commit d4f9b2a9d, confirmed on origin/main — "fix(core): drop a session's unbacked organization claim under a wall-enforcing posture". This card closed as completed by its Fixes line.

    Ruled and shipped: under a wall-enforcing posture, a session whose activeOrganizationId is not in grants.accessible_org_ids resolves with no active organization. Layer 0's existing !organizationId ⇒ RLS_DENY_FILTER covers 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 is const grants → let grants). One server-side warn naming 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 tenantId alone would not have been enough: the grants envelope carries org_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 core through dist, 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 --absent over 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 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

    Restoration proved the same way; core 86/86 and rest 13/13 green after.

    Rig: not the SimulatedOrgScopingPlugin stand-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, resolveExecCtx is not stubbed, and Layer 0 is modelled as tenant-layer.ts writes 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 lint run 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 the kernel-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:dispatched stripped in the same stroke as the close; bug, priority:p0, security, domain:engine retained.

  7. removed their assignment
    on Sep 5, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions