Skip to content

Commit d4f9b2a

Browse files
hotlongclaude
andauthored
fix(core): drop a session's unbacked organization claim under a wall-enforcing posture (#15794)
* fix(core): drop a session's unbacked organization claim under a walled posture (#15409) Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * test(core,rest): pin the session organization-claim drop end to end (#15409) Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * test(core): annotate the warn-line filter parameter (tsconfig.test.json checked zone) Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent 9140c76 commit d4f9b2a

4 files changed

Lines changed: 977 additions & 1 deletion

File tree

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
'@objectstack/core': patch
3+
---
4+
5+
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.
6+
7+
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.
8+
9+
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`.

‎packages/core/src/security/resolve-authz-context.test.ts‎

Lines changed: 240 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)