diff --git a/.changeset/sso-register-platform-admin-only.md b/.changeset/sso-register-platform-admin-only.md new file mode 100644 index 0000000000..f34bf8b56d --- /dev/null +++ b/.changeset/sso-register-platform-admin-only.md @@ -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. diff --git a/packages/plugins/plugin-auth/src/auth-manager.test.ts b/packages/plugins/plugin-auth/src/auth-manager.test.ts index 29873b7a3d..e50da10dd1 100644 --- a/packages/plugins/plugin-auth/src/auth-manager.test.ts +++ b/packages/plugins/plugin-auth/src/auth-manager.test.ts @@ -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('isOrgOrPlatformAdmin – one grade ruler for "is this membership an admin" (#5942)', () => { +describe('isPlatformAdminUserId – the /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' }]; @@ -3995,12 +3987,8 @@ 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 => { + /** The criterion, invoked exactly as the `/sso/register` hook invokes it. */ + const judge = async (engine: any, userId = 'u-1'): Promise => { const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); const manager = new AuthManager({ secret: SECRET, @@ -4008,7 +3996,7 @@ describe('isOrgOrPlatformAdmin – one grade ruler for "is this membership an ad dataEngine: engine, }); warn.mockRestore(); - return (manager as any).isOrgOrPlatformAdmin(userId, activeOrgId); + return (manager as any).isPlatformAdminUserId(userId); }; const memberRow = (role: unknown) => ({ @@ -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(); }); }); }); diff --git a/packages/plugins/plugin-auth/src/auth-manager.ts b/packages/plugins/plugin-auth/src/auth-manager.ts index 24e74d27e8..3bec144079 100644 --- a/packages/plugins/plugin-auth/src/auth-manager.ts +++ b/packages/plugins/plugin-auth/src/auth-manager.ts @@ -42,7 +42,6 @@ import { import { invitationRoleCapFailure, isPlainMemberInvitation, - isOrgAdminGrade, } from './invitation-role-cap.js'; import { DEFAULT_CREATOR_ROLE, @@ -1519,28 +1518,42 @@ export class AuthManager { // fall through — the vendor still performs the revoke itself } - // ── ADR-0024: admin-gate self-service SSO provider registration ── + // ── ADR-0024 + ADR-0068 D4: registering an identity provider is ── + // a PLATFORM-OPERATOR action. + // // `@better-auth/sso`'s POST /sso/register only checks org-admin when // `body.organizationId` is present (index.mjs: `if (ctx.body // .organizationId) { … hasOrgAdminRole … }`). A GLOBAL (org-less) // provider therefore passes with nothing but a valid session — so any // authenticated member can register an env-wide external IdP, a JIT- - // provisioning / login-routing vector. Require the caller to be a - // platform admin OR an owner/admin of their active org, regardless of - // whether `organizationId` is supplied. Unauthenticated requests fall - // through to better-auth's `sessionMiddleware` (→ 401). Fail-CLOSED: - // an unverifiable actor is denied. (D5.1's `/oauth2/authorize` gate is - // a different surface — the OP issuing codes, not the env's RP config.) + // provisioning / login-routing vector. So ObjectStack decides the + // authorization here, regardless of whether `organizationId` is + // supplied. + // + // [#10009] The admit set is PLATFORM ADMIN ONLY — the same posture + // #9653 landed on the `/admin/sso/*` bridges. It previously ALSO + // admitted an owner/admin of the caller's active org, and that gap + // made the bridge tightening honest labelling rather than a boundary: + // one principal, refused 403 at `/admin/sso/register` and admitted + // here, for the same underlying registration. Maintainer ruling + // 2026-08-20 closed the wider door (ADR-0068 D4 — platform-operator + // actions gate on the platform admin, sole operator). An org-scoped + // self-serve channel for org-scoped providers is a deliberate FUTURE + // ruling if multi-org IdP self-serve becomes a product goal — it is + // not the vendor default, inherited by omission. + // + // Unauthenticated requests fall through to better-auth's + // `sessionMiddleware` (→ 401). Fail-CLOSED: an unverifiable actor is + // denied. (D5.1's `/oauth2/authorize` gate is a different surface — + // the OP issuing codes, not the env's RP config.) if (ctx?.path === '/sso/register') { const actor = await this.resolveActor(ctx); if (actor?.userId) { - const ok = await this.isOrgOrPlatformAdmin(actor.userId, actor.activeOrgId); + const ok = await this.isPlatformAdminUserId(actor.userId); if (!ok) { const { APIError } = await import('better-auth/api'); throw new APIError('FORBIDDEN', { - message: - 'Only an organization owner/admin or a platform admin can ' + - 'register an SSO provider.', + message: 'Only a platform admin can register an SSO provider.', code: 'SSO_REGISTER_FORBIDDEN', }); } @@ -4357,10 +4370,14 @@ export class AuthManager { * permission set with `organization_id = null` (seeded by * `bootstrapPlatformAdmin`)? * - * Deliberately NARROWER than {@link isOrgOrPlatformAdmin}: it does not admit - * organization owners/admins. Platform-admin routes must not be reachable by - * whoever happens to own an org (ADR-0068), so the two questions stay two - * methods. + * Deliberately does NOT admit organization owners/admins. Platform-admin + * routes must not be reachable by whoever happens to own an org (ADR-0068). + * [#10009] This replaced an `isOrgOrPlatformAdmin` predicate that admitted + * both; once `/sso/register` stopped asking the org question, that wider + * predicate had no caller left and was removed rather than parked. + * + * ⛔ The legacy `user.role === 'admin'` scalar is NOT consulted here. This + * asks the permission-set question only — the channel ADR-0068 D2 keeps. * * Reads through `withSystemReadContext` so the lookups are not themselves * RLS-scoped to the acting — possibly non-privileged — user, and fails CLOSED @@ -4392,69 +4409,6 @@ export class AuthManager { } } - /** - * True when `userId` is a platform admin (a `sys_user_permission_set` row - * pointing at `admin_full_access` with `organization_id = null`) OR an - * owner/admin member of `activeOrgId` (any org membership with role - * owner/admin when no active org is set). Reads through - * `withSystemReadContext` so the lookups are not themselves RLS-scoped to the - * acting (possibly non-privileged) user. Fails CLOSED (returns false) on any - * lookup error — this backs a security gate, so an unverifiable actor must - * never pass. - * - * [#5942] The membership half asks {@link isOrgAdminGrade} — the single grade - * ladder in `invitation-role-cap.ts`, shared with the break-glass ban guard — - * so "which membership is an administrator" has exactly one answer inside - * plugin-auth. The platform-admin half above is unchanged and still has its - * own derivations elsewhere (`resolve-authz-context.ts` is authoritative). - */ - private async isOrgOrPlatformAdmin( - userId: string, - activeOrgId?: string, - ): Promise { - const engine = this.getDataEngine(); - if (!engine) return false; - const sys = withSystemReadContext(engine); - try { - // 1) platform admin — admin_full_access permission set, org-less link. - const links = await sys.find('sys_user_permission_set', { - where: { user_id: userId }, - limit: 50, - }); - const platformLinks = (Array.isArray(links) ? links : []).filter( - (l: any) => !l.organization_id, - ); - if (platformLinks.length) { - const sets = await sys.find('sys_permission_set', { limit: 50 }); - const adminSet = (Array.isArray(sets) ? sets : []).find( - (r: any) => r.name === 'admin_full_access', - ); - if (adminSet && platformLinks.some((l: any) => l.permission_set_id === adminSet.id)) { - return true; - } - } - // 2) org owner/admin — membership role in the active org (or any org). - const where: any = { user_id: userId }; - if (activeOrgId) where.organization_id = activeOrgId; - const members = await sys.find('sys_member', { where, limit: 10 }); - for (const m of (Array.isArray(members) ? members : [])) { - // [#5942] The ONE grade ladder answers "does this membership administer - // the org" — never a hand-copied `role === 'owner' || role === 'admin'`. - // The copy that used to live here was case-SENSITIVE and string-only, so - // a `sys_member.role` of `Owner` / `ADMIN` / `['owner']` was refused - // here while `last-admin-ban-guard.ts` — same question, same ladder — - // counted that row AS an administrator. Two spellings of one security - // question cannot disagree if there is only one spelling. - if (isOrgAdminGrade(m?.role)) { - return true; - } - } - return false; - } catch { - return false; - } - } - /** * [#8289] Answer `/organization/remove-member`'s PERMISSION denial with the * `403 YOU_ARE_NOT_ALLOWED_TO_*` envelope its siblings use, ahead of the diff --git a/packages/plugins/plugin-auth/src/last-admin-guard.ts b/packages/plugins/plugin-auth/src/last-admin-guard.ts index 3de6a20ecc..6d0456099d 100644 --- a/packages/plugins/plugin-auth/src/last-admin-guard.ts +++ b/packages/plugins/plugin-auth/src/last-admin-guard.ts @@ -91,8 +91,11 @@ * * ## What counts as an administrator * - * Exactly what `AuthManager.isOrgOrPlatformAdmin` (the repo's existing - * admin-gate answer) counts, enumerated in the opposite direction: + * The two grades below are what the ADR-0024 `/sso/register` admin gate counted + * until #10009 narrowed that gate to platform-admin-only and removed its + * `AuthManager.isOrgOrPlatformAdmin` predicate. That predicate is gone; this + * guard is unchanged and still counts both, so the definition stands on its own + * here, enumerated in the opposite direction: * * 1. **platform admin** — an UNSCOPED (`organization_id = null`), in-window * (ADR-0091) `sys_user_permission_set` grant of `admin_full_access`. This diff --git a/packages/plugins/plugin-auth/src/sso-register-platform-admin-gate.test.ts b/packages/plugins/plugin-auth/src/sso-register-platform-admin-gate.test.ts new file mode 100644 index 0000000000..79a27c678c --- /dev/null +++ b/packages/plugins/plugin-auth/src/sso-register-platform-admin-gate.test.ts @@ -0,0 +1,389 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#10009] The DIRECT `POST /sso/register` surface admits PLATFORM ADMINS ONLY. + * + * ## What this file pins, and why it exists at all + * + * `@better-auth/sso`'s own endpoint is served by the catch-all, and the ADR-0024 + * before-hook in `auth-manager.ts` is the only thing deciding authorization on + * it. Until this card that hook admitted *platform admin OR org owner/admin*, + * while the `/admin/sso/*` bridges (#9653) admitted platform admins only — two + * doors onto one operation with two different answers, which made the bridge + * tightening honest labelling rather than a boundary. The 2026-08-20 maintainer + * ruling closed the wider door (ADR-0068 D4: registering an identity provider is + * a platform-operator action). + * + * The hook had **no test pins at all** before this file: `SSO_REGISTER_FORBIDDEN` + * appeared nowhere outside its own `throw`. So the pins below are deliberately + * TWO-DIRECTIONAL — a hook that refused *everyone* would satisfy a refusal-only + * suite perfectly, and that failure mode is invisible from the refusal side. + * + * ① an org OWNER who is not a platform admin is REFUSED (403 + + * `SSO_REGISTER_FORBIDDEN` — ADR-0112 asserts code AND status, never one + * alone); + * ② a platform admin is ADMITTED — proven by the request reaching the + * VENDOR's own business validation. + * + * ## How admission is proven without a network + * + * `providerId: 'credential'` is permanently in `@better-auth/sso`'s reserved + * set, and the reserved-id refusal sits AFTER the vendor's whole authorization + * prologue and BEFORE any endpoint-URL validation or discovery fetch. So a + * `422 /reserved/` means the caller cleared BOTH the ObjectStack hook and the + * vendor's own gates — admission, measured offline. (The same discipline + * `admin-sso-bridge-gate.test.ts` uses for the vendor-posture measurement.) + * + * ## The grant is the ADR-0068 one, deliberately + * + * The platform admin is made one the way a real deployment does it — a + * `sys_user_permission_set` row pointing at the `admin_full_access` + * `sys_permission_set`, with `organization_id = null` — and the case ASSERTS + * that the legacy `sys_user.role` scalar is NOT `'admin'`. Without that second + * assertion the suite could pass while riding the retired D2 channel, which is + * precisely the channel this family is closing; the pin would then survive the + * removal of the thing it exists to protect. + * + * ## Fixture note + * + * The RBAC objects (`sys_permission_set`, `sys_user_permission_set`) live in + * `@objectstack/plugin-security`. They are declared locally here — the + * `last-admin-guard.test.ts` precedent — with only the columns the judge reads, + * so a fixture does not add a dependency edge to plugin-auth. Everything else is + * real: a real ObjectQL engine over real better-sqlite3, the real `AuthManager` + * with the real `sso()` plugin, real sign-up and real session cookies. + */ + +import { describe, it, expect, afterEach, vi } from 'vitest'; +import { Hono } from 'hono'; +import { ObjectQL } from '@objectstack/objectql'; +import { SqlDriver } from '@objectstack/driver-sql'; +import { ADMIN_FULL_ACCESS } from '@objectstack/spec/identity'; +import { AuthManager } from './auth-manager.js'; +import { AuthPlugin } from './auth-plugin.js'; +import { createTenancyService } from './tenancy-service.js'; +import type { PluginContext } from '@objectstack/core'; +import { + SysUser, + SysSession, + SysAccount, + SysVerification, + SysOrganization, + SysMember, + SysInvitation, + SysTeam, + SysTeamMember, + SysSsoProvider, +} from '@objectstack/platform-objects'; + +const BASE = 'http://localhost:3000'; +const AUTH = `${BASE}/api/v1/auth`; +const SECRET = 'test-secret-at-least-32-chars-long-10009'; +const SYSTEM = { context: { isSystem: true } } as const; + +const sysPermissionSet = { + name: 'sys_permission_set', + label: 'Permission Set', + fields: { + id: { name: 'id', type: 'text' as const, primaryKey: true }, + name: { name: 'name', type: 'text' as const }, + }, +}; + +const sysUserPermissionSet = { + name: 'sys_user_permission_set', + label: 'User Permission Set', + fields: { + id: { name: 'id', type: 'text' as const, primaryKey: true }, + user_id: { name: 'user_id', type: 'text' as const }, + permission_set_id: { name: 'permission_set_id', type: 'text' as const }, + organization_id: { name: 'organization_id', type: 'text' as const }, + }, +}; + +const engines: ObjectQL[] = []; +afterEach(async () => { + while (engines.length) { + const e = engines.pop(); + try { + await (e as unknown as { destroy?(): Promise })?.destroy?.(); + } catch { + /* noop */ + } + } +}); + +async function bootEngine(): Promise { + const engine = new ObjectQL(); + engines.push(engine); + engine.registerDriver( + new SqlDriver({ + client: 'better-sqlite3', + connection: { filename: ':memory:' }, + useNullAsDefault: true, + }), + true, + ); + await engine.init(); + const objects = [ + SysUser, SysSession, SysAccount, SysVerification, SysOrganization, + SysMember, SysInvitation, SysTeam, SysTeamMember, SysSsoProvider, + ]; + for (const object of objects) { + engine.registry.registerObject(object as never, '@objectstack/plugin-auth'); + } + engine.registry.registerObject(sysPermissionSet as never, '@objectstack/plugin-auth'); + engine.registry.registerObject(sysUserPermissionSet as never, '@objectstack/plugin-auth'); + await engine.syncSchemas(); + return engine; +} + +function makeManager(engine: ObjectQL): AuthManager { + return new AuthManager({ + secret: SECRET, + baseUrl: BASE, + dataEngine: engine as never, + // ADR-0093 D5 — `organization/create` is gated by the EFFECTIVE tenancy + // posture, so an org-owner principal needs a deployment that permits orgs. + getTenancy: () => createTenancyService({ requested: 'isolated', probeIsolation: () => true }), + plugins: { organization: true, sso: true }, + } as never); +} + +const cookiesFrom = (res: Response): string => + (res.headers.get('set-cookie') ?? '') + .split(',') + .map((c) => c.split(';')[0].trim()) + .filter(Boolean) + .join('; '); + +async function signUp( + send: (r: Request) => Promise, + email: string, +): Promise { + const res = await send( + new Request(`${AUTH}/sign-up/email`, { + method: 'POST', + headers: { 'content-type': 'application/json', origin: BASE }, + body: JSON.stringify({ email, password: 'S3cure!Passw0rd-10009', name: 'Probe' }), + }), + ); + expect(res.status, `sign-up failed: ${await res.clone().text()}`).toBeLessThan(400); + return cookiesFrom(res); +} + +async function createOrg( + send: (r: Request) => Promise, + cookie: string, + slug: string, +): Promise { + const res = await send( + new Request(`${AUTH}/organization/create`, { + method: 'POST', + headers: { 'content-type': 'application/json', origin: BASE, cookie }, + body: JSON.stringify({ name: 'Probe Org', slug }), + }), + ); + expect(res.status, `organization/create failed: ${await res.clone().text()}`).toBeLessThan(400); + const body = (await res.json()) as Record; + const id = (body?.id ?? body?.organization?.id) as string; + expect(id, 'organization/create must return an organization id').toBeTruthy(); + return id; +} + +/** Grant platform admin the ADR-0068 D2 way: an ORG-LESS `admin_full_access` link. */ +async function grantPlatformAdmin(engine: ObjectQL, userId: string): Promise { + await engine.insert( + 'sys_permission_set', + { id: 'ps_admin_full_access', name: ADMIN_FULL_ACCESS }, + SYSTEM as never, + ); + await engine.insert( + 'sys_user_permission_set', + { + id: 'ups_platform_admin', + user_id: userId, + permission_set_id: 'ps_admin_full_access', + organization_id: null, + }, + SYSTEM as never, + ); +} + +async function userIdOf(engine: ObjectQL, email: string): Promise { + const row = await engine.findOne('sys_user', { where: { email } }, SYSTEM as never); + expect(row, `no sys_user row for ${email}`).toBeTruthy(); + return String((row as Record).id); +} + +/** + * ⚠️ The retired channel must NOT be what admits the caller. ADR-0068 D2 stopped + * writing `sys_user.role = 'admin'` for a platform admin; a fixture that carried + * it could pass this whole file while the permission-set read was broken. + */ +async function expectLegacyRoleScalarIsNotAdmin(engine: ObjectQL, email: string): Promise { + const row = (await engine.findOne('sys_user', { where: { email } }, SYSTEM as never)) as + | Record + | null; + expect(row?.role, 'fixture must not ride the retired `role` scalar').not.toBe('admin'); +} + +/** + * `credential` is permanently reserved by `@better-auth/sso`; the reserved-id + * refusal sits after the vendor's authorization prologue, so reaching it proves + * admission without any network. + */ +const REGISTER_BODY = { + providerId: 'credential', + issuer: 'https://idp.example.com', + domain: 'example.com', + oidcConfig: { + clientId: 'cid', + clientSecret: 'csecret', + scopes: ['openid', 'email', 'profile'], + mapping: { email: 'email', name: 'name' }, + }, +}; + +const postRegister = (send: (r: Request) => Promise, cookie?: string) => + send( + new Request(`${AUTH}/sso/register`, { + method: 'POST', + headers: { + 'content-type': 'application/json', + origin: BASE, + ...(cookie ? { cookie } : {}), + }, + body: JSON.stringify(REGISTER_BODY), + }), + ); + +describe('[#10009] direct /sso/register — the ADR-0024 before-hook admits platform admins only', () => { + it('① an org OWNER who is not a platform admin is REFUSED 403 SSO_REGISTER_FORBIDDEN', async () => { + const engine = await bootEngine(); + const manager = makeManager(engine); + const send = (r: Request) => manager.handleRequest(r); + + const email = 'orgowner@example.com'; + const cookie = await signUp(send, email); + const orgId = await createOrg(send, cookie, 'probe-org-10009'); + + // The principal really is an org OWNER — the fixture's claim, verified. + const member = (await engine.findOne( + 'sys_member', + { where: { organization_id: orgId } }, + SYSTEM as never, + )) as Record; + expect(member?.role, 'the org creator must be an owner').toBe('owner'); + + // …and really is NOT a platform admin, by either channel. + await expectLegacyRoleScalarIsNotAdmin(engine, email); + const grants = await engine.find( + 'sys_user_permission_set', + { where: { user_id: await userIdOf(engine, email) } }, + SYSTEM as never, + ); + expect(grants, 'org owner must hold no platform grant').toHaveLength(0); + + const res = await postRegister(send, cookie); + const body = (await res.clone().json()) as Record; + + // ADR-0112: code AND status. A sibling card measured a real ablation where + // the status was unchanged and only the code moved, so neither alone is a + // sufficient assertion. + expect(res.status, await res.clone().text()).toBe(403); + expect(body?.code ?? body?.error?.code).toBe('SSO_REGISTER_FORBIDDEN'); + }); + + it('② a platform admin (ADR-0068 grant, legacy role scalar NOT admin) is ADMITTED', async () => { + const engine = await bootEngine(); + const manager = makeManager(engine); + const send = (r: Request) => manager.handleRequest(r); + + const email = 'platformadmin@example.com'; + const cookie = await signUp(send, email); + await grantPlatformAdmin(engine, await userIdOf(engine, email)); + await expectLegacyRoleScalarIsNotAdmin(engine, email); + + const res = await postRegister(send, cookie); + const text = await res.clone().text(); + + // Cleared the ObjectStack hook AND the vendor's authorization prologue: + // the answer is the vendor's own business validation, not a refusal. + expect(res.status, text).toBe(422); + expect(text).toMatch(/reserved/i); + expect(text).not.toMatch(/SSO_REGISTER_FORBIDDEN/); + }); + + it('③ an anonymous caller still falls through to the vendor session gate (401)', async () => { + const engine = await bootEngine(); + const manager = makeManager(engine); + const send = (r: Request) => manager.handleRequest(r); + + const res = await postRegister(send); + // The hook deliberately does not answer for the unauthenticated case — + // `sessionMiddleware` does, so the 401 stays the vendor's. + expect(res.status).toBe(401); + }); +}); + +// --------------------------------------------------------------------------- +// The alignment this card is FOR: one principal, both doors, one answer. +// --------------------------------------------------------------------------- + +const mockCtx = (): PluginContext => + ({ + registerService: vi.fn(), + getService: vi.fn((name: string) => (name === 'manifest' ? { register: vi.fn() } : undefined)), + getServices: vi.fn(() => new Map()), + hook: vi.fn(), + trigger: vi.fn(), + logger: { info: vi.fn(), error: vi.fn(), warn: vi.fn(), debug: vi.fn() }, + getKernel: vi.fn(), + }) as any; + +/** Mount the REAL `/admin/sso/*` bridges over the REAL auth manager. */ +async function mountBridges(manager: AuthManager): Promise { + const app = new Hono(); + const ctx = mockCtx(); + const plugin = new AuthPlugin({ secret: SECRET }); + await plugin.init(ctx); + (plugin as any).authManager = manager; + (plugin as any).registerAuthRoutes({ getRawApp: () => app, getPort: () => 0 }, ctx); + return app; +} + +describe('[#10009] the two doors onto SSO registration now answer the same org owner alike', () => { + it('org owner: refused at the /admin/sso/register bridge AND at the direct /sso/register', async () => { + const engine = await bootEngine(); + const manager = makeManager(engine); + const send = (r: Request) => manager.handleRequest(r); + + const cookie = await signUp(send, 'orgowner@example.com'); + await createOrg(send, cookie, 'probe-org-10009-both'); + + // Door 1 — the #9653 bridge (unchanged by this card). + const app = await mountBridges(manager); + const bridge = await app.request(`${AUTH}/admin/sso/register`, { + method: 'POST', + headers: { 'content-type': 'application/json', origin: BASE, cookie }, + body: JSON.stringify({ + providerId: 'acme', + issuer: 'https://idp.acme.example', + domain: 'acme.example', + clientId: 'cid', + clientSecret: 'csecret', + }), + }); + const bridgeBody = (await bridge.clone().json()) as Record; + expect(bridge.status).toBe(403); + expect(bridgeBody?.error?.code).toBe('PERMISSION_DENIED'); + + // Door 2 — the direct endpoint. Before this card it answered 422 (admitted); + // the divergence is what #10009 recorded. + const direct = await postRegister(send, cookie); + const directBody = (await direct.clone().json()) as Record; + expect(direct.status, await direct.clone().text()).toBe(403); + expect(directBody?.code ?? directBody?.error?.code).toBe('SSO_REGISTER_FORBIDDEN'); + }); +});