diff --git a/.changeset/session-unbacked-org-claim-dropped.md b/.changeset/session-unbacked-org-claim-dropped.md new file mode 100644 index 0000000000..43502e0f68 --- /dev/null +++ b/.changeset/session-unbacked-org-claim-dropped.md @@ -0,0 +1,9 @@ +--- +'@objectstack/core': patch +--- + +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. + +Under a wall-enforcing tenancy posture (`isolated` / `group`), `resolveAuthzContext` took a browser session's stored `activeOrganizationId` as the request tenant without ever comparing it to the user's current memberships — the framework's only such comparison was gated on an API-key principal. A session whose owner had been removed from an organization therefore kept reading that organization's rows and writing into it until the session expired on its own (7 days by default), including when the removal went through the product's own offboarding path. + +That claim is now vetted: if it is not in the caller's `accessible_org_ids`, it is dropped and the context resolves with no active organization at all, which the tenant wall already fails closed on (reads resolve to nothing; a tenant-scoped write is refused by ADR-0123 D2). The principal is **not** refused — a session is a person who may hold memberships elsewhere, so they stay signed in and can switch to an organization they are actually in. The API-key arm is unchanged: a key is its organization binding and is still refused outright. The wire is unchanged; the drop is reported to the operator as a single server-side `warn`. diff --git a/packages/core/src/security/resolve-authz-context.test.ts b/packages/core/src/security/resolve-authz-context.test.ts index 7f544cd0d2..50186f8f67 100644 --- a/packages/core/src/security/resolve-authz-context.test.ts +++ b/packages/core/src/security/resolve-authz-context.test.ts @@ -1543,3 +1543,243 @@ describe('the in-memory ObjectQL double honours `limit` (#10978)', () => { expect(found.every((r: any) => r.organization_id === 'o1')).toBe(true); }); }); + + +// ── [#15409] The SESSION arm of the organization claim ───────────────────── + +/** + * [#15409 — maintainer ruling 2026-09-05, option B] A browser session whose + * `activeOrganizationId` names an organization its owner has LEFT. + * + * ## What was measured, before the guard existed + * + * On a live `isolated` boot with the real cloud-private `Organizations` plugin + * and a file-backed sqlite store, a session whose owner had been removed + * through better-auth's OWN `/organization/remove-member` — driven by the org + * owner, 200, the `sys_member` row really deleted — went on READING that + * organization's rows (`GET 200`, total 4, every row `@org_alpha`) and WRITING + * into it (`POST 201`), the written row read back out of the sqlite file with + * the server stopped carrying `organization_id: org_alpha` and the removed + * user as `created_by`. Nothing revoked, nothing expired: `revoked_at` was + * still `null` and the default session lifetime is 7 days. + * + * The resolver already HELD the fact. `get-session` returned positions + * `["user","org_member"]` while the membership was intact and `["user"]` on + * the very next request — off the same `sys_member` read that builds + * `accessible_org_ids` — while still serving that organization's rows. The + * only tenant-claim-vs-membership comparison in the framework was + * `keyPrincipal`-gated, so a session never reached it. + * + * ## What is pinned here, and what is deliberately NOT + * + * Option B, ruled: the claim is DROPPED, the principal is NOT refused. So the + * assertions below are about `ctx.tenantId` going away while `ctx.userId` + * STAYS — the one pair that would go red if someone later "simplified" B into + * the API-key arm's option A. Refusing the whole credential is right for an API + * key, which IS its organization binding; a session is a person who may hold + * legitimate memberships elsewhere. + * + * ⛔ No new refusal mechanism is pinned because none was added: with no active + * organization, Layer 0 already fails closed — `!organizationId ⇒ + * RLS_DENY_FILTER` in `plugin-security/src/tenant-layer.ts` for reads, and + * ADR-0123 D2's write refusal for writes. Those two are pinned where they live. + * The end-to-end consequence — reads nothing, write does not land, read back + * from the STORE — is pinned in + * `packages/rest/src/single-kernel-isolated-session-org-claim-matrix.test.ts`. + */ +describe('[#15409] a session organization claim that no membership backs', () => { + /** The `sys_session` token — the credential. It must never reach a log line. */ + const SESSION_TOKEN = 'sess_token_never_log_me'; + + /** + * A session as better-auth returns it: the ROW `id` and the `token` are + * separate columns, which is why the log line can name one and never the + * other. + */ + const sessionAs = (userId: string, org: string | null, id = 'ses_probe') => + async () => ({ + user: { id: userId, email: `${userId}@x.com` }, + session: { id, token: SESSION_TOKEN, userId, activeOrganizationId: org }, + }); + + /** + * `u_ex` was removed from `org_alpha` and is still a member of `org_beta` — + * the shape that makes "still authenticated elsewhere" measurable rather than + * asserted. `u_ex2` is a fellow member of `org_alpha`, seeded so the + * fellow-org peer list has something to leak if the drop is incomplete. + */ + const tables = () => ({ + sys_user: [{ id: 'u_ex', email: 'u_ex@x.com' }, { id: 'u_ex2', email: 'u_ex2@x.com' }], + sys_member: [ + { user_id: 'u_ex', organization_id: 'org_beta', role: 'member' }, + { user_id: 'u_ex2', organization_id: 'org_alpha', role: 'member' }, + ], + sys_user_position: [], + sys_user_permission_set: [], + }); + + let warnSpy: ReturnType; + beforeEach(() => { warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); }); + afterEach(() => { warnSpy.mockRestore(); }); + const dropLines = () => + warnSpy.mock.calls + .map((c: unknown[]) => c.map(String).join(' ')) + .filter((l: string) => l.includes('Session organization claim dropped')); + + it('CONTROL · a BACKED claim is adopted untouched — the guard is not a blanket', async () => { + const ql = makeQl(tables()); + const ctx = await resolveAuthzContext({ + ql, headers: H(), getSession: sessionAs('u_ex', 'org_beta'), tenancyPosture: 'isolated', + }); + expect(ctx.userId).toBe('u_ex'); + expect(ctx.tenantId).toBe('org_beta'); + expect(dropLines()).toHaveLength(0); + }); + + it('THE SUBJECT: an UNBACKED claim resolves with NO active organization', async () => { + const ql = makeQl(tables()); + const ctx = await resolveAuthzContext({ + ql, headers: H(), getSession: sessionAs('u_ex', 'org_alpha'), tenancyPosture: 'isolated', + }); + expect(ctx.tenantId).toBeUndefined(); + // …and the key is GONE, not present-and-undefined: a downstream + // `'tenantId' in ctx` must read the same as a session that never picked one. + expect(Object.prototype.hasOwnProperty.call(ctx, 'tenantId')).toBe(false); + }); + + /** + * ⭐ B, NOT A. The assertion that goes red if the session arm is ever + * "simplified" into the API-key arm's refusal: the principal is still + * authenticated, still carries its own identity and its real membership set. + */ + it('B, not A: the PRINCIPAL survives — still authenticated, still a member where it really is', async () => { + const ql = makeQl(tables()); + const ctx = await resolveAuthzContext({ + ql, headers: H(), getSession: sessionAs('u_ex', 'org_alpha'), tenancyPosture: 'isolated', + }); + expect(ctx.userId).toBe('u_ex'); + expect(ctx.email).toBe('u_ex@x.com'); + // The organization they ARE in is still reachable — this is what lets them + // switch to it instead of being signed out of everything. + expect(ctx.accessible_org_ids).toEqual(['org_beta']); + // ⛔ And it is NOT the API-key refusal: no principal was refused. + expect(ctx.authRefusal).toBeUndefined(); + }); + + /** + * The drop is not a field edit — the envelope is RE-RESOLVED with no tenant. + * `org_user_ids` is the one that would give it away: computed under the + * rejected claim it enumerates the members of the organization the user left, + * and Layer 1 scopes identity tables with it. + */ + it('the envelope carries no residue of the rejected claim — the fellow-org peer list is not the left org\'s', async () => { + const ql = makeQl(tables()); + const ctx = await resolveAuthzContext({ + ql, headers: H(), getSession: sessionAs('u_ex', 'org_alpha'), tenancyPosture: 'isolated', + }); + expect(ctx.org_user_ids).toEqual(['u_ex']); + expect(ctx.org_user_ids).not.toContain('u_ex2'); + }); + + it('the same claim under `group` is dropped too — the wall is membership-derived there as well', async () => { + const ql = makeQl(tables()); + const ctx = await resolveAuthzContext({ + ql, headers: H(), getSession: sessionAs('u_ex', 'org_alpha'), tenancyPosture: 'group', + }); + expect(ctx.userId).toBe('u_ex'); + expect(ctx.tenantId).toBeUndefined(); + expect(dropLines()).toHaveLength(1); + }); + + /** + * Under `single` there is no organization boundary to cross, and a deployment + * with no membership rows at all would otherwise lose every session's active + * organization. Same scoping the API-key arm carries, same reason. + */ + it('does NOT apply under `single` — no wall, nothing to justify a claim against', async () => { + const ql = makeQl(tables()); + const ctx = await resolveAuthzContext({ + ql, headers: H(), getSession: sessionAs('u_ex', 'org_alpha'), tenancyPosture: 'single', + }); + expect(ctx.tenantId).toBe('org_alpha'); + expect(dropLines()).toHaveLength(0); + }); + + /** + * An unwired caller is never made WORSE, only less strict — the doctrine + * `ResolveAuthzInput.tenancyPosture` already states for the two API-key + * refusals. A transport that supplies no posture keeps its old behaviour. + */ + it('does NOT apply when the transport supplies no posture', async () => { + const ql = makeQl(tables()); + const ctx = await resolveAuthzContext({ + ql, headers: H(), getSession: sessionAs('u_ex', 'org_alpha'), + }); + expect(ctx.tenantId).toBe('org_alpha'); + expect(dropLines()).toHaveLength(0); + }); + + it('a session with NO claim at all is not a drop — nothing was claimed', async () => { + const ql = makeQl(tables()); + const ctx = await resolveAuthzContext({ + ql, headers: H(), getSession: sessionAs('u_ex', null), tenancyPosture: 'isolated', + }); + expect(ctx.userId).toBe('u_ex'); + expect(ctx.tenantId).toBeUndefined(); + expect(dropLines()).toHaveLength(0); + }); + + it('a membership whose ADR-0091 window has lapsed does not back a claim', async () => { + const ql = makeQl({ + ...tables(), + sys_member: [{ user_id: 'u_ex', organization_id: 'org_alpha', role: 'member', valid_until: '2000-01-01T00:00:00Z' }], + }); + const ctx = await resolveAuthzContext({ + ql, headers: H(), getSession: sessionAs('u_ex', 'org_alpha'), tenancyPosture: 'isolated', + }); + expect(ctx.userId).toBe('u_ex'); + expect(ctx.tenantId).toBeUndefined(); + expect(dropLines()).toHaveLength(1); + }); + + /** + * [#15256 / 2A, mirrored] The operator's only exit. The wire is unchanged, so + * without this line an offboarded member's live session naming the + * organization they left is invisible to everyone. + */ + it('says it OUT LOUD, once, naming session / principal / organization / reason — and ⛔ never the token', async () => { + const ql = makeQl(tables()); + await resolveAuthzContext({ + ql, headers: H(), getSession: sessionAs('u_ex', 'org_alpha', 'ses_victim'), tenancyPosture: 'isolated', + }); + const lines = dropLines(); + expect(lines).toHaveLength(1); + expect(lines[0]).toContain('organization_membership_ended'); + expect(lines[0]).toContain('session=ses_victim'); + expect(lines[0]).toContain('principal=u_ex'); + expect(lines[0]).toContain('organization=org_alpha'); + // ⛔ THE CREDENTIAL. `sys_session.token` is replay-proven impersonation — + // its own field comment says so. It must never appear in a log line. + expect(lines[0]).not.toContain(SESSION_TOKEN); + }); + + /** + * The API-key arm is UNCHANGED (#15256 decision 1A stands): a key IS its + * organization binding, so it keeps refusing the principal outright. The + * session line must not fire for it — one credential, one decision point. + */ + it('the API-key arm is untouched: an ex-member KEY is still refused, and says so in ITS own words', async () => { + const raw = 'osk_15409_exmember'; + const ql = makeQl({ + ...tables(), + sys_api_key: [{ id: 'key_ex', key: hashApiKey(raw), revoked: false, user_id: 'u_ex', active_organization_id: 'org_alpha' }], + }); + const ctx = await resolveAuthzContext({ + ql, headers: { 'x-api-key': raw }, tenancyPosture: 'isolated', + }); + expect(ctx.userId).toBeUndefined(); + expect(ctx.authRefusal?.reason).toBe('organization_membership_ended'); + // ⛔ Not degraded into the session's drop: the key is REFUSED, not trimmed. + expect(dropLines()).toHaveLength(0); + }); +}); diff --git a/packages/core/src/security/resolve-authz-context.ts b/packages/core/src/security/resolve-authz-context.ts index 671316e60e..9928f2cfc4 100644 --- a/packages/core/src/security/resolve-authz-context.ts +++ b/packages/core/src/security/resolve-authz-context.ts @@ -76,6 +76,15 @@ import { isRowActive } from './row-active.js'; /** The transport-agnostic authorization envelope produced from a request. */ export interface ResolvedAuthzContext { userId?: string; + /** + * The ACTIVE organization this request operates in. + * + * ⚠️ [#15409] For a session principal under a wall-enforcing posture this is + * a VETTED value, never the stored `activeOrganizationId` as read: a claim + * that is not in {@link accessible_org_ids} is dropped and the context + * resolves with no active organization at all. Absent here is the fail-closed + * state, not a missing lookup — Layer 0 denies on it. + */ tenantId?: string; email?: string; accessToken?: string; @@ -210,6 +219,57 @@ function warnApiKeyRefusal(details: { ); } +/** + * [#15409 — maintainer ruling 2026-09-05, option B] Say a DROPPED session + * organization claim out loud, on the SERVER side, where the drop is decided — + * the session mirror of {@link warnApiKeyRefusal} (#15256 / 2A). + * + * ## Why the operator needs this and the caller must not get it + * + * The drop is deliberately INVISIBLE on the wire: the principal stays + * authenticated, no status code moves, no response field is added and no reason + * travels. What the user sees is a session with no active organization — Layer + * 0's existing fail-closed branch (`!organizationId ⇒ RLS_DENY_FILTER` in + * `plugin-security/src/tenant-layer.ts`) — which is indistinguishable from + * never having selected one. That is right for the caller and useless for the + * operator, who otherwise has no way to learn that an offboarded member's live + * session was still naming the organization they left. + * + * ## What may appear here + * + * The `sys_session` ROW id, the owner, the claimed organization, the reason. + * ⛔ NEVER `sys_session.token` — that column's own field comment records a + * replay-proven impersonation (a leaked token is `Authorization: Bearer` for + * that user), so it is the one value this line must never carry. The row `id` + * is a separate column and is derived from neither the token nor its hash. + * + * ## Volume + * + * One line per request that drops a claim — bounded by real sessions whose + * membership ended, not by traffic. An anonymous or bogus cookie never reaches + * here (no `userId`, no claim), so a prober writes nothing. Deliberately not + * rate-limited: the burst — every request of a removed member's still-live + * session, for up to the session's remaining lifetime — is exactly what the + * operator needs to see. + * + * `console.warn` and not an injected logger, for the same reason the API-key + * line is: this resolver is kernel-agnostic and takes no host wiring. + */ +function warnSessionOrganizationClaimDropped(details: { + reason: 'organization_membership_ended'; + sessionId?: string; + userId?: string; + organizationId?: string; +}): void { + const { reason, sessionId, userId, organizationId } = details; + console.warn( + `[security] Session organization claim dropped (${reason}): ` + + `session=${sessionId ?? ''} principal=${userId ?? ''} ` + + `organization=${organizationId ?? ''}. ` + + 'The session stays authenticated with NO active organization — the wire is unchanged.', + ); +} + async function tryFind( ql: any, object: string, @@ -324,6 +384,12 @@ export async function resolveAuthzContext(input: ResolveAuthzInput): Promise = { + // No API keys on the session arms — an API key sets `userId` and makes + // the session path unreachable, so a stray one would make every arm + // below unreadable. §4 wires its own. + sys_api_key: [], + sys_member: [ + { user_id: 'u_member', organization_id: 'org_alpha', role: 'member' }, + { user_id: 'u_exmember', organization_id: 'org_beta', role: 'member' }, + ], + sys_user: [ + { id: 'u_member', email: 'u_member@example.com' }, + { id: 'u_exmember', email: 'u_exmember@example.com' }, + ], + // RBAC opened SYMMETRICALLY through one permission set — the cloud + // reading's discipline. With one shared grant, only the organization + // claim can separate the arms. + sys_user_permission_set: [ + { user_id: 'u_member', permission_set_id: 'ps_shared' }, + { user_id: 'u_exmember', permission_set_id: 'ps_shared' }, + ], + sys_permission_set: [ + { id: 'ps_shared', name: 'shared_access', system_permissions: ['manage_metadata', 'studio.access'] }, + ], + }; + return { + tables, + find: async (object: string, q: any = {}) => { + const rows = (tables[object] ?? []).filter((row: any) => matchesWhere(row, q?.where)); + return typeof q?.limit === 'number' ? rows.slice(0, q.limit) : rows; + }, + }; +} + +// --------------------------------------------------------------------------- +// Layer 0, as `plugin-security/src/tenant-layer.ts` computes it under `isolated` +// --------------------------------------------------------------------------- + +/** + * `computeTenantLayer0Filter`'s `isolated` branch, transcribed: a missing active + * organization is the DENY sentinel, never "no filter". Getting this backwards + * is the whole defect class — a resolver that hands down no tenant and a wall + * that reads that as "unscoped" is a wall that is off. + */ +function layer0IsDenied(tenantId: string | undefined): boolean { + return !tenantId; +} + +/** ADR-0123 D2's write half, as `security-plugin.ts` throws it. */ +function tenantWriteRefusal(): Error { + const err: any = new Error( + "[Security] Access denied: 'sys_business_unit' is scoped to an organization, and this session " + + 'has no active organization — so this create has no organization to place the record in. ' + + 'Join or select an active organization and retry.', + ); + err.name = 'PermissionDeniedError'; + err.code = 'PERMISSION_DENIED'; + return err; +} + +// --------------------------------------------------------------------------- +// The REST harness — real routes, real `computeExecCtx`, no stubbed exec ctx +// --------------------------------------------------------------------------- + +function makeRes() { + const res: any = { statusCode: 200, body: undefined }; + res.status = vi.fn((c: number) => { res.statusCode = c; return res; }); + res.json = vi.fn((b: any) => { res.body = b; return res; }); + res.header = vi.fn(() => res); + res.setHeader = vi.fn(); res.write = vi.fn(); res.end = vi.fn(); res.send = vi.fn(); + return res; +} + +interface Harness { + rest: RestServer; + /** Every row the fixture table holds, in insertion order. */ + store: () => BusinessUnitRow[]; + warnings: () => string[]; + /** + * better-auth's `setActiveOrganization`, modelled: it rewrites + * `active_organization_id` on the SAME session row — same id, same token, + * same cookie. This is what "switch organization" is, and §3 uses it to + * show the principal was never signed out. + */ + switchActiveOrganization: (cookie: string, org: string | null) => void; +} + +/** + * The single-kernel wiring, byte-faithful to `rest-api-plugin.ts`: no + * kernelManager, an auth provider and an objectql provider over the lone local + * kernel, plus the tenancy provider. + */ +function setup(opts: { withExMemberApiKey?: string } = {}): Harness { + const rows: BusinessUnitRow[] = SEED.map((r) => ({ ...r })); + let seq = 0; + const ql = makeQl(); + + const sessions: Record = { + sid_member: { id: 'ses_member', token: 'sess_token_member', userId: 'u_member', activeOrganizationId: 'org_alpha' }, + // ⭐ THE SUBJECT: the claim outlived the membership. + sid_exmember: { id: 'ses_exmember', token: TOKEN_EXMEMBER, userId: 'u_exmember', activeOrganizationId: 'org_alpha' }, + }; + + if (opts.withExMemberApiKey) ql.tables.sys_api_key.push(opts.withExMemberApiKey as any); + + const protocol: any = { + getDiscovery: vi.fn().mockResolvedValue({ + version: 'v0', routes: { data: '', metadata: '', ui: '', auth: '/auth' }, + }), + getMetaTypes: vi.fn().mockResolvedValue([]), + getMetaItems: vi.fn().mockResolvedValue([]), + getMetaItem: vi.fn().mockResolvedValue({}), + findData: vi.fn(async (r: any) => { + const tenantId = r?.context?.tenantId; + // RLS_DENY_FILTER — zero rows, never "everything". + if (layer0IsDenied(tenantId)) return { value: [], total: 0 }; + const visible = rows.filter((row) => row.organization_id === tenantId); + return { value: visible, total: visible.length }; + }), + createData: vi.fn(async (r: any) => { + const tenantId = r?.context?.tenantId; + // [ADR-0123 D2] No active organization → the write is REFUSED, not + // landed with a NULL organization. + if (layer0IsDenied(tenantId)) throw tenantWriteRefusal(); + const row: BusinessUnitRow = { + id: `w${++seq}`, + organization_id: tenantId, + created_by: r?.context?.userId, + name: String(r?.data?.name ?? ''), + }; + rows.push(row); + return row; + }), + }; + + const server: any = { + get: vi.fn(), post: vi.fn(), put: vi.fn(), delete: vi.fn(), patch: vi.fn(), use: vi.fn(), + listen: vi.fn().mockResolvedValue(undefined), close: vi.fn().mockResolvedValue(undefined), + }; + + const authServiceProvider = async () => ({ + api: { + getSession: async ({ headers }: any) => { + const cookie: string | undefined = headers?.get?.('cookie') ?? undefined; + const sid = cookie?.split('os_session=')[1]?.split(';')[0]; + const row = sid ? sessions[sid] : undefined; + if (!row) return undefined; + return { + user: { id: row.userId, email: `${row.userId}@example.com` }, + session: { ...row }, + }; + }, + }, + }); + const objectQLProvider = async () => ql; + const tenancyServiceProvider = async () => ({ posture: 'isolated' }); + + const rest = new RestServer( + server, + protocol, + {} as any, + undefined, // kernelManager — THE single-kernel wiring + undefined, // envRegistry + undefined, // defaultEnvironmentIdProvider + authServiceProvider, + objectQLProvider, + undefined, undefined, undefined, undefined, undefined, undefined, // email…i18n + undefined, undefined, undefined, undefined, undefined, undefined, // analytics…metadata + tenancyServiceProvider, + ); + rest.registerRoutes(); + + return { + rest, + store: () => rows.map((r) => ({ ...r })), + warnings: () => warnSpy.mock.calls.map((c: unknown[]) => c.map(String).join(' ')), + switchActiveOrganization: (cookie, org) => { + const sid = cookie.split('os_session=')[1]?.split(';')[0] as string; + sessions[sid].activeOrganizationId = org; + }, + }; +} + +function routeOf(rest: any, method: string, path: string) { + const route = rest.getRoutes().find((r: any) => r.method === method && r.path === path); + if (!route) throw new Error(`${method} ${path} route not registered`); + return route; +} + +function cookieHeaders(cookie?: string, apiKey?: string): Record { + const h: Record = {}; + if (cookie) h.cookie = cookie; + if (apiKey) h['x-api-key'] = apiKey; + return h; +} + +async function callGet(rest: any, cookie?: string, apiKey?: string) { + const res = makeRes(); + await routeOf(rest, 'GET', DATA_COLLECTION).handler( + { + method: 'GET', path: `/api/v1/data/${OBJECT}`, params: { object: OBJECT }, query: {}, + headers: cookieHeaders(cookie, apiKey), + }, + res, + ); + return res; +} + +async function callPost(rest: any, cookie: string | undefined, name: string, apiKey?: string) { + const res = makeRes(); + await routeOf(rest, 'POST', DATA_COLLECTION).handler( + { + method: 'POST', path: `/api/v1/data/${OBJECT}`, params: { object: OBJECT }, query: {}, + headers: cookieHeaders(cookie, apiKey), body: { name }, + }, + res, + ); + return res; +} + +const dropLines = (h: Harness) => h.warnings().filter((l) => l.includes('Session organization claim dropped')); + +let warnSpy: ReturnType; +let errorSpy: ReturnType; +beforeEach(() => { + warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); +}); +afterEach(() => { warnSpy.mockRestore(); errorSpy.mockRestore(); }); + +// --------------------------------------------------------------------------- +// §1 — Instrument controls. Both directions, before any subject arm is read. +// --------------------------------------------------------------------------- + +describe('[#15409] §1 — the rig can serve, and the door can refuse', () => { + it('CONTROL · data REACHES: a CURRENT member reads its own organization and only that one', async () => { + const h = setup(); + const res = await callGet(h.rest, COOKIE_MEMBER); + expect(res.statusCode).toBe(200); + expect(res.body.total).toBe(2); + expect(res.body.value.map((r: BusinessUnitRow) => r.id)).toEqual(['bu_a1', 'bu_a2']); + // The wall IS live: org_beta's two rows exist in the store and are not served. + expect(h.store().filter((r) => r.organization_id === 'org_beta')).toHaveLength(2); + }); + + it('CONTROL · writes REACH: a CURRENT member\'s POST lands, read back FROM THE STORE', async () => { + const h = setup(); + const res = await callPost(h.rest, COOKIE_MEMBER, 'w-member'); + expect(res.statusCode).toBe(201); + const landed = h.store().filter((r) => r.name === 'w-member'); + expect(landed).toHaveLength(1); + expect(landed[0]).toMatchObject({ organization_id: 'org_alpha', created_by: 'u_member' }); + }); + + it('CONTROL · the door refuses: no cookie is 401 on both verbs, and nothing lands', async () => { + const h = setup(); + const get = await callGet(h.rest, undefined); + expect(get.statusCode).toBe(ANONYMOUS_DENY_STATUS); + expect(get.body?.error?.code ?? get.body?.code).toBe(ANONYMOUS_DENY_CODE); + const post = await callPost(h.rest, undefined, 'w-anon'); + expect(post.statusCode).toBe(ANONYMOUS_DENY_STATUS); + expect(h.store()).toHaveLength(SEED.length); + }); + + it('CONTROL · a bogus cookie is 401 too — and is not a drop, because nothing was claimed', async () => { + const h = setup(); + const res = await callGet(h.rest, COOKIE_BOGUS); + expect(res.statusCode).toBe(ANONYMOUS_DENY_STATUS); + expect(dropLines(h)).toHaveLength(0); + }); +}); + +// --------------------------------------------------------------------------- +// §2 — THE SUBJECT ROW. The card's own case, driven end to end. +// --------------------------------------------------------------------------- + +describe('[#15409] §2 — a session whose membership ended while it stayed alive', () => { + it('REPAIRED: GET reads NOTHING from the organization it left — was 200 carrying that organization\'s rows', async () => { + const h = setup(); + const res = await callGet(h.rest, COOKIE_EXMEMBER); + expect(res.body.total).toBe(0); + expect(res.body.value).toEqual([]); + // The rows are still there — they were not served, not deleted. + expect(h.store().filter((r) => r.organization_id === 'org_alpha')).toHaveLength(2); + }); + + it('REPAIRED: POST does NOT land in that organization — read back from the store', async () => { + const h = setup(); + const res = await callPost(h.rest, COOKIE_EXMEMBER, 'w-exmember'); + // 403 `PERMISSION_DENIED` — ADR-0123 D2's EXISTING write refusal for a + // context with no active organization, in its existing words. ⛔ No new + // status code and no new error code were added by this card. + expect(res.statusCode).toBe(403); + expect(res.body?.error?.code ?? res.body?.code).toBe('PERMISSION_DENIED'); + // The measured defect was a write that LANDED, stamped with the left + // organization and attributed to the removed user. The STORE is the + // authority on whether it did — never the response body. + expect(h.store().filter((r) => r.name === 'w-exmember')).toHaveLength(0); + expect(h.store().filter((r) => r.created_by === 'u_exmember')).toHaveLength(0); + expect(h.store()).toHaveLength(SEED.length); + }); + + it('[2A, mirrored] the drop is said OUT LOUD once — session / principal / organization / reason, ⛔ never the token', async () => { + const h = setup(); + await callGet(h.rest, COOKIE_EXMEMBER); + const lines = dropLines(h); + expect(lines).toHaveLength(1); + expect(lines[0]).toContain('organization_membership_ended'); + expect(lines[0]).toContain('session=ses_exmember'); + expect(lines[0]).toContain('principal=u_exmember'); + expect(lines[0]).toContain('organization=org_alpha'); + // ⛔ THE CREDENTIAL. A leaked `sys_session.token` is `Authorization: + // Bearer` for that user — its own field comment calls the disclosure + // impersonation rather than exposure. + expect(lines[0]).not.toContain(TOKEN_EXMEMBER); + }); + + it('the WIRE is unchanged: no new status code, no reason, no organization travels to the caller', async () => { + const h = setup(); + const get = await callGet(h.rest, COOKIE_EXMEMBER); + const post = await callPost(h.rest, COOKIE_EXMEMBER, 'w-exmember-wire'); + for (const body of [get.body, post.body]) { + expect(JSON.stringify(body ?? {})).not.toMatch(/organization_membership_ended|claim dropped|ses_exmember/i); + } + // The 200-shaped empty read and the ADR-0123 D2 write refusal are both + // states this deployment could already produce for a session that simply + // has not selected an organization. Nothing here is new vocabulary. + expect(get.statusCode).toBe(200); + }); +}); + +// --------------------------------------------------------------------------- +// §3 — ⭐ B, NOT A. The assertion that reddens if B is "simplified" into A. +// --------------------------------------------------------------------------- + +describe('[#15409] §3 — the principal is dropped-from-an-org, NOT refused', () => { + it('the ex-member is STILL AUTHENTICATED — the read is a 200 with nothing in it, not a 401', async () => { + const h = setup(); + const res = await callGet(h.rest, COOKIE_EXMEMBER); + // ⛔ If this ever becomes ANONYMOUS_DENY_STATUS, option B has silently + // become option A: a person signed out of everything because one stored + // claim went stale. + expect(res.statusCode).not.toBe(ANONYMOUS_DENY_STATUS); + expect(res.statusCode).toBe(200); + expect(res.body?.error?.code ?? res.body?.code).not.toBe(ANONYMOUS_DENY_CODE); + }); + + it('and can still WORK in an organization they really are in — same session, switched', async () => { + const h = setup(); + // The dropped request first, so the switch is measured on a session that + // has already met the guard. + await callGet(h.rest, COOKIE_EXMEMBER); + // better-auth's `setActiveOrganization`: the SAME session row, same id, + // same token, same cookie — only the claim moves. + h.switchActiveOrganization(COOKIE_EXMEMBER, 'org_beta'); + + const get = await callGet(h.rest, COOKIE_EXMEMBER); + expect(get.statusCode).toBe(200); + expect(get.body.total).toBe(2); + expect(get.body.value.map((r: BusinessUnitRow) => r.id)).toEqual(['bu_b1', 'bu_b2']); + + const post = await callPost(h.rest, COOKIE_EXMEMBER, 'w-exmember-beta'); + expect(post.statusCode).toBe(201); + const landed = h.store().filter((r) => r.name === 'w-exmember-beta'); + expect(landed).toHaveLength(1); + expect(landed[0]).toMatchObject({ organization_id: 'org_beta', created_by: 'u_exmember' }); + }); + + it('and the backed claim says NOTHING — one line per drop, not one per request', async () => { + const h = setup(); + h.switchActiveOrganization(COOKIE_EXMEMBER, 'org_beta'); + await callGet(h.rest, COOKIE_EXMEMBER); + await callGet(h.rest, COOKIE_MEMBER); + expect(dropLines(h)).toHaveLength(0); + }); +}); + +// --------------------------------------------------------------------------- +// §4 — The API-key arm is UNTOUCHED (#15256 decision 1A stands). +// --------------------------------------------------------------------------- + +describe('[#15409] §4 — an API key is still refused outright, not trimmed', () => { + /** + * A key IS its organization binding, so refusing the whole credential is + * right there. This card changed the SESSION arm only; if someone ever + * generalises the drop over both, this row goes red — an ex-member's + * automation would go back to answering 200 with an empty set, the silent + * empty #15256 exists to remove. + */ + it('an ex-member\'s org-stamped key is 401, and the session drop line does not fire for it', async () => { + // The fixture stores the at-rest hash exactly as `sys_api_key` does. + const raw = 'osk_15409_exmember'; + const h = setup({ + withExMemberApiKey: { + id: 'key_exmember', key: hashApiKey(raw), user_id: 'u_exmember', + active_organization_id: 'org_alpha', revoked: false, + } as any, + }); + const res = await callGet(h.rest, undefined, raw); + expect(res.statusCode).toBe(ANONYMOUS_DENY_STATUS); + expect(res.body?.error?.code ?? res.body?.code).toBe(ANONYMOUS_DENY_CODE); + expect(h.warnings().filter((l) => l.includes('API key refused'))).toHaveLength(1); + expect(dropLines(h)).toHaveLength(0); + }); + + it('its POST lands nothing either — the store still holds only the seed', async () => { + const raw = 'osk_15409_exmember'; + const h = setup({ + withExMemberApiKey: { + id: 'key_exmember', key: hashApiKey(raw), user_id: 'u_exmember', + active_organization_id: 'org_alpha', revoked: false, + } as any, + }); + const res = await callPost(h.rest, undefined, 'w-exmember-key', raw); + expect(res.statusCode).toBe(ANONYMOUS_DENY_STATUS); + expect(h.store()).toHaveLength(SEED.length); + }); +});