diff --git a/.changeset/adr-0024-sso-register-admin-gate.md b/.changeset/adr-0024-sso-register-admin-gate.md new file mode 100644 index 0000000000..70a6cdc518 --- /dev/null +++ b/.changeset/adr-0024-sso-register-admin-gate.md @@ -0,0 +1,12 @@ +--- +'@objectstack/plugin-auth': patch +--- + +Auth: admin-gate self-service SSO provider registration + default-role JIT (ADR-0024 / cloud#551) + +`@better-auth/sso`'s `POST /sso/register` only enforces org-admin when `body.organizationId` is supplied — a **global** (org-less) provider passed on nothing but a valid session, so any authenticated env member could register an env-wide external IdP (a JIT-provisioning / login-routing vector). This closed the "registerSSOProvider is admin-only" requirement of ADR-0024's first slice. + +- **plugin-auth**: a `before`-hook on `/sso/register` now requires the caller to be a platform admin OR an owner/admin of their active org, regardless of `organizationId`. Fail-closed; unauthenticated requests still fall through to `sessionMiddleware` (→ 401). New helpers `resolveActor()` (hook-order-independent cookie/bearer resolution) and `isOrgOrPlatformAdmin()` (mirrors `customSession`'s role derivation; reads via `withSystemReadContext`). +- **plugin-auth**: `sso()` now receives `organizationProvisioning.defaultRole:'member'` so a first-time federated login lands with an explicit role (over SecurityPlugin's `member_default` baseline). + +Additive and fail-closed — no behavior change for legitimate admins. The SSO mechanism stays framework-open (no identity-governance added). diff --git a/packages/plugins/plugin-auth/src/auth-manager.ts b/packages/plugins/plugin-auth/src/auth-manager.ts index e8c653dbb8..f703f63529 100644 --- a/packages/plugins/plugin-auth/src/auth-manager.ts +++ b/packages/plugins/plugin-auth/src/auth-manager.ts @@ -551,6 +551,35 @@ export class AuthManager { // sees `userCount > 0` and the toggle is enforced again. hooks: { before: createAuthMiddleware(async (ctx: any) => { + // ── ADR-0024: admin-gate self-service SSO provider registration ── + // `@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.) + if (ctx?.path === '/sso/register') { + const actor = await this.resolveActor(ctx); + if (actor?.userId) { + const ok = await this.isOrgOrPlatformAdmin(actor.userId, actor.activeOrgId); + 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.', + code: 'SSO_REGISTER_FORBIDDEN', + }); + } + } + return; + } + // ── D5.1: cloud-as-IdP authorization gate ─────────────────── // On the OIDC OP's /oauth2/authorize, when a host gate is set // (cloud control plane), an AUTHENTICATED subject must be @@ -1198,11 +1227,17 @@ export class AuthManager { // model and accepts NO `schema` option (verified against 1.6.20 — no // mergeSchema, runtime never reads options.schema). Its table mapping to // `sys_sso_provider` must therefore be resolved by the better-auth adapter - // / a global model map, not per-plugin here (see AUTH_SSO_PROVIDER_SCHEMA; - // TODO confirm the resolved table name in E2E). - // provisionUser / organizationProvisioning will assign a default env role - // on first federated login (ADR-0024 V1); JIT works via account linking. - plugins.push(sso()); + // / a global model map, not per-plugin here (see AUTH_SSO_PROVIDER_SCHEMA). + // + // `organizationProvisioning.defaultRole` (ADR-0024 V1): a first-time + // federated login is JIT-provisioned into the user's domain-matched org + // with this role, so a member who arrives via an external IdP lands with + // an explicit default role (belt-and-suspenders over SecurityPlugin's + // `member_default` fallback, which already grants baseline access to any + // authenticated user). Requires the `organization` plugin — on by default. + plugins.push(sso({ + organizationProvisioning: { defaultRole: 'member' }, + })); } // External SCIM 2.0 Service Provider (@better-auth/scim, MIT) — lets an @@ -1773,6 +1808,116 @@ export class AuthManager { } } + /** + * Resolve the acting user (+ their active org) for a before-hook gate, + * hook-order-independent. Tries the standard cookie session first, then falls + * back to explicit token resolution (bearer or the session cookie's token + * part) — the bearer plugin may convert `Authorization: Bearer` to a session + * AFTER this global before-hook runs. Returns `null` when no valid session + * can be resolved (→ caller lets `sessionMiddleware` issue the 401). + */ + private async resolveActor( + ctx: any, + ): Promise<{ userId: string; activeOrgId?: string } | null> { + try { + const { getSessionFromCtx } = await import('better-auth/api'); + const s: any = await getSessionFromCtx(ctx as any); + const userId = s?.user?.id ?? s?.session?.userId; + if (userId) { + return { + userId: String(userId), + activeOrgId: + s?.session?.activeOrganizationId ?? s?.activeOrganizationId ?? undefined, + }; + } + } catch { /* fall through to explicit token resolution */ } + try { + const hdr = (k: string): string => + ((ctx?.headers?.get?.(k) ?? ctx?.request?.headers?.get?.(k)) as string) || ''; + let token: string | undefined; + const bm = /^Bearer\s+(.+)$/i.exec(hdr('authorization')); + if (bm?.[1]) token = bm[1].trim(); + if (!token) { + const cm = /(?:^|;\s*)(?:__Secure-|__Host-)?better-auth\.session_token=([^;]+)/.exec(hdr('cookie')); + if (cm?.[1]) token = decodeURIComponent(cm[1]).split('.')[0]; + } + if (token) { + const sess: any = await (ctx as any).context.adapter.findOne({ + model: 'session', + where: [{ field: 'token', value: token }], + }); + const exp = sess?.expiresAt ?? sess?.expires_at; + if (sess && (!exp || new Date(exp).getTime() > Date.now())) { + const userId = String(sess.userId ?? sess.user_id ?? ''); + if (userId) { + return { + userId, + activeOrgId: + sess.activeOrganizationId ?? sess.active_organization_id ?? undefined, + }; + } + } + } + } catch { /* unresolved → null */ } + return null; + } + + /** + * 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). Mirrors the role-derivation in + * `customSession`; 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. + */ + 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 : [])) { + const raw = typeof m?.role === 'string' ? m.role : ''; + if ( + raw + .split(',') + .map((s: string) => s.trim()) + .some((r: string) => r === 'owner' || r === 'admin') + ) { + return true; + } + } + return false; + } catch { + return false; + } + } + /** * Compose the framework's identity-source stamp (`account.create.after`) * with any host-supplied `databaseHooks`, preserving BOTH. The cloud passes