Skip to content

Commit 795ea05

Browse files
os-warrenclaude
andauthored
fix(core): a lapsed sys_member row confers no org role either (#10982) (#11088)
`resolveUserAuthzGrants` reads `sys_member` once and derives two facts from it — `accessible_org_ids` (ADR-0105 D2) and the org-administration role projection into `positions` (ADR-0095 D3). Only the first applied the ADR-0091 validity window, so a lapsed membership granted no org access while still conferring its better-auth role: two answers from one row. The role projection now drops out-of-window rows BEFORE the derivation — the shape step 6 already gives `sys_user_permission_set`, so a lapsed membership can no more yield `org_owner` than an expired `admin_full_access` can yield `platform_admin`. Fail-closed per ADR-0091 D2. Maintainer ruling, 2026-08-22 live session (item 2): a lapsed membership is no membership, not merely no org access. `sys_member` declares neither bound today and `isGrantActive` reads an absent bound as unbounded, so no shipped row changes answer — asserted directly. Claude-Session: https://claude.ai/code/session_01PnJHU45vPJj5UQrxe946Bx Co-authored-by: Claude <noreply@anthropic.com>
1 parent 504c8d5 commit 795ea05

4 files changed

Lines changed: 228 additions & 9 deletions

File tree

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
---
2+
"@objectstack/core": patch
3+
---
4+
5+
A lapsed `sys_member` row now confers no org role either — one row, one answer (#10982)
6+
7+
`resolveUserAuthzGrants` reads `sys_member` once and derives two facts from it:
8+
`accessible_org_ids` (the `group` posture's read reach, ADR-0105 D2) and the
9+
org-administration role projection into `positions` (ADR-0095 D3). Only the
10+
first applied the ADR-0091 validity window. A membership outside
11+
`[valid_from, valid_until)` was therefore excluded from org access while still
12+
projecting its better-auth role — two answers from one read, and with
13+
`role: 'owner'` the role reaches the `organization_admin` capability that
14+
`derivePosture` reads for `TENANT_ADMIN`.
15+
16+
The role projection now drops out-of-window rows **before** the derivation, the
17+
same shape `sys_user_permission_set` already had, so an expired membership can
18+
no more yield `org_owner` than an expired `admin_full_access` can yield
19+
`platform_admin`. Fail-closed per ADR-0091 D2. Maintainer ruling, 2026-08-22
20+
live session (item 2): a lapsed membership is *no membership*, not merely *no
21+
org access*.
22+
23+
**Why `patch` and not a breaking bump, argued in the open.** This is a real
24+
change of authorization semantics — a membership that used to confer a role
25+
stops conferring it — so the direction is a tightening, and tightenings are the
26+
kind of change that normally earns a major. It is nevertheless `patch` because
27+
the population it can affect is provably empty: `sys_member` declares neither
28+
`valid_from` nor `valid_until` (see `sys-member.object.ts`), and `isGrantActive`
29+
reads an absent bound as unbounded, so **no row any deployment can currently
30+
store is lapsed** and every existing membership resolves exactly as before. That
31+
is asserted directly rather than reasoned about, in
32+
`resolve-authz-context.test.ts` ("a membership with NO bounds is unbounded —
33+
every shipped row is unaffected"), alongside the load-bearing leg that an
34+
in-window membership still projects its role. Landing it now is the cheap
35+
moment: once the columns exist, the same change becomes a migration carrying
36+
live semantics.
37+
38+
**Not in scope, and deliberately so.** This does not add the validity columns to
39+
`sys_member`, and it does not reach into `sys_user_permission_set` rows that
40+
plugin-security's `reconcileOrgAdminGrant` provisioned from a membership role.
41+
Such a grant is standing authority in its own right with its own ADR-0091
42+
window; the role is only its provisioning source (ADR-0095 D3). The boundary is
43+
pinned as a measured fact rather than left as an assumption.

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

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -355,6 +355,169 @@ describe('grant validity windows (ADR-0091 D1/D2)', () => {
355355
const ctx = await resolveAuthzContext({ ql, headers: H(), getSession: session('u1'), nowMs: NOW });
356356
expect(ctx.positions).not.toContain('approver');
357357
});
358+
359+
// ── #10982 — the sys_member half: ONE row, ONE answer ───────────────────
360+
//
361+
// `resolveUserAuthzGrants` reads `sys_member` ONCE and derives two facts from
362+
// it: `accessible_org_ids` (the `group` posture's read reach) and the
363+
// org-administration role projection into `positions`. Only the first applied
364+
// the ADR-0091 window, so a lapsed membership granted no org ACCESS while
365+
// still conferring its org-admin ROLE — two answers from one row.
366+
//
367+
// Maintainer ruling, 2026-08-22 live session (item 2): **A — the role
368+
// projection honours the window. A lapsed membership is NO MEMBERSHIP, not
369+
// merely no org access.** Fail-closed per ADR-0091 D2. The fix is one
370+
// `isGrantActive` call in the `activeMembers` filter, placed BEFORE the
371+
// derivation — the shape §6 already gives `sys_user_permission_set`.
372+
//
373+
// ⚠️ `sys_member` declares NEITHER bound today, and `isGrantActive` reads an
374+
// absent bound as unbounded, so no shipped row can be lapsed and none changes
375+
// answer. The unbounded leg below is what says that out loud; without it this
376+
// block would be satisfied by an implementation that filtered everything and
377+
// silently un-admined every real org member.
378+
describe('#10982 — a lapsed sys_member row confers no role either', () => {
379+
it('a lapsed membership projects NO org role, and no org access — one row, one answer', async () => {
380+
const ql = makeQl({
381+
sys_user: [{ id: 'u1' }],
382+
sys_member: [{ user_id: 'u1', role: 'member', organization_id: 'o1', valid_until: PAST }],
383+
sys_user_position: [],
384+
sys_user_permission_set: [],
385+
});
386+
const ctx = await resolveAuthzContext({ ql, headers: H(), getSession: session('u1', { org: 'o1' }), nowMs: NOW });
387+
// The half that was already correct.
388+
expect(ctx.accessible_org_ids).toEqual([]);
389+
// The half this card fixes — it used to carry `org_member`.
390+
expect(ctx.positions).not.toContain('org_member');
391+
// `everyone` is the ADR-0090 D5 audience anchor, held by every
392+
// AUTHENTICATED principal and NOT membership-derived: asserted present so
393+
// the line above cannot pass by the resolver having returned nothing.
394+
expect(ctx.positions).toContain('everyone');
395+
});
396+
397+
it('a not-yet-active membership (future valid_from) projects no role either', async () => {
398+
const ql = makeQl({
399+
sys_user: [{ id: 'u1' }],
400+
sys_member: [{ user_id: 'u1', role: 'admin', organization_id: 'o1', valid_from: FUTURE }],
401+
sys_user_position: [],
402+
sys_user_permission_set: [],
403+
});
404+
const ctx = await resolveAuthzContext({ ql, headers: H(), getSession: session('u1', { org: 'o1' }), nowMs: NOW });
405+
expect(ctx.positions).not.toContain('org_admin');
406+
expect(ctx.accessible_org_ids).toEqual([]);
407+
});
408+
409+
// ⛔ LOAD-BEARING. An implementation that dropped every membership row would
410+
// satisfy every lapsed assertion above while un-admining the entire
411+
// installed base. Both rows sit in the SAME organization on purpose: the
412+
// active-org filter cannot explain either verdict, so the ONLY thing
413+
// separating them is the validity window — a blanket filter fails the first
414+
// assertion, and no filter at all fails the second.
415+
it('an ACTIVE membership still projects its role — the lapsed legs cannot pass vacuously', async () => {
416+
const tables = {
417+
sys_user: [{ id: 'u1' }],
418+
sys_member: [
419+
{ user_id: 'u1', role: 'owner', organization_id: 'o2', valid_until: PAST },
420+
{ user_id: 'u1', role: 'member', organization_id: 'o2', valid_from: PAST, valid_until: FUTURE },
421+
],
422+
sys_user_position: [],
423+
sys_user_permission_set: [],
424+
};
425+
const ctx = await resolveAuthzContext({
426+
ql: makeQl(tables), headers: H(), getSession: session('u1', { org: 'o2' }), nowMs: NOW,
427+
});
428+
expect(ctx.positions).toContain('org_member'); // the in-window row still grants
429+
expect(ctx.positions).not.toContain('org_owner'); // the lapsed one, same org, does not
430+
expect(ctx.accessible_org_ids).toEqual(['o2']); // and the two halves agree
431+
});
432+
433+
// ⛔ LOAD-BEARING, the safe-to-land-now leg. `sys_member` has no
434+
// `valid_from`/`valid_until` columns, so EVERY shipped row looks like this.
435+
// If this reddens, the change is not a tightening — it is a regression on
436+
// every existing deployment.
437+
it('a membership with NO bounds is unbounded — every shipped row is unaffected', async () => {
438+
const ql = makeQl({
439+
sys_user: [{ id: 'u1' }],
440+
sys_member: [{ user_id: 'u1', role: 'owner,member', organization_id: 'o1' }],
441+
sys_user_position: [],
442+
sys_user_permission_set: [],
443+
});
444+
const ctx = await resolveAuthzContext({ ql, headers: H(), getSession: session('u1', { org: 'o1' }), nowMs: NOW });
445+
expect(ctx.positions).toContain('org_owner');
446+
expect(ctx.positions).toContain('org_member');
447+
expect(ctx.accessible_org_ids).toEqual(['o1']);
448+
});
449+
450+
// ── The escalation the card is filed on ──────────────────────────────
451+
//
452+
// Make the lapsed row's role `owner` and the question stops being tidiness:
453+
// `org_owner` is a capability-bearing name. A suite that only checked
454+
// `org_member` would not catch a regression here, so the rung is pinned in
455+
// BOTH directions on the same fixture shape.
456+
//
457+
// ⚠️ Which escalation path this closes, stated precisely, because the two
458+
// differ: `derivePosture` reads HELD CAPABILITY GRANTS, never the
459+
// better-auth role (ADR-0095 D2/D3). The role reaches TENANT_ADMIN by two
460+
// routes — (P) `org_owner` resolves a `sys_position` row whose bound set is
461+
// an org-admin grant, entirely inside this resolver; and (D) plugin-security's
462+
// `reconcileOrgAdminGrant` provisions a direct `sys_user_permission_set`
463+
// row from the role. This fix closes (P). It deliberately does NOT reach
464+
// into (D): that row is standing authority in its own right, carrying its
465+
// OWN ADR-0091 window, and revoking someone else's grant row from a read
466+
// path is not this resolver's job. Both are pinned below so the boundary is
467+
// a measured fact rather than an assumption.
468+
const orgOwnerPositionTables = (memberRow: Record<string, unknown>) => ({
469+
sys_user: [{ id: 'esc' }],
470+
sys_member: [{ user_id: 'esc', role: 'owner', organization_id: 'o1', ...memberRow }],
471+
sys_user_position: [],
472+
sys_user_permission_set: [],
473+
sys_position: [{ id: 'pos_owner', name: 'org_owner' }],
474+
sys_position_permission_set: [{ position_id: 'pos_owner', permission_set_id: 'psO' }],
475+
sys_permission_set: [{ id: 'psO', name: 'organization_admin' }],
476+
});
477+
478+
it('escalation, BEFORE: an ACTIVE owner membership still resolves TENANT_ADMIN', async () => {
479+
const ctx = await resolveAuthzContext({
480+
ql: makeQl(orgOwnerPositionTables({})), headers: H(),
481+
getSession: session('esc', { org: 'o1' }), nowMs: NOW,
482+
});
483+
expect(ctx.positions).toContain('org_owner');
484+
expect(ctx.permissions).toContain('organization_admin');
485+
expect(ctx.posture).toBe('TENANT_ADMIN');
486+
});
487+
488+
it('escalation, AFTER: a LAPSED owner membership resolves MEMBER, not TENANT_ADMIN', async () => {
489+
const ctx = await resolveAuthzContext({
490+
ql: makeQl(orgOwnerPositionTables({ valid_until: PAST })), headers: H(),
491+
getSession: session('esc', { org: 'o1' }), nowMs: NOW,
492+
});
493+
expect(ctx.positions).not.toContain('org_owner');
494+
// The rung falls with the name: `org_owner` never resolves its
495+
// `sys_position` row, so the bound org-admin set is never collected.
496+
expect(ctx.permissions).not.toContain('organization_admin');
497+
expect(ctx.posture).toBe('MEMBER');
498+
expect(ctx.accessible_org_ids).toEqual([]);
499+
});
500+
501+
it('the boundary: a DIRECT org-admin grant is standing authority and survives the lapse — by design', async () => {
502+
// Route (D). The role is only the PROVISIONING source (ADR-0095 D3); the
503+
// grant row it caused is separate authority with its own window. So the
504+
// role projection goes quiet while the capability keeps resolving. This
505+
// is the correct layering, not a residual hole — but it IS the reason the
506+
// rung can outlive the membership, so it is pinned rather than assumed.
507+
const ql = makeQl({
508+
sys_user: [{ id: 'esc2' }],
509+
sys_member: [{ user_id: 'esc2', role: 'owner', organization_id: 'o1', valid_until: PAST }],
510+
sys_user_position: [],
511+
sys_user_permission_set: [{ user_id: 'esc2', permission_set_id: 'psO', organization_id: 'o1' }],
512+
sys_permission_set: [{ id: 'psO', name: 'organization_admin' }],
513+
});
514+
const ctx = await resolveAuthzContext({ ql, headers: H(), getSession: session('esc2', { org: 'o1' }), nowMs: NOW });
515+
expect(ctx.positions).not.toContain('org_owner'); // the role projection is closed
516+
expect(ctx.permissions).toContain('organization_admin'); // the grant row is not
517+
expect(ctx.posture).toBe('TENANT_ADMIN');
518+
expect(ctx.accessible_org_ids).toEqual([]); // and access is still withheld
519+
});
520+
});
358521
});
359522

360523
describe('audience anchors in the resolver (ADR-0090 D5)', () => {

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

Lines changed: 20 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -388,13 +388,26 @@ export async function resolveUserAuthzGrants(
388388
}
389389
grants.accessible_org_ids = Array.from(accessibleOrgIds);
390390

391-
// Positions come from the ACTIVE org's membership only (unchanged): a role
392-
// held in one organization must not grant its capabilities while the caller
393-
// operates in another. With no active org, every membership contributes —
394-
// exactly the pre-D2 behavior of the org-less read.
395-
const activeMembers = tenantId
396-
? members.filter((m) => (m.organization_id ?? m.organizationId) === tenantId)
397-
: members;
391+
// Positions come from the ACTIVE org's membership only: a role held in one
392+
// organization must not grant its capabilities while the caller operates in
393+
// another. With no active org, every membership contributes — exactly the
394+
// pre-D2 behavior of the org-less read.
395+
//
396+
// [ADR-0091 D2] Rows outside their validity window are dropped BEFORE the
397+
// role derivation — the same discipline §6 gives `sys_user_permission_set`,
398+
// so a lapsed membership can no more yield `org_owner` than an expired
399+
// `admin_full_access` can yield `platform_admin`. Maintainer ruling
400+
// 2026-08-22 (live session, item 2): a lapsed membership is NO MEMBERSHIP,
401+
// not merely no org access — so this half now answers the same question as
402+
// `accessible_org_ids` above, off the same rows, and (a)'s "correct the
403+
// moment they do" promise covers BOTH derivations rather than one. Fail
404+
// closed (D2). `sys_member` declares neither bound today and `isGrantActive`
405+
// reads an absent bound as unbounded, so no shipped row changes answer.
406+
const activeMembers = members.filter(
407+
(m) =>
408+
isGrantActive(m, nowMs)
409+
&& (!tenantId || (m.organization_id ?? m.organizationId) === tenantId),
410+
);
398411
for (const m of activeMembers) {
399412
if (m.role && typeof m.role === 'string') {
400413
for (const raw of m.role.split(',').map((s: string) => s.trim()).filter(Boolean)) {

0 commit comments

Comments
 (0)