Skip to content

Commit 203d1c4

Browse files
committed
security(plugin-auth): gate direct /sso/register on platform admin only
The ADR-0024 before-hook on better-auth's own POST /sso/register admitted a platform admin OR an owner/admin of the caller's active org, while the four /admin/sso/* bridges have gated on the platform-admin judge since #9653. One principal therefore got two answers for the same underlying registration -- 403 at the bridge, admitted at the direct endpoint -- which made the bridge tightening honest labelling rather than a boundary. Per the 2026-08-20 maintainer ruling on #10009, ADR-0068 D4 governs: registering an identity provider is a platform-operator action. The admit set here is now platform-admin-only. - `isPlatformAdminUserId` asks the ADR-0068 D2 permission-set question on its own (org-less admin_full_access link), through `withSystemReadContext`, fail closed. It deliberately does not consult the retired `role` scalar. - `isOrgOrPlatformAdmin` lost its only production caller with this change and is removed rather than parked; the one grade ladder it delegated to (`isOrgAdminGrade`) keeps its own pins in member-role-canonical.test.ts and its other reader in last-admin-guard.ts. - The direct surface gains its first test pins, two-directional on purpose: the org owner is refused (403 + SSO_REGISTER_FORBIDDEN, code AND status) and the platform admin is still admitted. The platform admin is granted the ADR-0068 way and the fixture asserts the legacy role scalar is NOT 'admin', so the suite cannot pass by riding the retired channel. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PnJHU45vPJj5UQrxe946Bx
1 parent 9a1ed7a commit 203d1c4

4 files changed

Lines changed: 513 additions & 183 deletions

File tree

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
"@objectstack/plugin-auth": patch
3+
---
4+
5+
**Behaviour change (tightening):** registering an SSO identity provider through the direct `POST /api/v1/auth/sso/register` endpoint now requires a **platform admin**. An organization **owner or admin** who is not a platform admin can no longer register an identity provider on any surface (#10009).
6+
7+
Who loses access: an org owner/admin (a `sys_member` row graded owner/admin) with no org-less `admin_full_access` grant. They previously passed the ADR-0024 before-hook on the direct endpoint and now receive `403 SSO_REGISTER_FORBIDDEN`. Platform admins — an org-less `sys_user_permission_set` link to `admin_full_access`, per ADR-0068 D2 — are unaffected, as are anonymous callers, who still fall through to better-auth's `sessionMiddleware` (`401`).
8+
9+
This closes a posture divergence: the four `/admin/sso/*` bridges the `sys_sso_provider` metadata actions call have gated on the platform-admin judge since #9653, while better-auth's own endpoint kept the wider ADR-0024 admit set — so the same principal was refused at one door and admitted at the other for the same underlying registration, leaving the bridge tightening as labelling rather than a boundary. Per the 2026-08-20 maintainer ruling, ADR-0068 D4 governs: registering an identity provider is a platform-operator action. If org-scoped IdP self-serve is ever wanted, it is a deliberate future decision rather than a vendor default inherited by omission.
10+
11+
The direct endpoint also gains its first test pins; the now-callerless `isOrgOrPlatformAdmin` predicate was removed rather than left dead.

‎packages/plugins/plugin-auth/src/auth-manager.test.ts‎

Lines changed: 61 additions & 126 deletions
Original file line numberDiff line numberDiff line change
@@ -3940,47 +3940,39 @@ describe('getPublicConfig devSeedAdmin (dev-only login hint)', () => {
39403940
});
39413941

39423942
// ---------------------------------------------------------------------------
3943-
// [#5942] `isOrgOrPlatformAdmin` — the ADR-0024 `/sso/register` admin gate's
3944-
// criterion — asks "does this membership administer the org" through the ONE
3945-
// grade ladder (`isOrgAdminGrade`, `invitation-role-cap.ts`), not a hand-copied
3946-
// `role === 'owner' || role === 'admin'`.
3943+
// [#10009] `isPlatformAdminUserId` — the criterion the ADR-0024
3944+
// `/sso/register` before-hook now judges on.
39473945
//
3948-
// The hand-copy it replaces did `.split(',').map(trim).some(=== 'owner' ||
3949-
// === 'admin')` — case-SENSITIVE, and blind to the array spelling. The grade
3950-
// ladder additionally `.toLowerCase()`s and joins arrays, so the two answered
3951-
// differently on `Owner` / `ADMIN` / `['owner']`: this gate refused a real
3952-
// administrator (false negative) while the break-glass ban guard
3953-
// (`last-admin-ban-guard.ts`, same ladder) counted the same row AS an
3954-
// administrator. Two spellings of one security question, diverging silently.
3946+
// This block replaces the #5942 `isOrgOrPlatformAdmin` block that stood here.
3947+
// That method asked TWO questions ("platform admin OR org owner/admin") because
3948+
// the `/sso/register` gate admitted both. The 2026-08-20 maintainer ruling on
3949+
// #10009 narrowed that gate to platform-admin-only (ADR-0068 D4: registering an
3950+
// identity provider is a platform-operator action, matching the `/admin/sso/*`
3951+
// bridges #9653 landed), which left the wider predicate with no caller — so it
3952+
// was removed and this block follows the criterion that survived.
39553953
//
3956-
// Direction of the change, measured (see the PR body): every difference is a
3957-
// WIDENING, and only over values the old spelling judged wrongly. There is no
3958-
// value that was admin before and is not admin now — the closed ADR-0108
3959-
// vocabulary (all lowercase) answers identically on both sides, which is why
3960-
// no user could hit this today.
3961-
//
3962-
// NOTE on `' admin '`: it is a regression pin, NOT a before-red case. The
3963-
// hand-copy already trimmed, so it answered `true` before the change too. Only
3964-
// the CASE and ARRAY spellings actually move.
3965-
//
3966-
// The platform-admin half of this method is deliberately untouched (#5942 is
3967-
// scoped to the org ruler); the platform-admin cases below pin that.
3954+
// The org-grade half of #5942 is NOT lost coverage: the one grade ladder
3955+
// (`isOrgAdminGrade`, `invitation-role-cap.ts`) keeps its own direct pins in
3956+
// `member-role-canonical.test.ts` (case, comma and array spellings) and is
3957+
// still read by `last-admin-guard.ts`. What is deliberately gone is the claim
3958+
// that THIS seam asks the org question — it no longer does, and the cases below
3959+
// pin that as a refusal rather than leaving it unstated.
39683960
// ---------------------------------------------------------------------------
3969-
describe('isOrgOrPlatformAdmin – one grade ruler for "is this membership an admin" (#5942)', () => {
3961+
describe('isPlatformAdminUserId – the /sso/register criterion is platform-admin-only (#10009)', () => {
39703962
const SECRET = 'test-secret-at-least-32-chars-long';
39713963

39723964
/**
3973-
* Read-only engine stub: `members` are the `sys_member` rows, `platformAdmin`
3974-
* controls the org-less `admin_full_access` link. `find` honours the `where`
3975-
* the gate actually passes (`user_id`, and `organization_id` when an active
3976-
* org is set) so the org-scoping half is the product's, not the fixture's.
3965+
* Read-only engine stub. `platformAdmin` controls the ADR-0068 org-less
3966+
* `admin_full_access` link; `members` are `sys_member` rows, which this
3967+
* criterion must now ignore entirely. `find` honours the `where` the judge
3968+
* passes, so any org-scoping is the product's, not the fixture's.
39773969
*/
3978-
const makeEngine = (opts: { members?: any[]; platformAdmin?: boolean; throws?: boolean } = {}) => ({
3970+
const makeEngine = (opts: { members?: any[]; platformAdmin?: boolean; throws?: boolean; orgScopedGrant?: boolean } = {}) => ({
39793971
find: vi.fn(async (object: string, query?: any) => {
39803972
if (opts.throws) throw new Error('db down');
39813973
if (object === 'sys_user_permission_set') {
39823974
return opts.platformAdmin
3983-
? [{ user_id: 'u-1', permission_set_id: 'ps-admin', organization_id: null }]
3975+
? [{ user_id: 'u-1', permission_set_id: 'ps-admin', organization_id: opts.orgScopedGrant ? 'org-1' : null }]
39843976
: [];
39853977
}
39863978
if (object === 'sys_permission_set') return [{ id: 'ps-admin', name: 'admin_full_access' }];
@@ -3995,20 +3987,16 @@ describe('isOrgOrPlatformAdmin – one grade ruler for "is this membership an ad
39953987
findOne: vi.fn(),
39963988
});
39973989

3998-
/** The gate's criterion, invoked exactly as the `/sso/register` hook does. */
3999-
const judge = async (
4000-
engine: any,
4001-
activeOrgId?: string,
4002-
userId = 'u-1',
4003-
): Promise<boolean> => {
3990+
/** The criterion, invoked exactly as the `/sso/register` hook invokes it. */
3991+
const judge = async (engine: any, userId = 'u-1'): Promise<boolean> => {
40043992
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
40053993
const manager = new AuthManager({
40063994
secret: SECRET,
40073995
baseUrl: 'http://localhost:3000',
40083996
dataEngine: engine,
40093997
});
40103998
warn.mockRestore();
4011-
return (manager as any).isOrgOrPlatformAdmin(userId, activeOrgId);
3999+
return (manager as any).isPlatformAdminUserId(userId);
40124000
};
40134001

40144002
const memberRow = (role: unknown) => ({
@@ -4018,114 +4006,61 @@ describe('isOrgOrPlatformAdmin – one grade ruler for "is this membership an ad
40184006
role,
40194007
});
40204008

4021-
// -- (1) the fix itself: values the hand-copy refused, the ladder admits ----
4022-
describe('case-insensitive + array spellings (before: refused, after: admitted)', () => {
4009+
// -- (1) the narrowing: administrative MEMBERSHIP is no longer a licence ---
4010+
describe('org owners/admins are REFUSED (the #10009 narrowing)', () => {
40234011
it.each([
4024-
['Owner', 'better-auth owner, capitalized by an import'],
4025-
['ADMIN', 'shout-cased by a hand-written SQL insert'],
4026-
[' Admin ', 'padded AND capitalized'],
4027-
['OWNER', 'shout-cased owner'],
4028-
['member,Owner', 'comma-joined with one capitalized administrative role'],
4029-
])('grades %j as an administrator (%s)', async (role) => {
4030-
expect(await judge(makeEngine({ members: [memberRow(role)] }), 'org-1')).toBe(true);
4012+
['owner', 'the canonical org owner'],
4013+
['admin', 'the canonical org admin'],
4014+
['Owner', 'capitalized — the #5942 ladder graded this as administrative'],
4015+
['ADMIN', 'shout-cased'],
4016+
['owner,member', 'comma-joined'],
4017+
])('refuses sys_member.role %j (%s)', async (role) => {
4018+
expect(await judge(makeEngine({ members: [memberRow(role)] }))).toBe(false);
40314019
});
40324020

4033-
it('grades the ARRAY spelling ["owner"] as an administrator', async () => {
4034-
// The hand-copy read `typeof m.role === 'string' ? m.role : ''`, so any
4035-
// array-valued role graded as nothing at all.
4036-
expect(await judge(makeEngine({ members: [memberRow(['owner'])] }), 'org-1')).toBe(true);
4021+
it('refuses the ARRAY spelling ["owner"] too', async () => {
4022+
expect(await judge(makeEngine({ members: [memberRow(['owner'])] }))).toBe(false);
40374023
});
40384024

4039-
it('grades the ARRAY spelling ["member","Admin"] as an administrator', async () => {
4040-
expect(
4041-
await judge(makeEngine({ members: [memberRow(['member', 'Admin'])] }), 'org-1'),
4042-
).toBe(true);
4025+
it('refuses an administrative membership in ANY org (no active-org escape hatch)', async () => {
4026+
const engine = makeEngine({
4027+
members: [{ id: 'm-1', user_id: 'u-1', organization_id: 'org-other', role: 'owner' }],
4028+
});
4029+
expect(await judge(engine)).toBe(false);
40434030
});
40444031
});
40454032

4046-
// -- (2) regression: the closed ADR-0108 vocabulary answers identically -----
4047-
describe('closed membership vocabulary (ADR-0108) — unchanged by the new ruler', () => {
4048-
it.each([
4049-
['owner', true],
4050-
['admin', true],
4051-
['delegated_admin', false],
4052-
['member', false],
4053-
] as const)('grades the built-in %j as admin=%s', async (role, expected) => {
4054-
expect(await judge(makeEngine({ members: [memberRow(role)] }), 'org-1')).toBe(expected);
4033+
// -- (2) the other direction: a real platform admin is still admitted ------
4034+
describe('the ADR-0068 platform admin is still ADMITTED', () => {
4035+
it('admits an org-less admin_full_access grant even when the membership is a plain member', async () => {
4036+
expect(await judge(makeEngine({ platformAdmin: true, members: [memberRow('member')] }))).toBe(true);
40554037
});
40564038

4057-
it.each([
4058-
['owner,member', true],
4059-
['member,admin', true],
4060-
[' admin ', true],
4061-
['member,delegated_admin', false],
4062-
] as const)(
4063-
'grades the comma/whitespace spelling %j as admin=%s (already true before #5942)',
4064-
async (role, expected) => {
4065-
expect(await judge(makeEngine({ members: [memberRow(role)] }), 'org-1')).toBe(expected);
4066-
},
4067-
);
4068-
});
4069-
4070-
// -- (3) non-administrative values still refused (no widening past admin) ---
4071-
describe('fail-closed floor — nothing else is admitted', () => {
4072-
it.each([
4073-
['manager', 'an app-registered name that is not an administrative grade'],
4074-
['administrator', 'a near-miss that is not the vocabulary'],
4075-
['adminx', 'a prefix collision'],
4076-
['', 'an empty role'],
4077-
])('refuses %j (%s)', async (role) => {
4078-
expect(await judge(makeEngine({ members: [memberRow(role)] }), 'org-1')).toBe(false);
4079-
});
4080-
4081-
it.each([
4082-
[null, 'null'],
4083-
[undefined, 'undefined'],
4084-
[42, 'a number'],
4085-
[{ role: 'owner' }, 'an object that merely mentions owner'],
4086-
])('refuses a non-string role (%s: %s)', async (role) => {
4087-
expect(await judge(makeEngine({ members: [memberRow(role)] }), 'org-1')).toBe(false);
4088-
});
4089-
4090-
it('refuses when the user has no membership row at all', async () => {
4091-
expect(await judge(makeEngine({ members: [] }), 'org-1')).toBe(false);
4092-
});
4093-
4094-
it('refuses when the engine read throws (fail CLOSED — ADR-0024)', async () => {
4095-
expect(await judge(makeEngine({ throws: true }), 'org-1')).toBe(false);
4039+
it('admits a platform admin who has NO membership row at all', async () => {
4040+
expect(await judge(makeEngine({ platformAdmin: true, members: [] }))).toBe(true);
40964041
});
40974042
});
40984043

4099-
// -- (4) org scoping and the untouched platform-admin half -----------------
4100-
describe('scoping and the platform-admin half (untouched by #5942)', () => {
4101-
it('judges only the ACTIVE org when one is set', async () => {
4102-
const engine = makeEngine({
4103-
members: [
4104-
{ id: 'm-1', user_id: 'u-1', organization_id: 'org-other', role: 'Owner' },
4105-
{ id: 'm-2', user_id: 'u-1', organization_id: 'org-1', role: 'member' },
4106-
],
4107-
});
4108-
// Administrative elsewhere, plain member here → refused for org-1 …
4109-
expect(await judge(engine, 'org-1')).toBe(false);
4110-
// … and admitted when that other org is the active one.
4111-
expect(await judge(engine, 'org-other')).toBe(true);
4044+
// -- (3) fail-closed floor -------------------------------------------------
4045+
describe('fail-closed floor (ADR-0024)', () => {
4046+
it('refuses a plain member with no grant', async () => {
4047+
expect(await judge(makeEngine({ members: [memberRow('member')] }))).toBe(false);
41124048
});
41134049

4114-
it('accepts an administrative membership in ANY org when no active org is set', async () => {
4115-
const engine = makeEngine({
4116-
members: [{ id: 'm-1', user_id: 'u-1', organization_id: 'org-other', role: 'ADMIN' }],
4117-
});
4118-
expect(await judge(engine, undefined)).toBe(true);
4050+
it('refuses when the admin_full_access grant is ORG-SCOPED, not platform-wide', async () => {
4051+
// `organization_id != null` is an org-scoped assignment — ADR-0068 D2
4052+
// reads only the org-less link as platform admin.
4053+
expect(await judge(makeEngine({ platformAdmin: true, orgScopedGrant: true }))).toBe(false);
41194054
});
41204055

4121-
it('still admits a platform admin whose membership is a plain member', async () => {
4122-
const engine = makeEngine({ platformAdmin: true, members: [memberRow('member')] });
4123-
expect(await judge(engine, 'org-1')).toBe(true);
4056+
it('refuses when the engine read throws (fail CLOSED)', async () => {
4057+
expect(await judge(makeEngine({ throws: true, platformAdmin: true }))).toBe(false);
41244058
});
41254059

4126-
it('still refuses a non-platform-admin with no administrative membership', async () => {
4127-
const engine = makeEngine({ platformAdmin: false, members: [memberRow('member')] });
4128-
expect(await judge(engine, 'org-1')).toBe(false);
4060+
it('refuses an empty user id without reading anything', async () => {
4061+
const engine = makeEngine({ platformAdmin: true });
4062+
expect(await judge(engine, '')).toBe(false);
4063+
expect(engine.find).not.toHaveBeenCalled();
41294064
});
41304065
});
41314066
});

0 commit comments

Comments
 (0)