@@ -1543,3 +1543,243 @@ describe('the in-memory ObjectQL double honours `limit` (#10978)', () => {
15431543 expect ( found . every ( ( r : any ) => r . organization_id === 'o1' ) ) . toBe ( true ) ;
15441544 } ) ;
15451545} ) ;
1546+
1547+
1548+ // ── [#15409] The SESSION arm of the organization claim ─────────────────────
1549+
1550+ /**
1551+ * [#15409 — maintainer ruling 2026-09-05, option B] A browser session whose
1552+ * `activeOrganizationId` names an organization its owner has LEFT.
1553+ *
1554+ * ## What was measured, before the guard existed
1555+ *
1556+ * On a live `isolated` boot with the real cloud-private `Organizations` plugin
1557+ * and a file-backed sqlite store, a session whose owner had been removed
1558+ * through better-auth's OWN `/organization/remove-member` — driven by the org
1559+ * owner, 200, the `sys_member` row really deleted — went on READING that
1560+ * organization's rows (`GET 200`, total 4, every row `@org_alpha`) and WRITING
1561+ * into it (`POST 201`), the written row read back out of the sqlite file with
1562+ * the server stopped carrying `organization_id: org_alpha` and the removed
1563+ * user as `created_by`. Nothing revoked, nothing expired: `revoked_at` was
1564+ * still `null` and the default session lifetime is 7 days.
1565+ *
1566+ * The resolver already HELD the fact. `get-session` returned positions
1567+ * `["user","org_member"]` while the membership was intact and `["user"]` on
1568+ * the very next request — off the same `sys_member` read that builds
1569+ * `accessible_org_ids` — while still serving that organization's rows. The
1570+ * only tenant-claim-vs-membership comparison in the framework was
1571+ * `keyPrincipal`-gated, so a session never reached it.
1572+ *
1573+ * ## What is pinned here, and what is deliberately NOT
1574+ *
1575+ * Option B, ruled: the claim is DROPPED, the principal is NOT refused. So the
1576+ * assertions below are about `ctx.tenantId` going away while `ctx.userId`
1577+ * STAYS — the one pair that would go red if someone later "simplified" B into
1578+ * the API-key arm's option A. Refusing the whole credential is right for an API
1579+ * key, which IS its organization binding; a session is a person who may hold
1580+ * legitimate memberships elsewhere.
1581+ *
1582+ * ⛔ No new refusal mechanism is pinned because none was added: with no active
1583+ * organization, Layer 0 already fails closed — `!organizationId ⇒
1584+ * RLS_DENY_FILTER` in `plugin-security/src/tenant-layer.ts` for reads, and
1585+ * ADR-0123 D2's write refusal for writes. Those two are pinned where they live.
1586+ * The end-to-end consequence — reads nothing, write does not land, read back
1587+ * from the STORE — is pinned in
1588+ * `packages/rest/src/single-kernel-isolated-session-org-claim-matrix.test.ts`.
1589+ */
1590+ describe ( '[#15409] a session organization claim that no membership backs' , ( ) => {
1591+ /** The `sys_session` token — the credential. It must never reach a log line. */
1592+ const SESSION_TOKEN = 'sess_token_never_log_me' ;
1593+
1594+ /**
1595+ * A session as better-auth returns it: the ROW `id` and the `token` are
1596+ * separate columns, which is why the log line can name one and never the
1597+ * other.
1598+ */
1599+ const sessionAs = ( userId : string , org : string | null , id = 'ses_probe' ) =>
1600+ async ( ) => ( {
1601+ user : { id : userId , email : `${ userId } @x.com` } ,
1602+ session : { id, token : SESSION_TOKEN , userId, activeOrganizationId : org } ,
1603+ } ) ;
1604+
1605+ /**
1606+ * `u_ex` was removed from `org_alpha` and is still a member of `org_beta` —
1607+ * the shape that makes "still authenticated elsewhere" measurable rather than
1608+ * asserted. `u_ex2` is a fellow member of `org_alpha`, seeded so the
1609+ * fellow-org peer list has something to leak if the drop is incomplete.
1610+ */
1611+ const tables = ( ) => ( {
1612+ sys_user : [ { id : 'u_ex' , email : 'u_ex@x.com' } , { id : 'u_ex2' , email : 'u_ex2@x.com' } ] ,
1613+ sys_member : [
1614+ { user_id : 'u_ex' , organization_id : 'org_beta' , role : 'member' } ,
1615+ { user_id : 'u_ex2' , organization_id : 'org_alpha' , role : 'member' } ,
1616+ ] ,
1617+ sys_user_position : [ ] ,
1618+ sys_user_permission_set : [ ] ,
1619+ } ) ;
1620+
1621+ let warnSpy : ReturnType < typeof vi . spyOn > ;
1622+ beforeEach ( ( ) => { warnSpy = vi . spyOn ( console , 'warn' ) . mockImplementation ( ( ) => { } ) ; } ) ;
1623+ afterEach ( ( ) => { warnSpy . mockRestore ( ) ; } ) ;
1624+ const dropLines = ( ) =>
1625+ warnSpy . mock . calls
1626+ . map ( ( c : unknown [ ] ) => c . map ( String ) . join ( ' ' ) )
1627+ . filter ( ( l : string ) => l . includes ( 'Session organization claim dropped' ) ) ;
1628+
1629+ it ( 'CONTROL · a BACKED claim is adopted untouched — the guard is not a blanket' , async ( ) => {
1630+ const ql = makeQl ( tables ( ) ) ;
1631+ const ctx = await resolveAuthzContext ( {
1632+ ql, headers : H ( ) , getSession : sessionAs ( 'u_ex' , 'org_beta' ) , tenancyPosture : 'isolated' ,
1633+ } ) ;
1634+ expect ( ctx . userId ) . toBe ( 'u_ex' ) ;
1635+ expect ( ctx . tenantId ) . toBe ( 'org_beta' ) ;
1636+ expect ( dropLines ( ) ) . toHaveLength ( 0 ) ;
1637+ } ) ;
1638+
1639+ it ( 'THE SUBJECT: an UNBACKED claim resolves with NO active organization' , async ( ) => {
1640+ const ql = makeQl ( tables ( ) ) ;
1641+ const ctx = await resolveAuthzContext ( {
1642+ ql, headers : H ( ) , getSession : sessionAs ( 'u_ex' , 'org_alpha' ) , tenancyPosture : 'isolated' ,
1643+ } ) ;
1644+ expect ( ctx . tenantId ) . toBeUndefined ( ) ;
1645+ // …and the key is GONE, not present-and-undefined: a downstream
1646+ // `'tenantId' in ctx` must read the same as a session that never picked one.
1647+ expect ( Object . prototype . hasOwnProperty . call ( ctx , 'tenantId' ) ) . toBe ( false ) ;
1648+ } ) ;
1649+
1650+ /**
1651+ * ⭐ B, NOT A. The assertion that goes red if the session arm is ever
1652+ * "simplified" into the API-key arm's refusal: the principal is still
1653+ * authenticated, still carries its own identity and its real membership set.
1654+ */
1655+ it ( 'B, not A: the PRINCIPAL survives — still authenticated, still a member where it really is' , async ( ) => {
1656+ const ql = makeQl ( tables ( ) ) ;
1657+ const ctx = await resolveAuthzContext ( {
1658+ ql, headers : H ( ) , getSession : sessionAs ( 'u_ex' , 'org_alpha' ) , tenancyPosture : 'isolated' ,
1659+ } ) ;
1660+ expect ( ctx . userId ) . toBe ( 'u_ex' ) ;
1661+ expect ( ctx . email ) . toBe ( 'u_ex@x.com' ) ;
1662+ // The organization they ARE in is still reachable — this is what lets them
1663+ // switch to it instead of being signed out of everything.
1664+ expect ( ctx . accessible_org_ids ) . toEqual ( [ 'org_beta' ] ) ;
1665+ // ⛔ And it is NOT the API-key refusal: no principal was refused.
1666+ expect ( ctx . authRefusal ) . toBeUndefined ( ) ;
1667+ } ) ;
1668+
1669+ /**
1670+ * The drop is not a field edit — the envelope is RE-RESOLVED with no tenant.
1671+ * `org_user_ids` is the one that would give it away: computed under the
1672+ * rejected claim it enumerates the members of the organization the user left,
1673+ * and Layer 1 scopes identity tables with it.
1674+ */
1675+ it ( 'the envelope carries no residue of the rejected claim — the fellow-org peer list is not the left org\'s' , async ( ) => {
1676+ const ql = makeQl ( tables ( ) ) ;
1677+ const ctx = await resolveAuthzContext ( {
1678+ ql, headers : H ( ) , getSession : sessionAs ( 'u_ex' , 'org_alpha' ) , tenancyPosture : 'isolated' ,
1679+ } ) ;
1680+ expect ( ctx . org_user_ids ) . toEqual ( [ 'u_ex' ] ) ;
1681+ expect ( ctx . org_user_ids ) . not . toContain ( 'u_ex2' ) ;
1682+ } ) ;
1683+
1684+ it ( 'the same claim under `group` is dropped too — the wall is membership-derived there as well' , async ( ) => {
1685+ const ql = makeQl ( tables ( ) ) ;
1686+ const ctx = await resolveAuthzContext ( {
1687+ ql, headers : H ( ) , getSession : sessionAs ( 'u_ex' , 'org_alpha' ) , tenancyPosture : 'group' ,
1688+ } ) ;
1689+ expect ( ctx . userId ) . toBe ( 'u_ex' ) ;
1690+ expect ( ctx . tenantId ) . toBeUndefined ( ) ;
1691+ expect ( dropLines ( ) ) . toHaveLength ( 1 ) ;
1692+ } ) ;
1693+
1694+ /**
1695+ * Under `single` there is no organization boundary to cross, and a deployment
1696+ * with no membership rows at all would otherwise lose every session's active
1697+ * organization. Same scoping the API-key arm carries, same reason.
1698+ */
1699+ it ( 'does NOT apply under `single` — no wall, nothing to justify a claim against' , async ( ) => {
1700+ const ql = makeQl ( tables ( ) ) ;
1701+ const ctx = await resolveAuthzContext ( {
1702+ ql, headers : H ( ) , getSession : sessionAs ( 'u_ex' , 'org_alpha' ) , tenancyPosture : 'single' ,
1703+ } ) ;
1704+ expect ( ctx . tenantId ) . toBe ( 'org_alpha' ) ;
1705+ expect ( dropLines ( ) ) . toHaveLength ( 0 ) ;
1706+ } ) ;
1707+
1708+ /**
1709+ * An unwired caller is never made WORSE, only less strict — the doctrine
1710+ * `ResolveAuthzInput.tenancyPosture` already states for the two API-key
1711+ * refusals. A transport that supplies no posture keeps its old behaviour.
1712+ */
1713+ it ( 'does NOT apply when the transport supplies no posture' , async ( ) => {
1714+ const ql = makeQl ( tables ( ) ) ;
1715+ const ctx = await resolveAuthzContext ( {
1716+ ql, headers : H ( ) , getSession : sessionAs ( 'u_ex' , 'org_alpha' ) ,
1717+ } ) ;
1718+ expect ( ctx . tenantId ) . toBe ( 'org_alpha' ) ;
1719+ expect ( dropLines ( ) ) . toHaveLength ( 0 ) ;
1720+ } ) ;
1721+
1722+ it ( 'a session with NO claim at all is not a drop — nothing was claimed' , async ( ) => {
1723+ const ql = makeQl ( tables ( ) ) ;
1724+ const ctx = await resolveAuthzContext ( {
1725+ ql, headers : H ( ) , getSession : sessionAs ( 'u_ex' , null ) , tenancyPosture : 'isolated' ,
1726+ } ) ;
1727+ expect ( ctx . userId ) . toBe ( 'u_ex' ) ;
1728+ expect ( ctx . tenantId ) . toBeUndefined ( ) ;
1729+ expect ( dropLines ( ) ) . toHaveLength ( 0 ) ;
1730+ } ) ;
1731+
1732+ it ( 'a membership whose ADR-0091 window has lapsed does not back a claim' , async ( ) => {
1733+ const ql = makeQl ( {
1734+ ...tables ( ) ,
1735+ sys_member : [ { user_id : 'u_ex' , organization_id : 'org_alpha' , role : 'member' , valid_until : '2000-01-01T00:00:00Z' } ] ,
1736+ } ) ;
1737+ const ctx = await resolveAuthzContext ( {
1738+ ql, headers : H ( ) , getSession : sessionAs ( 'u_ex' , 'org_alpha' ) , tenancyPosture : 'isolated' ,
1739+ } ) ;
1740+ expect ( ctx . userId ) . toBe ( 'u_ex' ) ;
1741+ expect ( ctx . tenantId ) . toBeUndefined ( ) ;
1742+ expect ( dropLines ( ) ) . toHaveLength ( 1 ) ;
1743+ } ) ;
1744+
1745+ /**
1746+ * [#15256 / 2A, mirrored] The operator's only exit. The wire is unchanged, so
1747+ * without this line an offboarded member's live session naming the
1748+ * organization they left is invisible to everyone.
1749+ */
1750+ it ( 'says it OUT LOUD, once, naming session / principal / organization / reason — and ⛔ never the token' , async ( ) => {
1751+ const ql = makeQl ( tables ( ) ) ;
1752+ await resolveAuthzContext ( {
1753+ ql, headers : H ( ) , getSession : sessionAs ( 'u_ex' , 'org_alpha' , 'ses_victim' ) , tenancyPosture : 'isolated' ,
1754+ } ) ;
1755+ const lines = dropLines ( ) ;
1756+ expect ( lines ) . toHaveLength ( 1 ) ;
1757+ expect ( lines [ 0 ] ) . toContain ( 'organization_membership_ended' ) ;
1758+ expect ( lines [ 0 ] ) . toContain ( 'session=ses_victim' ) ;
1759+ expect ( lines [ 0 ] ) . toContain ( 'principal=u_ex' ) ;
1760+ expect ( lines [ 0 ] ) . toContain ( 'organization=org_alpha' ) ;
1761+ // ⛔ THE CREDENTIAL. `sys_session.token` is replay-proven impersonation —
1762+ // its own field comment says so. It must never appear in a log line.
1763+ expect ( lines [ 0 ] ) . not . toContain ( SESSION_TOKEN ) ;
1764+ } ) ;
1765+
1766+ /**
1767+ * The API-key arm is UNCHANGED (#15256 decision 1A stands): a key IS its
1768+ * organization binding, so it keeps refusing the principal outright. The
1769+ * session line must not fire for it — one credential, one decision point.
1770+ */
1771+ it ( 'the API-key arm is untouched: an ex-member KEY is still refused, and says so in ITS own words' , async ( ) => {
1772+ const raw = 'osk_15409_exmember' ;
1773+ const ql = makeQl ( {
1774+ ...tables ( ) ,
1775+ sys_api_key : [ { id : 'key_ex' , key : hashApiKey ( raw ) , revoked : false , user_id : 'u_ex' , active_organization_id : 'org_alpha' } ] ,
1776+ } ) ;
1777+ const ctx = await resolveAuthzContext ( {
1778+ ql, headers : { 'x-api-key' : raw } , tenancyPosture : 'isolated' ,
1779+ } ) ;
1780+ expect ( ctx . userId ) . toBeUndefined ( ) ;
1781+ expect ( ctx . authRefusal ?. reason ) . toBe ( 'organization_membership_ended' ) ;
1782+ // ⛔ Not degraded into the session's drop: the key is REFUSED, not trimmed.
1783+ expect ( dropLines ( ) ) . toHaveLength ( 0 ) ;
1784+ } ) ;
1785+ } ) ;
0 commit comments