Repository navigation
Commit 879bd38
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
File tree
- .changeset
- packages/platform-objects/src
- identity
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
65 | 65 | | |
66 | 66 | | |
67 | 67 | | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
| 75 | + | |
| 76 | + | |
| 77 | + | |
68 | 78 | | |
69 | | - | |
| 79 | + | |
70 | 80 | | |
71 | 81 | | |
72 | 82 | | |
| |||
100 | 110 | | |
101 | 111 | | |
102 | 112 | | |
103 | | - | |
| 113 | + | |
| 114 | + | |
| 115 | + | |
| 116 | + | |
104 | 117 | | |
105 | 118 | | |
106 | 119 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
77 | 77 | | |
78 | 78 | | |
79 | 79 | | |
| 80 | + | |
| 81 | + | |
| 82 | + | |
| 83 | + | |
| 84 | + | |
| 85 | + | |
| 86 | + | |
| 87 | + | |
| 88 | + | |
| 89 | + | |
| 90 | + | |
80 | 91 | | |
81 | 92 | | |
82 | 93 | | |
| |||
97 | 108 | | |
98 | 109 | | |
99 | 110 | | |
100 | | - | |
| 111 | + | |
101 | 112 | | |
102 | 113 | | |
103 | 114 | | |
| |||
118 | 129 | | |
119 | 130 | | |
120 | 131 | | |
121 | | - | |
| 132 | + | |
122 | 133 | | |
123 | 134 | | |
124 | 135 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
73 | 73 | | |
74 | 74 | | |
75 | 75 | | |
| 76 | + | |
| 77 | + | |
| 78 | + | |
| 79 | + | |
| 80 | + | |
| 81 | + | |
| 82 | + | |
| 83 | + | |
| 84 | + | |
| 85 | + | |
| 86 | + | |
| 87 | + | |
76 | 88 | | |
77 | 89 | | |
78 | 90 | | |
| |||
90 | 102 | | |
91 | 103 | | |
92 | 104 | | |
| 105 | + | |
| 106 | + | |
93 | 107 | | |
94 | 108 | | |
95 | 109 | | |
| |||
130 | 144 | | |
131 | 145 | | |
132 | 146 | | |
| 147 | + | |
| 148 | + | |
133 | 149 | | |
134 | 150 | | |
135 | 151 | | |
| |||
160 | 176 | | |
161 | 177 | | |
162 | 178 | | |
| 179 | + | |
| 180 | + | |
163 | 181 | | |
164 | 182 | | |
165 | 183 | | |
| |||
188 | 206 | | |
189 | 207 | | |
190 | 208 | | |
| 209 | + | |
| 210 | + | |
191 | 211 | | |
192 | 212 | | |
193 | 213 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
63 | 63 | | |
64 | 64 | | |
65 | 65 | | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
66 | 75 | | |
67 | 76 | | |
| 77 | + | |
68 | 78 | | |
69 | 79 | | |
70 | | - | |
| 80 | + | |
71 | 81 | | |
72 | 82 | | |
73 | 83 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
115 | 115 | | |
116 | 116 | | |
117 | 117 | | |
| 118 | + | |
| 119 | + | |
| 120 | + | |
| 121 | + | |
| 122 | + | |
| 123 | + | |
| 124 | + | |
| 125 | + | |
| 126 | + | |
| 127 | + | |
| 128 | + | |
| 129 | + | |
118 | 130 | | |
119 | 131 | | |
120 | 132 | | |
| |||
123 | 135 | | |
124 | 136 | | |
125 | 137 | | |
| 138 | + | |
| 139 | + | |
126 | 140 | | |
127 | 141 | | |
128 | 142 | | |
| |||
149 | 163 | | |
150 | 164 | | |
151 | 165 | | |
| 166 | + | |
| 167 | + | |
152 | 168 | | |
153 | 169 | | |
154 | 170 | | |
| |||
165 | 181 | | |
166 | 182 | | |
167 | 183 | | |
| 184 | + | |
| 185 | + | |
168 | 186 | | |
169 | 187 | | |
170 | 188 | | |
| |||
185 | 203 | | |
186 | 204 | | |
187 | 205 | | |
| 206 | + | |
| 207 | + | |
188 | 208 | | |
189 | 209 | | |
190 | 210 | | |
| |||
249 | 269 | | |
250 | 270 | | |
251 | 271 | | |
| 272 | + | |
| 273 | + | |
252 | 274 | | |
253 | 275 | | |
254 | 276 | | |
| |||
298 | 320 | | |
299 | 321 | | |
300 | 322 | | |
| 323 | + | |
| 324 | + | |
301 | 325 | | |
302 | 326 | | |
303 | 327 | | |
| |||
358 | 382 | | |
359 | 383 | | |
360 | 384 | | |
361 | | - | |
| 385 | + | |
| 386 | + | |
| 387 | + | |
| 388 | + | |
| 389 | + | |
| 390 | + | |
| 391 | + | |
| 392 | + | |
362 | 393 | | |
363 | 394 | | |
364 | 395 | | |
| |||
0 commit comments