diff --git a/.changeset/21903-platform-admin-affordance-visibility.md b/.changeset/21903-platform-admin-affordance-visibility.md new file mode 100644 index 00000000000..1100cee5ef9 --- /dev/null +++ b/.changeset/21903-platform-admin-affordance-visibility.md @@ -0,0 +1,16 @@ +--- +"@objectstack/platform-objects": patch +--- + +The user, OAuth-application and SSO-provider actions whose endpoint admits only a platform administrator are now offered only to a platform administrator. + +Clause-②: no + +- These thirteen actions now declare `visible: 'current_user.isPlatformAdmin == true'`, composed with their existing terms: + - `sys_user`: `ban_user`, `unban_user`, `unlock_user`, `create_user`, `set_user_password`, `impersonate_user` and `set_user_manager`; + - `sys_oauth_application`: `disable_oauth_application` and `enable_oauth_application`; + - `sys_sso_provider`: `register_sso_provider`, `register_saml_provider`, `request_domain_verification` and `verify_domain`. +- Where an action also carries `requiresFeature`, the feature gate composes onto it at parse time. For example, `ban_user` now serves `(current_user.isPlatformAdmin == true) && features.admin == true`. +- Each endpoint (`/api/v1/auth/admin/*`) has always admitted a platform administrator alone (ADR-0068) and answered every other caller, org owners and admins included, with 403 `PERMISSION_DENIED`. Before this change the buttons were still shown to those callers. +- `create_oauth_application`, `rotate_client_secret`, `delete_oauth_application` and `delete_sso_provider` are unchanged: their endpoints authorize the signed-in user or the record's owner, not the platform administrator. +- ⛔ Nothing you author changes. The endpoints and the callers they admit are unchanged, and no key, export or parameter is added. The actions' labels are unchanged. diff --git a/packages/platform-objects/src/identity/action-predicate-sparse-face.test.ts b/packages/platform-objects/src/identity/action-predicate-sparse-face.test.ts index 1f5527212f3..1b40d2692b6 100644 --- a/packages/platform-objects/src/identity/action-predicate-sparse-face.test.ts +++ b/packages/platform-objects/src/identity/action-predicate-sparse-face.test.ts @@ -65,8 +65,18 @@ import { SysBusinessUnitMember } from './sys-business-unit-member.object.js'; * same reasoning as the all-on {@link FEATURES} below: a grade term that * answered false would short-circuit the composed `&&` and hide the record half * this sweep exists to test. + * + * For the same reason the principal also holds the platform-admin standing: + * the actions whose door runs the platform-admin gate lead with + * `current_user.isPlatformAdmin == true`, and a principal without it would + * short-circuit their record half just the same. It is bound the way the + * console binds it — the whole scope handed to the engine as `extra`, one + * subject under every alias — because under `user:` `@objectstack/formula` + * re-derives `isPlatformAdmin` from `positions`, and the only way to make that + * answer true there is the `'platform_admin'` position spelling the standing + * must never be read from. */ -const USER = { id: 'u1', email: 'me@example.com', positions: ['org_owner'] }; +const USER = { id: 'u1', email: 'me@example.com', positions: ['org_owner'], isPlatformAdmin: true }; /** * `defineObject` normalizes a CEL shorthand string into a `{dialect, source}` @@ -100,7 +110,10 @@ const FEATURES = { /** Evaluate through the canonical engine; a fault is reported, never thrown. */ function evaluate(source: string, record: Record): boolean | string { - const r = celEngine.evaluate({ dialect: 'cel', source }, { record, user: USER, extra: { features: FEATURES } }); + const r = celEngine.evaluate( + { dialect: 'cel', source }, + { record, extra: { current_user: USER, user: USER, ctx: { user: USER }, os: { user: USER }, features: FEATURES } }, + ); if (!r.ok) return `FAULT ${r.error.message.split('\n')[0].trim()}`; return typeof r.value === 'boolean' ? r.value : `NON-BOOLEAN ${JSON.stringify(r.value)}`; } diff --git a/packages/platform-objects/src/identity/sys-oauth-application.object.ts b/packages/platform-objects/src/identity/sys-oauth-application.object.ts index 1ff2c3f3558..b89dd6d5b3d 100644 --- a/packages/platform-objects/src/identity/sys-oauth-application.object.ts +++ b/packages/platform-objects/src/identity/sys-oauth-application.object.ts @@ -77,6 +77,17 @@ export const SysOauthApplication = ObjectSchema.create({ // The equality form also keeps the intended meaning of a null column — // never disabled, so Disable is offered and Enable is not. See // `materializeDeclaredFields` in `@objectstack/objectql` for the rule. + // + // Both toggle predicates also LEAD with the platform-admin standing + // (ADR-0068 D4): `toggle-disabled` runs the platform-admin gate and answers + // every other caller 403 `PERMISSION_DENIED`, so the pair is offered only to + // `current_user.isPlatformAdmin == true`, the ADR-0095 D3 PLATFORM_ADMIN + // rung that the session payload emits and the gate judges (⛔ never a + // `current_user.positions` read). The other three actions carry no such + // term, deliberately: `register` is a session-only self-service mount, and + // `rotate-secret` / `delete-client` are better-auth's own routes, which + // authorize the application's OWNER — gating them on the standing would + // hide a working affordance from the developer who registered the app. actions: [ { name: 'disable_oauth_application', @@ -97,7 +108,7 @@ export const SysOauthApplication = ObjectSchema.create({ description: 'Disable this OAuth application? Active access/refresh tokens issued to it will continue to be rejected at the token, authorize, and introspect endpoints. Existing integrations will stop working immediately.', successMessage: 'OAuth application disabled', refreshAfter: true, - visible: 'has(record.disabled) && record.disabled != true', + visible: 'current_user.isPlatformAdmin == true && has(record.disabled) && record.disabled != true', bodyExtra: { disabled: true }, params: [ { name: 'client_id', field: 'client_id', defaultFromRow: true, required: true }, @@ -118,7 +129,7 @@ export const SysOauthApplication = ObjectSchema.create({ description: 'Re-enable this OAuth application? Token issuance, authorization, and introspection will resume immediately.', successMessage: 'OAuth application enabled', refreshAfter: true, - visible: 'has(record.disabled) && record.disabled == true', + visible: 'current_user.isPlatformAdmin == true && has(record.disabled) && record.disabled == true', bodyExtra: { disabled: false }, params: [ { name: 'client_id', field: 'client_id', defaultFromRow: true, required: true }, diff --git a/packages/platform-objects/src/identity/sys-sso-provider.object.ts b/packages/platform-objects/src/identity/sys-sso-provider.object.ts index 7266f993679..2d94cf90507 100644 --- a/packages/platform-objects/src/identity/sys-sso-provider.object.ts +++ b/packages/platform-objects/src/identity/sys-sso-provider.object.ts @@ -73,6 +73,18 @@ export const SysSsoProvider = ObjectSchema.create({ // All mutations go through @better-auth/sso's endpoints under // /api/v1/auth/sso/* (register / delete-provider) rather than the generic // data layer, so server-side config validation + secret handling run. + // + // The four `/admin/sso/*` bridge actions are OFFERED only to the one + // standing their door admits (ADR-0068 D4): each bridge runs the + // platform-admin gate before it delegates and answers every other caller + // 403 `PERMISSION_DENIED`. So each authors + // `visible: 'current_user.isPlatformAdmin == true'`, the ADR-0095 D3 + // PLATFORM_ADMIN rung that the session payload emits and the gate judges + // (⛔ never a `current_user.positions` read). The object-level + // `manage_platform_settings` requirement above is a capability, not that + // rung, so it does not stand in for it. `delete_sso_provider` carries no + // such term: its door is @better-auth/sso's own `/sso/delete-provider`, + // which authorizes the provider's owner or an admin of its organization. actions: [ { name: 'register_sso_provider', @@ -90,6 +102,8 @@ export const SysSsoProvider = ObjectSchema.create({ // /sso/register would drop clientId/clientSecret (top-level → Zod-stripped) // and persist an unusable `oidc_config = null` provider. target: '/api/v1/auth/admin/sso/register', + // Platform-admin standing (ADR-0068 D4) — see the actions header above. + visible: 'current_user.isPlatformAdmin == true', refreshAfter: true, params: [ { name: 'providerId', label: 'Provider ID', type: 'text', required: true, helpText: 'Stable identifier, e.g. "okta" or "acme-entra".' }, @@ -130,6 +144,8 @@ export const SysSsoProvider = ObjectSchema.create({ // re-dispatches to /sso/register (admin gate runs). The response returns // the SP ACS + metadata URLs to configure on the IdP. target: '/api/v1/auth/admin/sso/register-saml', + // Platform-admin standing (ADR-0068 D4) — see the actions header above. + visible: 'current_user.isPlatformAdmin == true', refreshAfter: true, params: [ { name: 'providerId', label: 'Provider ID', type: 'text', required: true, helpText: 'Stable identifier, e.g. "acme-saml".' }, @@ -160,6 +176,8 @@ export const SysSsoProvider = ObjectSchema.create({ // feature is OFF the bridge returns a clear "not enabled for this // environment" error instead of a bare 404. target: '/api/v1/auth/admin/sso/request-domain-verification', + // Platform-admin standing (ADR-0068 D4) — see the actions header above. + visible: 'current_user.isPlatformAdmin == true', params: [ { name: 'providerId', field: 'provider_id', defaultFromRow: true, required: true }, { name: 'domain', field: 'domain', defaultFromRow: true, required: false }, @@ -188,6 +206,8 @@ export const SysSsoProvider = ObjectSchema.create({ // on success. Routed through the env bridge, which maps @better-auth/sso's // empty 204 / 502 into a clear success/error toast. target: '/api/v1/auth/admin/sso/verify-domain', + // Platform-admin standing (ADR-0068 D4) — see the actions header above. + visible: 'current_user.isPlatformAdmin == true', successMessage: 'Domain ownership verified', refreshAfter: true, params: [ diff --git a/packages/platform-objects/src/identity/sys-user-set-manager-action.test.ts b/packages/platform-objects/src/identity/sys-user-set-manager-action.test.ts index abcd7a1acc6..20dfa5af7a1 100644 --- a/packages/platform-objects/src/identity/sys-user-set-manager-action.test.ts +++ b/packages/platform-objects/src/identity/sys-user-set-manager-action.test.ts @@ -63,11 +63,21 @@ function sourceOf(raw: unknown): string | undefined { return undefined; } +/** + * The admin these verdicts are about: a PLATFORM admin, because the door's + * gate admits that standing alone and the predicate leads with it. Bound the + * way the console binds it — the whole scope handed to the engine as `extra`, + * one subject under every alias — and ⛔ not through `user:`, under which + * `@objectstack/formula` re-derives `isPlatformAdmin` from `positions`. + */ +const PLATFORM_ADMIN = { id: 'admin_1', isPlatformAdmin: true, positions: [] as string[] }; + /** Evaluate through the canonical engine; a fault is reported, never thrown. */ function evaluate(source: string, record: Record): boolean | string { + const s = PLATFORM_ADMIN; const r = celEngine.evaluate( { dialect: 'cel', source }, - { record, user: { id: 'admin_1' }, extra: { features: {} } }, + { record, extra: { current_user: s, user: s, ctx: { user: s }, os: { user: s }, features: {} } }, ); if (!r.ok) return `FAULT ${r.error.message.split('\n')[0].trim()}`; return typeof r.value === 'boolean' ? r.value : `NON-BOOLEAN ${JSON.stringify(r.value)}`; diff --git a/packages/platform-objects/src/identity/sys-user.object.ts b/packages/platform-objects/src/identity/sys-user.object.ts index a8d755bf535..0b9e8e738fb 100644 --- a/packages/platform-objects/src/identity/sys-user.object.ts +++ b/packages/platform-objects/src/identity/sys-user.object.ts @@ -115,6 +115,18 @@ export const SysUser = ObjectSchema.create({ // "More" menu) so platform admins can manage an account from either // the Users list or an open user record — without dropping to SQL or // a custom Setup wizard. + // + // Every action in this block, and `set_user_manager` below, is OFFERED + // only to the one standing its door admits (ADR-0068 D4): each endpoint + // runs the platform-admin gate first and answers every other caller — + // org owners and admins included — 403 `PERMISSION_DENIED`. So each one + // authors `visible: 'current_user.isPlatformAdmin == true'`, the ADR-0095 + // D3 PLATFORM_ADMIN posture rung that the session payload emits and the + // gate judges; `requiresFeature` composes onto it at parse time, e.g. + // `(current_user.isPlatformAdmin == true) && features.admin == true`. + // ⛔ Never `'platform_admin' in current_user.positions`: `EvalUserSchema` + // rules that standing is read from this key, never from the array. The + // repo-wide pin is `platform-admin-affordance-standing.test.ts`. { name: 'ban_user', label: 'Ban User', @@ -123,6 +135,8 @@ export const SysUser = ObjectSchema.create({ locations: ['list_item', 'record_header'], type: 'api', target: '/api/v1/auth/admin/ban-user', + // Platform-admin standing (ADR-0068 D4) — see the block header above. + visible: 'current_user.isPlatformAdmin == true', requiresFeature: 'admin', recordIdParam: 'userId', successMessage: 'User banned', @@ -149,6 +163,8 @@ export const SysUser = ObjectSchema.create({ locations: ['list_item', 'record_header'], type: 'api', target: '/api/v1/auth/admin/unban-user', + // Platform-admin standing (ADR-0068 D4) — see the block header above. + visible: 'current_user.isPlatformAdmin == true', requiresFeature: 'admin', recordIdParam: 'userId', successMessage: 'User unbanned', @@ -165,6 +181,8 @@ export const SysUser = ObjectSchema.create({ locations: ['list_item', 'record_header'], type: 'api', target: '/api/v1/auth/admin/unlock-user', + // Platform-admin standing (ADR-0068 D4) — see the block header above. + visible: 'current_user.isPlatformAdmin == true', requiresFeature: 'admin', recordIdParam: 'userId', successMessage: 'Account unlocked', @@ -185,6 +203,8 @@ export const SysUser = ObjectSchema.create({ locations: ['list_toolbar'], type: 'api', target: '/api/v1/auth/admin/create-user', + // Platform-admin standing (ADR-0068 D4) — see the block header above. + visible: 'current_user.isPlatformAdmin == true', requiresFeature: 'admin', successMessage: 'User created', refreshAfter: true, @@ -249,6 +269,8 @@ export const SysUser = ObjectSchema.create({ // legacy role scalar), can mint a temporary password, and stamps // must_change_password. target: '/api/v1/auth/admin/set-user-password', + // Platform-admin standing (ADR-0068 D4) — see the block header above. + visible: 'current_user.isPlatformAdmin == true', requiresFeature: 'admin', recordIdParam: 'userId', successMessage: 'Password updated', @@ -298,6 +320,8 @@ export const SysUser = ObjectSchema.create({ locations: ['list_item', 'record_header'], type: 'api', target: '/api/v1/auth/admin/impersonate-user', + // Platform-admin standing (ADR-0068 D4) — see the block header above. + visible: 'current_user.isPlatformAdmin == true', requiresFeature: 'admin', recordIdParam: 'userId', successMessage: 'Now impersonating user', @@ -358,7 +382,14 @@ export const SysUser = ObjectSchema.create({ // half is deliberately NOT copied (it would hide the button from every // admin). `has()` per operand for the sparse action face (#8990) — the // rationale is on the self-service block below. - visible: 'has(record.source) && record.source != "idp_provisioned"', + // + // ANDed ahead of it, the platform-admin standing the door's gate judges + // (ADR-0068 D4, the admin block header above): without it the button + // was offered to every caller who could open a user row, and the door + // refused each one 403 `PERMISSION_DENIED`. One flat conjunction, the + // principal term first, the way the self-service predicates below lead + // with theirs. + visible: 'current_user.isPlatformAdmin == true && has(record.source) && record.source != "idp_provisioned"', // The action collects a param, so its explanatory line rides // `description` and ⛔ never `confirmText` — pairing the two shows two // dialogs for one decision (#7278/#7309). diff --git a/packages/platform-objects/src/platform-admin-affordance-standing.test.ts b/packages/platform-objects/src/platform-admin-affordance-standing.test.ts new file mode 100644 index 00000000000..8d1779b1439 --- /dev/null +++ b/packages/platform-objects/src/platform-admin-affordance-standing.test.ts @@ -0,0 +1,307 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Every first-party action whose door runs the platform-admin gate is OFFERED + * only to the standing that door admits — the enumeration pin for the + * "affordance offered that its door refuses" family. + * + * ## The rule + * + * An action whose target endpoint runs the ADR-0068 platform-admin gate answers + * every other caller — org owners, org admins, delegated admins, members — 403 + * `PERMISSION_DENIED`. Offering that action to them is a button that can only + * fail. So its served `visible` must carry `current_user.isPlatformAdmin == + * true`: the ADR-0095 D3 PLATFORM_ADMIN posture rung that the session payload + * emits and the gate judges (ADR-0068 D4). ⛔ Never `'platform_admin' in + * current_user.positions`: `EvalUserSchema` rules that standing is read from + * the key, never from the array, and a tenant can write a position by that name. + * + * ## The population is derived, not listed + * + * Every `*.object.ts` under `packages/` is imported and every action on every + * exported object is judged, so an action added anywhere tomorrow is held to the + * rule with no edit here — it fails until its `visible` carries the standing. + * The walk, its scaffolding-template exclusion and its cross-package + * declaration are the ones `managed-api-method-affordance-sweep.test.ts` already + * uses for the same population. + * + * ## The oracle: which doors are platform-admin gated + * + * plugin-auth exposes no structural marker for it — no exported mount table, no + * gate field on its route ledger rows (their notes say it in prose) — and this + * package may not depend on plugin-auth. So the oracle is the narrowest honest + * form, read off the mounts in `auth-plugin.ts`: + * + * - the `/api/v1/auth/admin/` namespace: every ObjectStack mount in it runs + * `gateAdmin` / `judgePlatformAdmin` first, and `/admin/impersonate-user` is + * re-authorized on the same standing inside better-auth; + * - minus the two routes in that namespace that do NOT refuse below platform + * admin, named below with the reason each one admits someone else; + * - plus `/api/v1/auth/organization/add-member`, the one gated mount outside + * the namespace. + * + * What it misses, stated rather than discovered later: a platform-admin-gated + * mount added OUTSIDE the namespace (it must be named here); a door gated on a + * CAPABILITY rather than the rung (datasource admin routes ask for + * `manage_platform_settings` and are deliberately not this family); routes + * served by another repository (`/api/v1/cloud/**`); and actions that are not + * declared on an object file (metadata-type actions registered at runtime). + * The better-auth-native routes in the namespace still judge the legacy `role` + * scalar and refuse a platform admin too; the standing is the floor of who can + * be offered such an action, not a promise the vendor admits them. + * + * ## How it evaluates + * + * Both halves, for every member: the served predicate is evaluated with + * `@objectstack/formula`'s `celEngine` with `current_user` bound the way the + * console binds it — the whole scope handed to the engine as `extra`, one + * subject under `current_user` / `user` / `ctx.user` / `os.user`, `features` + * beside it — and ⛔ not through `user:`, under which the engine re-derives + * `isPlatformAdmin` from `positions`. No principal without the rung is offered + * the action on any row; the principal with it is offered it on some row. + */ + +import { readdirSync, statSync } from 'node:fs'; +import { join, relative, resolve } from 'node:path'; +import { beforeAll, describe, expect, it } from 'vitest'; +import { celEngine } from '@objectstack/formula'; + +/** The standing, spelled as the ruling fixed it. */ +const STANDING = 'current_user.isPlatformAdmin == true'; + +/** Seeded from `__dirname` for the reasons the managed-api-method sweep records. */ +const HERE = __dirname; +/** …/packages/platform-objects/src → repo root */ +const REPO_ROOT = resolve(HERE, '../../..'); +const PACKAGES_DIR = join(REPO_ROOT, 'packages'); + +/** Object files that are templates for a generated project, not definitions this repo boots. */ +const SCAFFOLDING_TEMPLATES: readonly string[] = [ + 'packages/create-objectstack/src/templates/blank/src/objects/note.object.ts', +]; + +const ADMIN_NAMESPACE = '/api/v1/auth/admin/'; + +/** Routes inside the namespace whose door does NOT refuse below platform admin. */ +const NAMESPACE_ROUTES_ADMITTING_OTHERS: Readonly> = { + '/api/v1/auth/admin/has-permission': + 'a permission QUERY: every signed-in caller gets an answer, a plain member its own negative', + '/api/v1/auth/admin/stop-impersonating': + 'admits the IMPERSONATED session, which by construction is not a platform admin', +}; + +/** Platform-admin-gated mounts outside the namespace, each named. */ +const GATED_OUTSIDE_NAMESPACE: readonly string[] = ['/api/v1/auth/organization/add-member']; + +function isPlatformAdminGatedDoor(target: string): boolean { + if (GATED_OUTSIDE_NAMESPACE.includes(target)) return true; + return target.startsWith(ADMIN_NAMESPACE) && !Object.hasOwn(NAMESPACE_ROUTES_ADMITTING_OTHERS, target); +} + +/** Every `*.object.ts` under `packages/`, skipping build output, deps and tests. */ +function walkObjectFiles(dir: string, out: string[] = []): string[] { + let entries: string[]; + try { + entries = readdirSync(dir); + } catch { + return out; + } + for (const entry of entries) { + if (entry === 'node_modules' || entry === 'dist' || entry.startsWith('.')) continue; + const full = join(dir, entry); + let isDir: boolean; + try { + isDir = statSync(full).isDirectory(); + } catch { + continue; + } + if (isDir) walkObjectFiles(full, out); + else if (entry.endsWith('.object.ts') && !entry.endsWith('.test.ts')) out.push(full); + } + return out; +} + +interface FirstPartyAction { + /** `.` */ + site: string; + file: string; + target: string; + /** The served (parsed) predicate's CEL source, or undefined when none is declared. */ + source: string | undefined; +} + +function sourceOf(raw: unknown): string | undefined { + if (typeof raw === 'string') return raw; + if (raw && typeof raw === 'object' && typeof (raw as { source?: unknown }).source === 'string') { + return (raw as { source: string }).source; + } + return undefined; +} + +async function loadActions(): Promise<{ files: number; actions: FirstPartyAction[] }> { + const files = walkObjectFiles(PACKAGES_DIR).filter( + (f) => !SCAFFOLDING_TEMPLATES.includes(relative(REPO_ROOT, f)), + ); + const actions: FirstPartyAction[] = []; + for (const file of files) { + const mod: Record = await import(file); + for (const value of Object.values(mod)) { + if (!value || typeof value !== 'object' || Array.isArray(value)) continue; + const def = value as { name?: unknown; actions?: unknown }; + if (typeof def.name !== 'string' || !Array.isArray(def.actions)) continue; + for (const action of def.actions as Array>) { + if (typeof action.target !== 'string') continue; + actions.push({ + site: `${def.name}.${String(action.name)}`, + file: relative(REPO_ROOT, file), + target: action.target, + source: sourceOf(action.visible), + }); + } + } + } + return { files: files.length, actions }; +} + +/** Every capability flag on, so no feature term hides the standing's verdict. */ +const FEATURES = { + organization: true, multiOrgEnabled: true, twoFactor: true, + oidcProvider: true, admin: true, phoneNumber: true, apiKey: true, sso: true, +}; + +type Subject = { id: string; email: string; isPlatformAdmin: boolean; positions: string[] }; + +/** The one principal the gate admits — holding the rung, and no position that names it. */ +const PLATFORM_ADMIN: Subject = { id: 'u_pa', email: 'pa@example.com', isPlatformAdmin: true, positions: [] }; + +/** Every principal the gate refuses, the grades first, then the position the rung must never be read from. */ +const REFUSED: ReadonlyArray<[string, Subject]> = [ + ['org owner', { id: 'u_owner', email: 'owner@example.com', isPlatformAdmin: false, positions: ['org_owner'] }], + ['org admin', { id: 'u_admin', email: 'admin@example.com', isPlatformAdmin: false, positions: ['org_admin'] }], + ['delegated admin', { id: 'u_del', email: 'del@example.com', isPlatformAdmin: false, positions: ['delegated_admin'] }], + ['plain member', { id: 'u_member', email: 'member@example.com', isPlatformAdmin: false, positions: ['member'] }], + [ + "a tenant-written 'platform_admin' position without the rung", + { id: 'u_pos', email: 'pos@example.com', isPlatformAdmin: false, positions: ['platform_admin', 'org_owner'] }, + ], +]; + +/** The console's binding: the whole scope as `extra`, one subject under every alias. */ +function offered(source: string, subject: Subject, record: Record): boolean { + const r = celEngine.evaluate( + { dialect: 'cel', source }, + { + record, + extra: { current_user: subject, user: subject, ctx: { user: subject }, os: { user: subject }, features: FEATURES }, + }, + ); + return r.ok && r.value === true; +} + +/** + * Rows to evaluate on: the empty row, plus every combination of the values a + * record term could ask for — true, false, an arbitrary string, and each string + * literal the predicate itself compares against. + */ +function candidateRows(source: string): Array> { + const paths = [...new Set([...source.matchAll(/record\.([a-z_][a-z0-9_]*)/gi)].map((m) => m[1]))]; + const literals = [...source.matchAll(/'([^']*)'|"([^"]*)"/g)].map((m) => m[1] ?? m[2]); + const values: unknown[] = [true, false, 'x', ...new Set(literals)]; + let rows: Array> = [{}]; + for (const path of paths) { + rows = rows.flatMap((row) => values.map((v) => ({ ...row, [path]: v }))); + } + return [{}, ...rows]; +} + +describe('platform-admin-gated doors: every first-party action offers itself to the standing the door admits', () => { + let files = 0; + let all: FirstPartyAction[] = []; + let family: FirstPartyAction[] = []; + + beforeAll(async () => { + ({ files, actions: all } = await loadActions()); + family = all.filter((a) => isPlatformAdminGatedDoor(a.target)); + }, 120_000); + + it('the instrument is live: the walk loads objects, and the family holds every member known today', () => { + // Positive control FIRST. A walk that found nothing, or an oracle that + // matched nothing, would leave every assertion below vacuously green. + expect(files).toBeGreaterThan(50); + expect(all.length).toBeGreaterThan(50); + // A floor, not the set: an action added later joins the family by its + // target and is judged below, with no edit here. + expect(family.map((a) => a.site)).toEqual( + expect.arrayContaining([ + 'sys_member.add_member', + 'sys_user.ban_user', + 'sys_user.unban_user', + 'sys_user.unlock_user', + 'sys_user.create_user', + 'sys_user.set_user_password', + 'sys_user.impersonate_user', + 'sys_user.set_user_manager', + 'sys_oauth_application.disable_oauth_application', + 'sys_oauth_application.enable_oauth_application', + 'sys_sso_provider.register_sso_provider', + 'sys_sso_provider.register_saml_provider', + 'sys_sso_provider.request_domain_verification', + 'sys_sso_provider.verify_domain', + ]), + ); + }); + + it('the oracle does not sweep in doors that admit someone other than a platform admin', () => { + // Negative control: each of these is a first-party target today whose door + // authorizes another principal — the session itself, the application's or + // provider's owner. Gating them on the standing would hide a working button. + for (const target of [ + '/api/v1/auth/sys-oauth-application/register', + '/api/v1/auth/oauth2/client/rotate-secret', + '/api/v1/auth/oauth2/delete-client', + '/api/v1/auth/sso/delete-provider', + ...Object.keys(NAMESPACE_ROUTES_ADMITTING_OTHERS), + ]) { + expect(isPlatformAdminGatedDoor(target), target).toBe(false); + } + for (const site of [ + 'sys_oauth_application.create_oauth_application', + 'sys_oauth_application.rotate_client_secret', + 'sys_oauth_application.delete_oauth_application', + 'sys_sso_provider.delete_sso_provider', + ]) { + expect(all.some((a) => a.site === site), `${site} is still a first-party action`).toBe(true); + expect(family.some((a) => a.site === site), `${site} is not in the family`).toBe(false); + } + }); + + it('each member serves a visible that carries the standing term', () => { + const missing = family + .filter((a) => !a.source?.includes(STANDING)) + .map((a) => `${a.site} → ${a.target} (${a.file}) serves ${a.source === undefined ? 'no visible' : `\`${a.source}\``}`); + expect(missing, `add \`visible: '${STANDING}'\` (AND-composed with any existing term)`).toEqual([]); + }); + + it('no principal the gate refuses is offered a member, on any row — current_user bound as the console binds it', () => { + const leaks: string[] = []; + for (const a of family) { + if (a.source === undefined) { + leaks.push(`${a.site}: no visible, so every principal is offered it`); + continue; + } + for (const [who, subject] of REFUSED) { + const row = candidateRows(a.source).find((r) => offered(a.source!, subject, r)); + if (row) leaks.push(`${a.site} is offered to ${who} on ${JSON.stringify(row)}`); + } + } + expect(leaks).toEqual([]); + }); + + it('the platform admin is still offered every member — the standing is a gate, not an always-false wrapper', () => { + const hidden = family + .filter((a) => a.source !== undefined) + .filter((a) => !candidateRows(a.source!).some((r) => offered(a.source!, PLATFORM_ADMIN, r))) + .map((a) => `${a.site}: \`${a.source}\``); + expect(hidden).toEqual([]); + }); +}); diff --git a/packages/platform-objects/src/platform-objects.test.ts b/packages/platform-objects/src/platform-objects.test.ts index ead40140fe9..fc8c7de731e 100644 --- a/packages/platform-objects/src/platform-objects.test.ts +++ b/packages/platform-objects/src/platform-objects.test.ts @@ -191,13 +191,14 @@ describe('@objectstack/platform-objects', () => { // body schema (`dist/authorize-Crqw4_bR.mjs:2862-2889`) still does // not accept `disabled`, so the bridge route stays warranted. They // differ only in the static `disabled` body field and the - // visibility predicate, so exactly one is active at any time. + // visibility predicate, so exactly one is active at any time. Both lead + // with the platform-admin standing the bridge's gate judges. expect(disable?.target).toBe('/api/v1/auth/admin/oauth2/toggle-disabled'); expect(disable?.bodyExtra).toEqual({ disabled: true }); - expect((disable?.visible as any)?.source).toBe('(has(record.disabled) && record.disabled != true) && features.oidcProvider != false'); + expect((disable?.visible as any)?.source).toBe('(current_user.isPlatformAdmin == true && has(record.disabled) && record.disabled != true) && features.oidcProvider != false'); expect(enable?.target).toBe('/api/v1/auth/admin/oauth2/toggle-disabled'); expect(enable?.bodyExtra).toEqual({ disabled: false }); - expect((enable?.visible as any)?.source).toBe('(has(record.disabled) && record.disabled == true) && features.oidcProvider != false'); + expect((enable?.visible as any)?.source).toBe('(current_user.isPlatformAdmin == true && has(record.disabled) && record.disabled == true) && features.oidcProvider != false'); // Generic CRUD must NOT expose mutating methods — all writes are // reserved for better-auth wrappers above so OAuth-specific @@ -575,6 +576,12 @@ describe('feature-gate lowering matrix (#2874)', () => { const INVITE = "('org_owner' in current_user.positions || 'org_admin' in current_user.positions || 'delegated_admin' in current_user.positions)"; const ADMINS = "('org_owner' in current_user.positions || 'org_admin' in current_user.positions)"; const OWNER = "'org_owner' in current_user.positions"; + // The platform-admin standing: the authored `visible` of every action whose + // door runs the platform-admin gate — the ADR-0095 D3 PLATFORM_ADMIN rung the + // gate judges (ADR-0068 D4). Composed ahead of any feature gate. ⛔ Never a + // `current_user.positions` read. The repo-wide enumeration of that family is + // `platform-admin-affordance-standing.test.ts`; these rows pin the bytes. + const STANDING = 'current_user.isPlatformAdmin == true'; const rows: Array<[string, { actions?: readonly { name?: string; visible?: unknown; params?: readonly unknown[] }[] }, string, string]> = [ ['SysOrganization', SysOrganization, 'create_organization', MULTI_ORG], @@ -584,7 +591,7 @@ describe('feature-gate lowering matrix (#2874)', () => { ['SysOrganization', SysOrganization, 'leave_organization', MULTI_ORG], ['SysOrganization', SysOrganization, 'change_slug', MULTI_ORG], ['SysUser', SysUser, 'invite_user', `${INVITE} && ${ORG}`], - ['SysUser', SysUser, 'create_user', 'features.admin == true'], + ['SysUser', SysUser, 'create_user', `(${STANDING}) && features.admin == true`], // [#11544] Third mirror of `invite_user` — the Members tab's own copy of // the email-invite entry. Same gate as the sys_user / sys_invitation rows. ['SysMember', SysMember, 'invite_user', `${INVITE} && ${ORG}`], @@ -593,7 +600,15 @@ describe('feature-gate lowering matrix (#2874)', () => { // The standing term is the authored `visible` — the ADR-0095 D3 // PLATFORM_ADMIN rung the door's gate judges (ADR-0068 D4) — composed // ahead of the feature gate. ⛔ Never a `current_user.positions` read. - ['SysMember', SysMember, 'add_member', `(current_user.isPlatformAdmin == true) && ${ORG}`], + ['SysMember', SysMember, 'add_member', `(${STANDING}) && ${ORG}`], + // The rest of the platform-admin family. Three carry no feature gate, so + // the authored predicate is served as written — no lowering — and is + // pinned here anyway, beside add_member, so every member has its row. + ['SysUser', SysUser, 'set_user_manager', `${STANDING} && has(record.source) && record.source != "idp_provisioned"`], + ['SysSsoProvider', SysSsoProvider, 'register_sso_provider', STANDING], + ['SysSsoProvider', SysSsoProvider, 'register_saml_provider', STANDING], + ['SysSsoProvider', SysSsoProvider, 'request_domain_verification', STANDING], + ['SysSsoProvider', SysSsoProvider, 'verify_domain', STANDING], ['SysMember', SysMember, 'update_member_role', `${ADMINS} && ${ORG}`], ['SysMember', SysMember, 'remove_member', `${ADMINS} && ${ORG}`], ['SysMember', SysMember, 'transfer_ownership', `((has(record.role) && record.role != 'owner') && ${OWNER}) && ${ORG}`], @@ -607,11 +622,12 @@ describe('feature-gate lowering matrix (#2874)', () => { ['SysTeamMember', SysTeamMember, 'remove_team_member', `${ADMINS} && ${ORG}`], // #2874 P2b — audit gates: capability-dependent actions that previously // shipped UNGATED (rendered even with the backing plugin off, then 404'd). - ['SysUser', SysUser, 'ban_user', 'features.admin == true'], - ['SysUser', SysUser, 'unban_user', 'features.admin == true'], - ['SysUser', SysUser, 'unlock_user', 'features.admin == true'], - ['SysUser', SysUser, 'set_user_password', 'features.admin == true'], - ['SysUser', SysUser, 'impersonate_user', 'features.admin == true'], + // Their doors are platform-admin gated, so the standing leads. + ['SysUser', SysUser, 'ban_user', `(${STANDING}) && features.admin == true`], + ['SysUser', SysUser, 'unban_user', `(${STANDING}) && features.admin == true`], + ['SysUser', SysUser, 'unlock_user', `(${STANDING}) && features.admin == true`], + ['SysUser', SysUser, 'set_user_password', `(${STANDING}) && features.admin == true`], + ['SysUser', SysUser, 'impersonate_user', `(${STANDING}) && features.admin == true`], ['SysUser', SysUser, 'enable_two_factor', '(has(record.id) && record.id == ctx.user.id && has(record.two_factor_enabled) && record.two_factor_enabled != true) && features.twoFactor == true'], ['SysUser', SysUser, 'disable_two_factor', '(has(record.id) && record.id == ctx.user.id && has(record.two_factor_enabled) && record.two_factor_enabled == true) && features.twoFactor == true'], ['SysUser', SysUser, 'generate_backup_codes', '(has(record.id) && record.id == ctx.user.id && has(record.two_factor_enabled) && record.two_factor_enabled == true) && features.twoFactor == true'], @@ -620,8 +636,8 @@ describe('feature-gate lowering matrix (#2874)', () => { ['SysTwoFactor', SysTwoFactor, 'regenerate_backup_codes', 'features.twoFactor == true'], ['SysOauthApplication', SysOauthApplication, 'create_oauth_application', 'features.oidcProvider != false'], ['SysOauthApplication', SysOauthApplication, 'delete_oauth_application', 'features.oidcProvider != false'], - ['SysOauthApplication', SysOauthApplication, 'disable_oauth_application', '(has(record.disabled) && record.disabled != true) && features.oidcProvider != false'], - ['SysOauthApplication', SysOauthApplication, 'enable_oauth_application', '(has(record.disabled) && record.disabled == true) && features.oidcProvider != false'], + ['SysOauthApplication', SysOauthApplication, 'disable_oauth_application', `(${STANDING} && has(record.disabled) && record.disabled != true) && features.oidcProvider != false`], + ['SysOauthApplication', SysOauthApplication, 'enable_oauth_application', `(${STANDING} && has(record.disabled) && record.disabled == true) && features.oidcProvider != false`], ['SysOauthApplication', SysOauthApplication, 'rotate_client_secret', 'features.oidcProvider != false'], ];