Skip to content

Commit 879bd38

Browse files
fix(platform-objects): offer the platform-admin-gated user, OAuth and SSO actions only to a platform admin (#22068)
Fixes #21903 Clause-②: no ## What changes Thirteen first-party actions are now offered only to a platform administrator. That is the one standing their endpoints admit. - **Before:** each action was offered to every principal who could open the row or list. That covered org owners, org admins, delegated admins and plain members. The endpoint then answered each of them 403, because every one of these doors runs the ADR-0068 platform-admin gate first. - **After:** each action declares `visible: 'current_user.isPlatformAdmin == true'`. That is ruling A-lite's predicate (record 6019378035), the one `sys_member.add_member` landed with in PR #22064. It is ANDed with any term the action already had, and `requiresFeature` composes onto it at parse time. - **The members:** - `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`. - **The enumeration pin:** `packages/platform-objects/src/platform-admin-affordance-standing.test.ts` imports every `*.object.ts` under `packages/` and judges every action whose target door is platform-admin gated. An action added later fails it until its `visible` carries the standing. The doors and the callers they admit are unchanged. No key, export or parameter is added, and no label changes. That makes `Clause-②: no`, and the changeset is a `@objectstack/platform-objects` patch. **File surface.** This is the surface the claim declared: - the three producers in `packages/platform-objects/src/identity/`; - the lowering-matrix rows in `packages/platform-objects/src/platform-objects.test.ts`; - the new enumeration pin; - `.changeset/21903-platform-admin-affordance-visibility.md`. Two existing tests in the same `identity/` directory evaluate these predicates, so their fixtures were re-judged (see Acceptance notes): `action-predicate-sparse-face.test.ts` and `sys-user-set-manager-action.test.ts`. There is no `packages/spec`, `plugin-auth`, `@objectstack/formula`, `docs/adr` or dogfood edit. ## The census (H1), measured at `56bf27affb` Every first-party action with a `/api/v1/auth/` target lives in `packages/platform-objects/src/identity/`. The gate lines are in `packages/plugins/plugin-auth/src/auth-plugin.ts` unless named otherwise. | Action | Target | Door and its gate | Gated | `visible` served before | `visible` served after | |---|---|---|---|---|---| | `sys_user.ban_user` | `/admin/ban-user` | mount `:2670`, `gateAdmin` `:2672` | yes | `features.admin == true` | `(current_user.isPlatformAdmin == true) && features.admin == true` | | `sys_user.unban_user` | `/admin/unban-user` | mount `:2691`, `gateAdmin` `:2693` | yes | `features.admin == true` | same as `ban_user` | | `sys_user.unlock_user` | `/admin/unlock-user` | mount `:2490`, `judgePlatformAdmin` `:2502` | yes | `features.admin == true` | same as `ban_user` | | `sys_user.create_user` | `/admin/create-user` | mount `:2601`, `gateAdmin` `:2603` | yes | `features.admin == true` | same as `ban_user` | | `sys_user.set_user_password` | `/admin/set-user-password` | mount `:2851`, `gateAdmin` `:2853` | yes | `features.admin == true` | same as `ban_user` | | `sys_user.impersonate_user` | `/admin/impersonate-user` | better-auth endpoint replaced in place (`admin-impersonate-endpoint.ts:198`, caller predicate `:213`, wired at `auth-manager.ts:3609`) | yes | `features.admin == true` | same as `ban_user` | | `sys_user.set_user_manager` | `/admin/set-user-manager` | mount `:2536`, `gateAdmin` `:2538` | yes | `has(record.source) && record.source != "idp_provisioned"` | `current_user.isPlatformAdmin == true && has(record.source) && record.source != "idp_provisioned"` | | `sys_oauth_application.disable_oauth_application` | `/admin/oauth2/toggle-disabled` | mount `:2355`, `judgePlatformAdmin` `:2372` | yes | `(has(record.disabled) && record.disabled != true) && features.oidcProvider != false` | `(current_user.isPlatformAdmin == true && has(record.disabled) && record.disabled != true) && features.oidcProvider != false` | | `sys_oauth_application.enable_oauth_application` | `/admin/oauth2/toggle-disabled` | as above | yes | `(has(record.disabled) && record.disabled == true) && features.oidcProvider != false` | `(current_user.isPlatformAdmin == true && has(record.disabled) && record.disabled == true) && features.oidcProvider != false` | | `sys_sso_provider.register_sso_provider` | `/admin/sso/register` | mount `:2468`, `gateAdmin` `:2470` | yes | none | `current_user.isPlatformAdmin == true` | | `sys_sso_provider.register_saml_provider` | `/admin/sso/register-saml` | mount `:2958`, `gateAdmin` `:2962` | yes | none | `current_user.isPlatformAdmin == true` | | `sys_sso_provider.request_domain_verification` | `/admin/sso/request-domain-verification` | mount `:2983`, `gateAdmin` `:2988` | yes | none | `current_user.isPlatformAdmin == true` | | `sys_sso_provider.verify_domain` | `/admin/sso/verify-domain` | mount `:3002`, `gateAdmin` `:3005` | yes | none | `current_user.isPlatformAdmin == true` | | `sys_member.add_member` (the precedent) | `/organization/add-member` | mount `:2932`, `gateAdmin` `:2934` | yes | unchanged: `(current_user.isPlatformAdmin == true) && features.organization != false` | unchanged | | `sys_oauth_application.create_oauth_application` | `/sys-oauth-application/register` | mount `:3038`, session only | no | `features.oidcProvider != false` | unchanged | | `sys_oauth_application.rotate_client_secret` | `/oauth2/client/rotate-secret` | vendor route: `@better-auth/oauth-provider` 1.7.3 `rotateClientSecretEndpoint` authorizes the client's owner (`client.userId === session.user.id`); plugin-auth configures no `clientPrivileges` | no | `features.oidcProvider != false` | unchanged | | `sys_oauth_application.delete_oauth_application` | `/oauth2/delete-client` | vendor route: `deleteClientEndpoint`, same owner check | no | `features.oidcProvider != false` | unchanged | | `sys_sso_provider.delete_sso_provider` | `/sso/delete-provider` | vendor route: `@better-auth/sso` 1.7.3 `checkProviderAccess` admits the provider's owner or an admin of its organization | no | none | unchanged | Targets are spelled after `/api/v1/auth`. ## The dispatch's hypotheses, measured - **H1: confirmed, with one count corrected.** - The family is the thirteen actions above plus `add_member`. - The body's "`sys_oauth_application`: two actions" is two of the five that object declares. The other three authorize the session or the application's owner, as the table shows. - The body's "`sys_sso_provider`: four actions" is four of five. `delete_sso_provider`'s door is the vendor's provider-access check. - Outside object files, `service-datasource` registers two type-level actions. Their doors ask for the `manage_platform_settings` capability (`admin-routes.ts:272`), and the file rules out an `isPlatformAdmin` arm (`admin-routes.ts:268`). They are not members. - `sys_organization.change_slug` targets `/api/v1/cloud/...`, which another repository serves. Its door is **NOT MEASURED** here. - **H2: confirmed.** - `lowerRequiresFeature` (`packages/spec/src/kernel/public-auth-features.ts:352`) composes `(existing) && gate` at `:417`. - Where an action already had a `visible`, the standing is ANDed in the repo's existing spelling for an authored composed predicate. That is one flat conjunction with the principal term first, as the `sys_user` self-service predicates spell theirs. - Each served result is pinned in the lowering matrix. - **H3: confirmed. No member's door admits a principal below platform admin, apart from the legacy scalar noted below.** - **Offered (before / after):** I evaluated the served predicate from the built `dist` with `celEngine`, binding `current_user` the way the console does. The whole scope goes in as `extra`, with one subject under `current_user`, `user`, `ctx.user` and `os.user`. - At `56bf27affb`, every member was offered to all five principals: platform admin, org owner, org admin, delegated admin and plain member. - At `efd167f5a3`, each member is offered to the platform admin alone. - The four non-members are unchanged. - **Admitted:** I called the built `judgePlatformAdmin` on the same five session shapes. It admits the platform admin and refuses the other four with 403 `PERMISSION_DENIED`. It also refuses a session whose `positions` carry a tenant-written `platform_admin` without the rung. - The impersonate caller predicate (`admin-impersonate-endpoint.ts:213`) refuses the same principals with 403 `YOU_ARE_NOT_ALLOWED_TO_IMPERSONATE_USERS`. Its oracle is the posture rung, which `auth-manager.ts:4083` also emits as the session's `isPlatformAdmin`. - On a real showcase boot at this head, `admin-route-nonadmin-refusal.dogfood.test.ts` and `admin-platform-admin-standing.dogfood.test.ts` passed 14 of 14. They cover all thirteen member doors in both directions. - **Page reach:** `sys_sso_provider` also requires `manage_platform_settings` at object level (`sys-sso-provider.object.ts:60`). That is a capability, not the rung, so the predicate is still needed for a holder of the capability who is not a platform admin. - **H4: confirmed. No structural oracle exists without a new export.** - `plugin-auth` exports no mount table. - `auth-route-ledger.ts` is package-internal, and its rows state the gate only in `note` prose. - `VENDOR_ADMIN_PATH_PREFIX` names the namespace, but `plugin-auth` is not a dependency of `platform-objects`. - So the pin uses the narrowest honest form, read off the mounts: - the `/api/v1/auth/admin/` namespace; - minus `/admin/has-permission` (a query that answers every caller) and `/admin/stop-impersonating` (it admits the impersonated session); - plus `/api/v1/auth/organization/add-member`. - **What it misses** (also stated in the test's header): - a gated mount added outside the namespace; - a capability-gated door; - routes another repository serves; - actions not declared on an object file. - **Dogfood pin (c) was not needed.** The door-level check is already pinned by the two dogfood sweeps above, which passed 14 of 14. ## Tests All runs are at the final head `efd167f5a3`. - **Pin (a), the lowering matrix in `platform-objects.test.ts`:** every member has a row with its composed served predicate, beside `add_member`'s row. - `set_user_manager` and the four `sys_sso_provider` rows are new. - The two exact-source assertions on the OAuth toggle pair were updated. - **Pin (b), `platform-admin-affordance-standing.test.ts`:** 5 cases. - The instrument is live: the walk loads more than 50 object files, and the family contains all 14 known members. That is a floor, not the exact set. - The oracle does not include the four non-member targets or the two excluded namespace routes. - Every member's served `visible` carries `current_user.isPlatformAdmin == true`. - No refused principal is offered any member on any candidate row. The refused principals are the four grades and a tenant-written `platform_admin` position without the rung. `current_user` is bound through `extra`. - The platform admin is still offered every member. - **The package:** `pnpm --filter @objectstack/platform-objects exec vitest run --maxWorkers=2` passed 63 files and 1006 tests. On `main` it ran 62 files and 996 tests: this adds 5 pin cases and 5 matrix rows. - **Typecheck:** `pnpm --filter @objectstack/platform-objects typecheck` passed. - The test-layer program (`tsconfig.test.json`) compiles all four touched test files: `--listFiles` counts 1 hit each, and the build program counts 0. - Its only errors are the 3 already ledgered in `src/feature-gate-guard.test.ts`. - **Dogfood, real showcase boot:** these passed on a real showcase boot: - `admin-route-nonadmin-refusal` and `admin-platform-admin-standing`: 14 of 14; - `org-admin-affordance-reach`: 14 of 14. ### Reverse verification, on committed HEAD `efd167f5a3` - **Prediction:** both pins turn red. - **Mutation:** `scripts/ablation-replace.mjs` replaced `set_user_manager`'s predicate with its pre-change text, `has(record.source) && record.source != "idp_provisioned"`. - The anchor count went from 1 to 0, and the file's blob from `0b9e8e738fb4` to `0ac51042a82d`. - An outer `trap … EXIT INT TERM` used absolute paths. - Both pins import the subject from source: a relative import, and the absolute `.object.ts` path. No `dist/` is in their resolution path, so no rebuild was needed. - **Result: red, as predicted, 3 failed of 137.** - (a) The `SysUser.set_user_manager` row expected `current_user.isPlatformAdmin == true && has(record.source) && record.source != "idp_provisioned"` and received the mutated text. - (b) "each member serves a visible that carries the standing term" listed `sys_user.set_user_manager`. - (b) "no principal the gate refuses is offered a member" listed five leaks. The org owner, org admin, delegated admin, plain member and the tenant-written position were each offered `set_user_manager`. - **Restore:** `git checkout HEAD --` on the absolute path. - The blob after the restore and the HEAD blob are both `0b9e8e738fb4911b9a379b2ebc699344f68127bd`. - `git diff HEAD` is empty, and `git status` is clean. - Both `ablation-replace` and the outer script proved this. ## Gates These ran at `efd167f5a3`. `origin/main` was still `56bf27affb` at the last fetch, so there was nothing to merge. - **Full build:** `turbo run build --filter=!@objectstack/docs --concurrency=2` passed 72 of 72 tasks. - **The derived gates:** `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derives 63 commands (31 pnpm, 32 node). All 63 exited 0. - `--ran`, with each exit code recorded: `63 derived, 63 run, 0 NOT-MEASURED, 0 UNRUN` (a derived zero). - The build-dependent ones printed real verdicts. For example, `check:dual-build-cjs-loads` loaded 106 entries across 66 packages, and `check:dts-closure` swept 72 packages. - **The artifact-roster block:** 54 commands outside the total. 51 exited 0. - Three answered NOT WIRED (exit 2) because they need pull-request context: `check-closing-target-claim`, `check-partof-closing-keyword` and `check-single-claim-paths`. - `check-partof-closing-keyword` was rerun with this body as `PR_BODY`. The other two need this PR's number, and CI runs them. - `check-sdui-manifest` passed, but its objectui version comparison is NOT CHECKED: there is no objectui checkout here. - `check:console-injection` ran its self-test only, because there is no console `dist/`. - **The four symbol-anchor sweeps:** `check:adr-symbol-anchors`, `check:scripts-symbol-anchors`, `check:spec-docblock-symbol-anchors` and `check:adr-anchors` all passed. - **Lint, as a proven narrowing:** `pnpm exec eslint --no-inline-config --format json` over the seven touched TypeScript files. - The JSON output counts 7 files, with 0 errors and 0 warnings. - `--print-config` resolves a config for each file, so none is ignored. - Type-aware linting is off. No resolved config carries `parserOptions.project` or `projectService`, and `eslint.config.mjs:327-328` states the repo never enables it. So this diff cannot move the verdict on any untouched file. - **Left to CI, as a declared narrowing:** - the type-check lanes; - Test Core; - the Dogfood Regression Gate beyond the three files above; - Build Core; - Temporal Conformance; - the repo-wide `pnpm lint`. ## Acceptance notes - **The door also admits a legacy `user.role === 'admin'` scalar** (`platform-admin-gate.ts:80-84`, and the impersonate predicate's first half), and this predicate does not read it. This is the boundary PR #22064 recorded for `add_member`. Nothing in ObjectStack writes that scalar for a platform admin, and re-synthesizing it is vetoed. Noted, not filed. - **Two fixtures were re-judged, because the predicates they evaluate changed:** - `action-predicate-sparse-face.test.ts` bound an org owner through `user:`. Under that binding `@objectstack/formula` re-derives `isPlatformAdmin` from `positions`. - With the standing term in place, the owner would have short-circuited the record half of three predicates to `false`, and the sweep exists to test exactly that half. - Its OAuth per-site verdicts would also have gone red. - It now binds the same principal through `extra`, with the rung, the way the console binds it. The only alternative under `user:` was a `platform_admin` position, the spelling the standing must never be read from. - `sys-user-set-manager-action.test.ts` evaluated its "offered to an admin" case for a principal with no standing. It now binds a platform admin the same way. - **An unmeasured observation about SSO providers, not filed.** - `request_domain_verification` and `verify_domain` pass the platform-admin gate and then delegate to `@better-auth/sso`'s `checkProviderAccess`. So does `delete_sso_provider`'s door, with no gate in front. - That check admits only the provider's owner or an admin of its organization. - By reading, a second platform admin who did not register an org-less provider would be offered these actions and then refused by the vendor. This was not reproduced on a boot. - It is a different principal from this card's family. The standing remains the necessary term. - **The pin's oracle and what it misses** are written in its header. Every exclusion is named, with the principal that the excluded door admits. --- _Generated by [Claude Code](https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent ae97841 commit 879bd38

8 files changed

Lines changed: 442 additions & 18 deletions
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
"@objectstack/platform-objects": patch
3+
---
4+
5+
The user, OAuth-application and SSO-provider actions whose endpoint admits only a platform administrator are now offered only to a platform administrator.
6+
7+
Clause-②: no
8+
9+
- These thirteen actions now declare `visible: 'current_user.isPlatformAdmin == true'`, composed with their existing terms:
10+
- `sys_user`: `ban_user`, `unban_user`, `unlock_user`, `create_user`, `set_user_password`, `impersonate_user` and `set_user_manager`;
11+
- `sys_oauth_application`: `disable_oauth_application` and `enable_oauth_application`;
12+
- `sys_sso_provider`: `register_sso_provider`, `register_saml_provider`, `request_domain_verification` and `verify_domain`.
13+
- 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`.
14+
- 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.
15+
- `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.
16+
- ⛔ 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.

‎packages/platform-objects/src/identity/action-predicate-sparse-face.test.ts‎

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -65,8 +65,18 @@ import { SysBusinessUnitMember } from './sys-business-unit-member.object.js';
6565
* same reasoning as the all-on {@link FEATURES} below: a grade term that
6666
* answered false would short-circuit the composed `&&` and hide the record half
6767
* this sweep exists to test.
68+
*
69+
* For the same reason the principal also holds the platform-admin standing:
70+
* the actions whose door runs the platform-admin gate lead with
71+
* `current_user.isPlatformAdmin == true`, and a principal without it would
72+
* short-circuit their record half just the same. It is bound the way the
73+
* console binds it — the whole scope handed to the engine as `extra`, one
74+
* subject under every alias — because under `user:` `@objectstack/formula`
75+
* re-derives `isPlatformAdmin` from `positions`, and the only way to make that
76+
* answer true there is the `'platform_admin'` position spelling the standing
77+
* must never be read from.
6878
*/
69-
const USER = { id: 'u1', email: 'me@example.com', positions: ['org_owner'] };
79+
const USER = { id: 'u1', email: 'me@example.com', positions: ['org_owner'], isPlatformAdmin: true };
7080

7181
/**
7282
* `defineObject` normalizes a CEL shorthand string into a `{dialect, source}`
@@ -100,7 +110,10 @@ const FEATURES = {
100110

101111
/** Evaluate through the canonical engine; a fault is reported, never thrown. */
102112
function evaluate(source: string, record: Record<string, unknown>): boolean | string {
103-
const r = celEngine.evaluate({ dialect: 'cel', source }, { record, user: USER, extra: { features: FEATURES } });
113+
const r = celEngine.evaluate(
114+
{ dialect: 'cel', source },
115+
{ record, extra: { current_user: USER, user: USER, ctx: { user: USER }, os: { user: USER }, features: FEATURES } },
116+
);
104117
if (!r.ok) return `FAULT ${r.error.message.split('\n')[0].trim()}`;
105118
return typeof r.value === 'boolean' ? r.value : `NON-BOOLEAN ${JSON.stringify(r.value)}`;
106119
}

‎packages/platform-objects/src/identity/sys-oauth-application.object.ts‎

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,17 @@ export const SysOauthApplication = ObjectSchema.create({
7777
// The equality form also keeps the intended meaning of a null column —
7878
// never disabled, so Disable is offered and Enable is not. See
7979
// `materializeDeclaredFields` in `@objectstack/objectql` for the rule.
80+
//
81+
// Both toggle predicates also LEAD with the platform-admin standing
82+
// (ADR-0068 D4): `toggle-disabled` runs the platform-admin gate and answers
83+
// every other caller 403 `PERMISSION_DENIED`, so the pair is offered only to
84+
// `current_user.isPlatformAdmin == true`, the ADR-0095 D3 PLATFORM_ADMIN
85+
// rung that the session payload emits and the gate judges (⛔ never a
86+
// `current_user.positions` read). The other three actions carry no such
87+
// term, deliberately: `register` is a session-only self-service mount, and
88+
// `rotate-secret` / `delete-client` are better-auth's own routes, which
89+
// authorize the application's OWNER — gating them on the standing would
90+
// hide a working affordance from the developer who registered the app.
8091
actions: [
8192
{
8293
name: 'disable_oauth_application',
@@ -97,7 +108,7 @@ export const SysOauthApplication = ObjectSchema.create({
97108
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.',
98109
successMessage: 'OAuth application disabled',
99110
refreshAfter: true,
100-
visible: 'has(record.disabled) && record.disabled != true',
111+
visible: 'current_user.isPlatformAdmin == true && has(record.disabled) && record.disabled != true',
101112
bodyExtra: { disabled: true },
102113
params: [
103114
{ name: 'client_id', field: 'client_id', defaultFromRow: true, required: true },
@@ -118,7 +129,7 @@ export const SysOauthApplication = ObjectSchema.create({
118129
description: 'Re-enable this OAuth application? Token issuance, authorization, and introspection will resume immediately.',
119130
successMessage: 'OAuth application enabled',
120131
refreshAfter: true,
121-
visible: 'has(record.disabled) && record.disabled == true',
132+
visible: 'current_user.isPlatformAdmin == true && has(record.disabled) && record.disabled == true',
122133
bodyExtra: { disabled: false },
123134
params: [
124135
{ name: 'client_id', field: 'client_id', defaultFromRow: true, required: true },

‎packages/platform-objects/src/identity/sys-sso-provider.object.ts‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,18 @@ export const SysSsoProvider = ObjectSchema.create({
7373
// All mutations go through @better-auth/sso's endpoints under
7474
// /api/v1/auth/sso/* (register / delete-provider) rather than the generic
7575
// data layer, so server-side config validation + secret handling run.
76+
//
77+
// The four `/admin/sso/*` bridge actions are OFFERED only to the one
78+
// standing their door admits (ADR-0068 D4): each bridge runs the
79+
// platform-admin gate before it delegates and answers every other caller
80+
// 403 `PERMISSION_DENIED`. So each authors
81+
// `visible: 'current_user.isPlatformAdmin == true'`, the ADR-0095 D3
82+
// PLATFORM_ADMIN rung that the session payload emits and the gate judges
83+
// (⛔ never a `current_user.positions` read). The object-level
84+
// `manage_platform_settings` requirement above is a capability, not that
85+
// rung, so it does not stand in for it. `delete_sso_provider` carries no
86+
// such term: its door is @better-auth/sso's own `/sso/delete-provider`,
87+
// which authorizes the provider's owner or an admin of its organization.
7688
actions: [
7789
{
7890
name: 'register_sso_provider',
@@ -90,6 +102,8 @@ export const SysSsoProvider = ObjectSchema.create({
90102
// /sso/register would drop clientId/clientSecret (top-level → Zod-stripped)
91103
// and persist an unusable `oidc_config = null` provider.
92104
target: '/api/v1/auth/admin/sso/register',
105+
// Platform-admin standing (ADR-0068 D4) — see the actions header above.
106+
visible: 'current_user.isPlatformAdmin == true',
93107
refreshAfter: true,
94108
params: [
95109
{ 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({
130144
// re-dispatches to /sso/register (admin gate runs). The response returns
131145
// the SP ACS + metadata URLs to configure on the IdP.
132146
target: '/api/v1/auth/admin/sso/register-saml',
147+
// Platform-admin standing (ADR-0068 D4) — see the actions header above.
148+
visible: 'current_user.isPlatformAdmin == true',
133149
refreshAfter: true,
134150
params: [
135151
{ 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({
160176
// feature is OFF the bridge returns a clear "not enabled for this
161177
// environment" error instead of a bare 404.
162178
target: '/api/v1/auth/admin/sso/request-domain-verification',
179+
// Platform-admin standing (ADR-0068 D4) — see the actions header above.
180+
visible: 'current_user.isPlatformAdmin == true',
163181
params: [
164182
{ name: 'providerId', field: 'provider_id', defaultFromRow: true, required: true },
165183
{ name: 'domain', field: 'domain', defaultFromRow: true, required: false },
@@ -188,6 +206,8 @@ export const SysSsoProvider = ObjectSchema.create({
188206
// on success. Routed through the env bridge, which maps @better-auth/sso's
189207
// empty 204 / 502 into a clear success/error toast.
190208
target: '/api/v1/auth/admin/sso/verify-domain',
209+
// Platform-admin standing (ADR-0068 D4) — see the actions header above.
210+
visible: 'current_user.isPlatformAdmin == true',
191211
successMessage: 'Domain ownership verified',
192212
refreshAfter: true,
193213
params: [

‎packages/platform-objects/src/identity/sys-user-set-manager-action.test.ts‎

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,11 +63,21 @@ function sourceOf(raw: unknown): string | undefined {
6363
return undefined;
6464
}
6565

66+
/**
67+
* The admin these verdicts are about: a PLATFORM admin, because the door's
68+
* gate admits that standing alone and the predicate leads with it. Bound the
69+
* way the console binds it — the whole scope handed to the engine as `extra`,
70+
* one subject under every alias — and ⛔ not through `user:`, under which
71+
* `@objectstack/formula` re-derives `isPlatformAdmin` from `positions`.
72+
*/
73+
const PLATFORM_ADMIN = { id: 'admin_1', isPlatformAdmin: true, positions: [] as string[] };
74+
6675
/** Evaluate through the canonical engine; a fault is reported, never thrown. */
6776
function evaluate(source: string, record: Record<string, unknown>): boolean | string {
77+
const s = PLATFORM_ADMIN;
6878
const r = celEngine.evaluate(
6979
{ dialect: 'cel', source },
70-
{ record, user: { id: 'admin_1' }, extra: { features: {} } },
80+
{ record, extra: { current_user: s, user: s, ctx: { user: s }, os: { user: s }, features: {} } },
7181
);
7282
if (!r.ok) return `FAULT ${r.error.message.split('\n')[0].trim()}`;
7383
return typeof r.value === 'boolean' ? r.value : `NON-BOOLEAN ${JSON.stringify(r.value)}`;

‎packages/platform-objects/src/identity/sys-user.object.ts‎

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,18 @@ export const SysUser = ObjectSchema.create({
115115
// "More" menu) so platform admins can manage an account from either
116116
// the Users list or an open user record — without dropping to SQL or
117117
// a custom Setup wizard.
118+
//
119+
// Every action in this block, and `set_user_manager` below, is OFFERED
120+
// only to the one standing its door admits (ADR-0068 D4): each endpoint
121+
// runs the platform-admin gate first and answers every other caller —
122+
// org owners and admins included — 403 `PERMISSION_DENIED`. So each one
123+
// authors `visible: 'current_user.isPlatformAdmin == true'`, the ADR-0095
124+
// D3 PLATFORM_ADMIN posture rung that the session payload emits and the
125+
// gate judges; `requiresFeature` composes onto it at parse time, e.g.
126+
// `(current_user.isPlatformAdmin == true) && features.admin == true`.
127+
// ⛔ Never `'platform_admin' in current_user.positions`: `EvalUserSchema`
128+
// rules that standing is read from this key, never from the array. The
129+
// repo-wide pin is `platform-admin-affordance-standing.test.ts`.
118130
{
119131
name: 'ban_user',
120132
label: 'Ban User',
@@ -123,6 +135,8 @@ export const SysUser = ObjectSchema.create({
123135
locations: ['list_item', 'record_header'],
124136
type: 'api',
125137
target: '/api/v1/auth/admin/ban-user',
138+
// Platform-admin standing (ADR-0068 D4) — see the block header above.
139+
visible: 'current_user.isPlatformAdmin == true',
126140
requiresFeature: 'admin',
127141
recordIdParam: 'userId',
128142
successMessage: 'User banned',
@@ -149,6 +163,8 @@ export const SysUser = ObjectSchema.create({
149163
locations: ['list_item', 'record_header'],
150164
type: 'api',
151165
target: '/api/v1/auth/admin/unban-user',
166+
// Platform-admin standing (ADR-0068 D4) — see the block header above.
167+
visible: 'current_user.isPlatformAdmin == true',
152168
requiresFeature: 'admin',
153169
recordIdParam: 'userId',
154170
successMessage: 'User unbanned',
@@ -165,6 +181,8 @@ export const SysUser = ObjectSchema.create({
165181
locations: ['list_item', 'record_header'],
166182
type: 'api',
167183
target: '/api/v1/auth/admin/unlock-user',
184+
// Platform-admin standing (ADR-0068 D4) — see the block header above.
185+
visible: 'current_user.isPlatformAdmin == true',
168186
requiresFeature: 'admin',
169187
recordIdParam: 'userId',
170188
successMessage: 'Account unlocked',
@@ -185,6 +203,8 @@ export const SysUser = ObjectSchema.create({
185203
locations: ['list_toolbar'],
186204
type: 'api',
187205
target: '/api/v1/auth/admin/create-user',
206+
// Platform-admin standing (ADR-0068 D4) — see the block header above.
207+
visible: 'current_user.isPlatformAdmin == true',
188208
requiresFeature: 'admin',
189209
successMessage: 'User created',
190210
refreshAfter: true,
@@ -249,6 +269,8 @@ export const SysUser = ObjectSchema.create({
249269
// legacy role scalar), can mint a temporary password, and stamps
250270
// must_change_password.
251271
target: '/api/v1/auth/admin/set-user-password',
272+
// Platform-admin standing (ADR-0068 D4) — see the block header above.
273+
visible: 'current_user.isPlatformAdmin == true',
252274
requiresFeature: 'admin',
253275
recordIdParam: 'userId',
254276
successMessage: 'Password updated',
@@ -298,6 +320,8 @@ export const SysUser = ObjectSchema.create({
298320
locations: ['list_item', 'record_header'],
299321
type: 'api',
300322
target: '/api/v1/auth/admin/impersonate-user',
323+
// Platform-admin standing (ADR-0068 D4) — see the block header above.
324+
visible: 'current_user.isPlatformAdmin == true',
301325
requiresFeature: 'admin',
302326
recordIdParam: 'userId',
303327
successMessage: 'Now impersonating user',
@@ -358,7 +382,14 @@ export const SysUser = ObjectSchema.create({
358382
// half is deliberately NOT copied (it would hide the button from every
359383
// admin). `has()` per operand for the sparse action face (#8990) — the
360384
// rationale is on the self-service block below.
361-
visible: 'has(record.source) && record.source != "idp_provisioned"',
385+
//
386+
// ANDed ahead of it, the platform-admin standing the door's gate judges
387+
// (ADR-0068 D4, the admin block header above): without it the button
388+
// was offered to every caller who could open a user row, and the door
389+
// refused each one 403 `PERMISSION_DENIED`. One flat conjunction, the
390+
// principal term first, the way the self-service predicates below lead
391+
// with theirs.
392+
visible: 'current_user.isPlatformAdmin == true && has(record.source) && record.source != "idp_provisioned"',
362393
// The action collects a param, so its explanatory line rides
363394
// `description` and ⛔ never `confirmText` — pairing the two shows two
364395
// dialogs for one decision (#7278/#7309).

0 commit comments

Comments
 (0)