You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Commit 3306cad
Browse filesBrowse the repository at this point in the historyBrowse files
feat(plugin-auth): Create User can grant a permission set in the same step, through the delegated-administration gate (#25381)
<!-- claude-projects-attribution: {"github_login":"os-tesla"} -->
_Requested by **Tesla** · [project
thread](https://claude.ai/code/project/chan_01AXsQkLFKRdQCqQ3RTr6Jao?thread=cmsg_01AXsQkLFKRdQCqQ3RTr6JaoL6oH8EmKk5FD1KRSQUD9wm)_Fixes#25338
Clause-②: yes (widening: `POST /api/v1/auth/admin/create-user` and the
`sys_user` `create_user` action accept an optional `permissionSet`; a
request without it is answered exactly as before)
Metadata-protocol: none
Companion UI half: objectstack-ai/objectui#13347 (a member who may read
none of an app's objects lands on one access page instead of a grid of
refusals).
## What changes
**Create User can grant one permission set in the same step.**
- `create_user` gains an optional inline `permissionSet` param (text,
the set's machine name). The result dialog shows it when one was
granted.
- `runAdminCreateUser` (plugin-auth) parses `permissionSet`. When one is
named, it resolves the calling administrator's OWN execution context
through `resolveRequestExecutionContext` (`@objectstack/core`,
session-only, fail-closed) before anything is written, then runs the
account creation and `engine.insert('sys_user_permission_set', {
user_id, permission_set }, { context: <caller's context> })` inside one
`engine.transaction()`.
- A refused grant answers the refusal's own status and code (403
`PERMISSION_DENIED` from the delegated-administration gate, 400
`VALIDATION_FAILED` with `fields` for a name the catalog does not hold),
and the transaction rolls the user back.
- Refused before any write: a non-string `permissionSet` (400), a caller
whose own context does not resolve (403), a host that wires no grant
seam (501).
- Cloud-managed is unchanged: `create_user` stays hidden under
`features.cloudManaged` (#25295).
## Security floor
- **No new assignment path.** The grant is the same non-system insert
into `sys_user_permission_set` that Setup's grant form makes, under the
caller's context, so the security middleware, the ADR-0090 D12
`DelegatedAdminGate` (which stamps `granted_by`) and the catalog name
check (`grant-permission-set-name.ts`) judge it exactly as they judge
the form. No `isSystem` context is used for the grant.
- **No widening of who can grant what.** The route's existing
platform-admin gate decides who may create a user. It never decides what
that user may be granted; the D12 gate does. Dogfood G1 reads
`granted_by` = the administrator, which is the gate's own stamp.
- Ruling carried: hotcrm#2194 record 6354968262 ("25251 B hotcrm B").
## Verification
- `plugin-auth` `admin-user-endpoints.test.ts`: 65/65, 8 new cases (none
asked; assigned under the actor's context, not system; 403 refusal rolls
back and writes no audit; unknown name gives 400 with `fields` and rolls
back; no seam 501; unresolved context 403; non-string 400; the param is
inline, never field-backed).
- New dogfood `create-user-permission-set.dogfood.test.ts` (showcase
boot): 3/3. G1 assigned `showcase_contributor` with `granted_by` =
admin; G2 `no_such_set_25338` gives 400 and no user; G3 a member caller
gets 403 and no user.
- Ablation: `runAtomically` replaced with a plain `work()` call (a
`ctx.logger.debug` marker, proven in `dist/` by
`ablation-dist-preflight.mjs`). G2 goes red: the user survives the
refused grant ("expected [ …(26) ] to have a length of +0 but got 1").
Restored to HEAD's blob, rebuilt, `--absent` passes. A first leg used a
comment marker that the build stripped, so preflight refused it and it
measured nothing; it is not counted.
- `platform-objects`: 88 files, 1318 tests pass (the create-user
result-dialog pin now lists `permissionSet`). `tsc --noEmit` clean for
plugin-auth and platform-objects. `check-i18n-bundles` OK (zh-CN, ja-JP,
es-ES translated).
- Tenant-audit census re-counted for the one new write site (237 → 238)
with `tenant-audit-census.mjs --write`, and the page's hand-written
figures updated to match.
## Acceptance notes
- The param is INLINE text, not field-backed on
`sys_user_permission_set.permission_set`. That object belongs to
plugin-security, and the console's `ActionParamDialog` refuses the whole
action when a field-backed param's object metadata is not loaded. The
field-backed form would not have given a picker anyway: a field-backed
param takes the field's `type` (text), not its `widget:
'permission-ref'` hint. A picker for this param needs params to honour a
widget hint (objectui), noted here, not filed.
- A datasource whose driver has no `beginTransaction` runs the account
and the grant unatomically, the engine's declared limit (ADR-0119 D1).
🤖 Generated with [Claude Code](https://claude.com/claude-code)
https://claude.ai/code/session_01TpToTVFniSfCoKfhPL16Nz
---------
Co-authored-by: Claude <noreply@anthropic.com>
feat: Create User can grant a permission set in the same step, judged by the same delegated-administration gate as Setup's grant form
7
+
8
+
Clause-②: yes (widening: `POST /api/v1/auth/admin/create-user` and the `sys_user``create_user` action accept an optional `permissionSet`; a request without it is answered exactly as before)
9
+
Metadata-protocol: none
10
+
11
+
A user created with no permission set signed in to a screen of refusals. Setup → Users → Create User now offers an optional **Permission Set** (the set's machine name, checked against the security catalog).
12
+
13
+
-**How the grant is written.** The route inserts the `sys_user_permission_set` row under the calling administrator's OWN execution context, never the system's. The security middleware, the ADR-0090 D12 delegated-administration gate (which stamps `granted_by`) and the catalog name check judge it exactly as they judge the grant form. The route's platform-admin gate decides who may create a user; it never decides what that user may be granted.
14
+
-**One step, one outcome.** The account and the grant run in one engine transaction. A refused grant answers the refusal's own status and code (403 `PERMISSION_DENIED` from the gate, 400 `VALIDATION_FAILED` with its `fields` for a name the catalog does not hold), and no user is created. A datasource whose driver has no `beginTransaction` runs the two writes unatomically, the engine's declared limit (ADR-0119 D1).
15
+
-**Refused before anything is written:** a `permissionSet` that is not a string (400), a caller whose own context does not resolve (403), and a host that wires no grant seam (501).
16
+
-**Response.** The success body carries `permissionSet` beside the user, and the result dialog shows it.
17
+
-**Cloud-managed environments are unchanged.**`create_user` stays hidden while `features.cloudManaged` is on.
Copy file name to clipboardExpand all lines: content/docs/permissions/tenant-audit-census.mdx
+17-17Lines changed: 17 additions & 17 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -93,7 +93,7 @@ what moved this page's population from 225 to 227; nothing about the two sites
93
93
changed, only whether this instrument could see them.
94
94
95
95
**The expensive failure direction is a keyword.** Sites whose receiver the author
96
-
typed `any` have no type to read, and there are 54 of them — just over a fifth
96
+
typed `any` have no type to read, and there are 55 of them — just over a fifth
97
97
of the population, concentrated in exactly the seed and bootstrap paths this
98
98
control exists for. Scoring an unreadable receiver as "not an engine" would have
99
99
dropped every one of them silently, with a clean exit and a smaller number that
@@ -132,7 +132,7 @@ are reported as `undecidable` rather than assumed either way.
132
132
133
133
The same holds twice over for the context. An options argument spelled as a
134
134
literal can be read; one spelled `options`, `{ ...opts }`, or handed through a
135
-
forwarding shim cannot, and **51 of the 237 sites are spelled that way**. A
135
+
forwarding shim cannot, and **51 of the 238 sites are spelled that way**. A
136
136
context resolved from an inline literal or a local `const` can be tested for
137
137
`isSystem`; one arriving from a helper call cannot.
138
138
@@ -197,10 +197,10 @@ reproduce them. Where it disagrees, it disagrees on the page:
197
197
198
198
| carried figure | where it survives | this census |
199
199
| :--- | :--- | ---: |
200
-
| 175 write call sites | quoted in the merged changeset |**237**|
200
+
| 175 write call sites | quoted in the merged changeset |**238**|
201
201
| 24 carrying no tenant context | quoted in the merged changeset |**2** provable and tenancy-enabled; **29** more whose options argument is unreadable |
202
-
| 127 of 175 statically decidable, 48 runtime-parameter-name sites | restated on the `isSystem`-scoping card |**161 of 237** decidable, **76** undecidable |
203
-
| 135 (77%) silenced by the `isSystem` guard before the posture gate | the lost issue body — **no surviving corroboration**|**not reproduced**: 135 decidably elevated, 0 decidably not, 94 undecidable |
202
+
| 127 of 175 statically decidable, 48 runtime-parameter-name sites | restated on the `isSystem`-scoping card |**162 of 238** decidable, **76** undecidable |
203
+
| 135 (77%) silenced by the `isSystem` guard before the posture gate | the lost issue body — **no surviving corroboration**|**not reproduced**: 135 decidably elevated, 0 decidably not, 95 undecidable |
204
204
| 141 and 132, two independent re-derivations | the card that filed this work | — |
205
205
206
206
**The differences are not reconciled, and deliberately so.** The old census's
@@ -210,18 +210,18 @@ be stated is what this instrument counts, which is written above and re-runnable
210
210
at any commit.
211
211
212
212
Two structural facts do plausibly widen this reading against any hand or regex
213
-
one, and both are counted in the generated tables below: the 54 sites reached
213
+
one, and both are counted in the generated tables below: the 55 sites reached
214
214
through an erased (`any`) receiver, and the 51 that name their object through a
215
215
`const` rather than inline. An instrument that read either the way a person does
216
216
would report a smaller number and would not say so.
217
217
218
218
The fourth row is the one worth flagging to anyone citing it. **The 135 / 77%
219
219
figure has no surviving corroboration anywhere in the tree.** This census reads
220
-
135 of 237 (57%) as decidably elevated, with 94 more whose elevation is a
220
+
135 of 238 (57%) as decidably elevated, with 95 more whose elevation is a
221
221
run-time fact — so the claim is neither confirmed nor refuted, and the honest
222
222
answer is that a static reading cannot settle it.
223
223
224
-
⇒ **Cite `2 / 237`, and say what it is**: the sites whose options argument was
224
+
⇒ **Cite `2 / 238`, and say what it is**: the sites whose options argument was
225
225
READ and holds no tenant context, against a decidably tenancy-enabled object.
226
226
That is the control's provable yield surface. ⛔ Do not cite it as "the sites
227
227
without tenant context" — **29 further sites** have an options argument this
@@ -233,28 +233,28 @@ cannot read, and they are neither in nor out.
233
233
234
234
| what | count |
235
235
| :--- | ---: |
236
-
| write call sites on the application surface |**237**|
237
-
| …whose object name is statically decidable |161|
236
+
| write call sites on the application surface |**238**|
237
+
| …whose object name is statically decidable |162|
238
238
| …whose object name is chosen at run time | 76 |
239
-
| …against an object with tenancy ENABLED |160|
239
+
| …against an object with tenancy ENABLED |161|
240
240
| …against an object that declares tenancy off | 1 |
241
-
| threading a tenant context |178|
241
+
| threading a tenant context |179|
242
242
| PROVABLY carrying none (options read, no context key) |**8**|
243
243
| …of those, against a decidably tenancy-enabled object |**2**|
244
244
| options argument UNREADABLE — may or may not carry one | 51 |
245
245
| …of those, against a decidably tenancy-enabled object | 29 |
246
246
| threading a decidably ELEVATED (`isSystem`) context | 135 |
247
247
| threading a context that is decidably NOT elevated | 0 |
248
-
| threading a context whose elevation is a run-time fact |94|
248
+
| threading a context whose elevation is a run-time fact |95|
249
249
250
250
| how the instrument reached the site | count |
251
251
| :--- | ---: |
252
252
| receiver carried a readable engine type | 183 |
253
-
| receiver erased, placed by the object NAME |44|
253
+
| receiver erased, placed by the object NAME |45|
254
254
| receiver erased, placed by an `object: string` PARAMETER | 5 |
255
255
| receiver erased, placed by an `UNTYPED_RECEIVERS` row | 5 |
256
256
257
-
| object name spelled inline |110|
257
+
| object name spelled inline |111|
258
258
| object name spelled through a `const`| 51 |
259
259
| object name is an `object: string` parameter | 7 |
260
260
| object name is some other run-time expression | 69 |
@@ -307,11 +307,11 @@ holds still. They are required to be HERE and to say WHEN they were true;
307
307
their values are not compared. The reasoning, and the measurement behind it,
308
308
are in `scripts/check-tenant-audit-census.mjs`.
309
309
310
-
Measured on 2026-10-23 at `fb63d396b`.
310
+
Measured on 2026-10-23 at `d45e01440`.
311
311
312
312
| corpus scale (not enforced) | count |
313
313
| :--- | ---: |
314
-
| tracked non-test sources scanned |714|
314
+
| tracked non-test sources scanned |716|
315
315
| engine-shaped types recognised | 87 |
316
316
| declared objects in the registry | 117 |
317
317
| same-named calls subtracted as non-engine | 172 |
0 commit comments