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
16 changes: 16 additions & 0 deletions .changeset/21903-platform-admin-affordance-visibility.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -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}`
Expand Down Expand Up @@ -100,7 +110,10 @@ const FEATURES = {

/** Evaluate through the canonical engine; a fault is reported, never thrown. */
function evaluate(source: string, record: Record<string, unknown>): 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)}`;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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 },
Expand All @@ -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 },
Expand Down
20 changes: 20 additions & 0 deletions packages/platform-objects/src/identity/sys-sso-provider.object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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".' },
Expand Down Expand Up @@ -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".' },
Expand Down Expand Up @@ -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 },
Expand Down Expand Up @@ -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: [
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>): 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)}`;
Expand Down
33 changes: 32 additions & 1 deletion packages/platform-objects/src/identity/sys-user.object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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',
Expand All @@ -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',
Expand All @@ -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',
Expand All @@ -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,
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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).
Expand Down
Loading
Loading