Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .changeset/sso-register-platform-admin-only.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
---
"@objectstack/plugin-auth": patch
---

**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).

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`).

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.

The direct endpoint also gains its first test pins; the now-callerless `isOrgOrPlatformAdmin` predicate was removed rather than left dead.
187 changes: 61 additions & 126 deletions packages/plugins/plugin-auth/src/auth-manager.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -3940,47 +3940,39 @@ describe('getPublicConfig devSeedAdmin (dev-only login hint)', () => {
});

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

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

/** The gate's criterion, invoked exactly as the `/sso/register` hook does. */
const judge = async (
engine: any,
activeOrgId?: string,
userId = 'u-1',
): Promise<boolean> => {
/** The criterion, invoked exactly as the `/sso/register` hook invokes it. */
const judge = async (engine: any, userId = 'u-1'): Promise<boolean> => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
const manager = new AuthManager({
secret: SECRET,
baseUrl: 'http://localhost:3000',
dataEngine: engine,
});
warn.mockRestore();
return (manager as any).isOrgOrPlatformAdmin(userId, activeOrgId);
return (manager as any).isPlatformAdminUserId(userId);
};

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

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

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

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

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

it.each([
['owner,member', true],
['member,admin', true],
[' admin ', true],
['member,delegated_admin', false],
] as const)(
'grades the comma/whitespace spelling %j as admin=%s (already true before #5942)',
async (role, expected) => {
expect(await judge(makeEngine({ members: [memberRow(role)] }), 'org-1')).toBe(expected);
},
);
});

// -- (3) non-administrative values still refused (no widening past admin) ---
describe('fail-closed floor — nothing else is admitted', () => {
it.each([
['manager', 'an app-registered name that is not an administrative grade'],
['administrator', 'a near-miss that is not the vocabulary'],
['adminx', 'a prefix collision'],
['', 'an empty role'],
])('refuses %j (%s)', async (role) => {
expect(await judge(makeEngine({ members: [memberRow(role)] }), 'org-1')).toBe(false);
});

it.each([
[null, 'null'],
[undefined, 'undefined'],
[42, 'a number'],
[{ role: 'owner' }, 'an object that merely mentions owner'],
])('refuses a non-string role (%s: %s)', async (role) => {
expect(await judge(makeEngine({ members: [memberRow(role)] }), 'org-1')).toBe(false);
});

it('refuses when the user has no membership row at all', async () => {
expect(await judge(makeEngine({ members: [] }), 'org-1')).toBe(false);
});

it('refuses when the engine read throws (fail CLOSED — ADR-0024)', async () => {
expect(await judge(makeEngine({ throws: true }), 'org-1')).toBe(false);
it('admits a platform admin who has NO membership row at all', async () => {
expect(await judge(makeEngine({ platformAdmin: true, members: [] }))).toBe(true);
});
});

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

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

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

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