Skip to content

Commit 3306cad

Browse 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>
1 parent eca6b61 commit 3306cad

13 files changed

Lines changed: 568 additions & 60 deletions
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
'@objectstack/plugin-auth': patch
3+
'@objectstack/platform-objects': patch
4+
---
5+
6+
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.

‎content/docs/permissions/tenant-audit-census.mdx‎

Lines changed: 17 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@ what moved this page's population from 225 to 227; nothing about the two sites
9393
changed, only whether this instrument could see them.
9494

9595
**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
9797
of the population, concentrated in exactly the seed and bootstrap paths this
9898
control exists for. Scoring an unreadable receiver as "not an engine" would have
9999
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.
132132

133133
The same holds twice over for the context. An options argument spelled as a
134134
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
136136
context resolved from an inline literal or a local `const` can be tested for
137137
`isSystem`; one arriving from a helper call cannot.
138138

@@ -197,10 +197,10 @@ reproduce them. Where it disagrees, it disagrees on the page:
197197

198198
| carried figure | where it survives | this census |
199199
| :--- | :--- | ---: |
200-
| 175 write call sites | quoted in the merged changeset | **237** |
200+
| 175 write call sites | quoted in the merged changeset | **238** |
201201
| 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 |
204204
| 141 and 132, two independent re-derivations | the card that filed this work | — |
205205

206206
**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
210210
at any commit.
211211

212212
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
214214
through an erased (`any`) receiver, and the 51 that name their object through a
215215
`const` rather than inline. An instrument that read either the way a person does
216216
would report a smaller number and would not say so.
217217

218218
The fourth row is the one worth flagging to anyone citing it. **The 135 / 77%
219219
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
221221
run-time fact — so the claim is neither confirmed nor refuted, and the honest
222222
answer is that a static reading cannot settle it.
223223

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
225225
READ and holds no tenant context, against a decidably tenancy-enabled object.
226226
That is the control's provable yield surface. ⛔ Do not cite it as "the sites
227227
without tenant context" — **29 further sites** have an options argument this
@@ -233,28 +233,28 @@ cannot read, and they are neither in nor out.
233233

234234
| what | count |
235235
| :--- | ---: |
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 |
238238
| …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 |
240240
| …against an object that declares tenancy off | 1 |
241-
| threading a tenant context | 178 |
241+
| threading a tenant context | 179 |
242242
| PROVABLY carrying none (options read, no context key) | **8** |
243243
| …of those, against a decidably tenancy-enabled object | **2** |
244244
| options argument UNREADABLE — may or may not carry one | 51 |
245245
| …of those, against a decidably tenancy-enabled object | 29 |
246246
| threading a decidably ELEVATED (`isSystem`) context | 135 |
247247
| 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 |
249249

250250
| how the instrument reached the site | count |
251251
| :--- | ---: |
252252
| 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 |
254254
| receiver erased, placed by an `object: string` PARAMETER | 5 |
255255
| receiver erased, placed by an `UNTYPED_RECEIVERS` row | 5 |
256256

257-
| object name spelled inline | 110 |
257+
| object name spelled inline | 111 |
258258
| object name spelled through a `const` | 51 |
259259
| object name is an `object: string` parameter | 7 |
260260
| 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;
307307
their values are not compared. The reasoning, and the measurement behind it,
308308
are in `scripts/check-tenant-audit-census.mjs`.
309309

310-
Measured on 2026-10-23 at `fb63d396b`.
310+
Measured on 2026-10-23 at `d45e01440`.
311311

312312
| corpus scale (not enforced) | count |
313313
| :--- | ---: |
314-
| tracked non-test sources scanned | 714 |
314+
| tracked non-test sources scanned | 716 |
315315
| engine-shaped types recognised | 87 |
316316
| declared objects in the registry | 117 |
317317
| same-named calls subtracted as non-engine | 172 |

‎docs/audits/2026-08-tenant-audit-write-call-sites.counts.md‎

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -33,19 +33,19 @@ silent, and `node scripts/tenant-audit-census.mjs --write` is the resolution.
3333

3434
| Measure | Value |
3535
|---|---:|
36-
| Write call sites | 237 |
37-
| Object name statically decidable | 161 |
36+
| Write call sites | 238 |
37+
| Object name statically decidable | 162 |
3838
| Object name chosen at run time | 76 |
39-
| Against a tenancy-enabled object | 160 |
39+
| Against a tenancy-enabled object | 161 |
4040
| Against an object declaring tenancy off | 1 |
41-
| Threading a tenant context | 178 |
41+
| Threading a tenant context | 179 |
4242
| Provably carrying none | 8 |
4343
| …and decidably tenancy-enabled | 2 |
4444
| Options argument unreadable | 51 |
4545
| …and decidably tenancy-enabled | 29 |
4646
| Threading a decidably elevated context | 135 |
4747
| Threading a decidably non-elevated context | 0 |
48-
| Threading a context of undecidable elevation | 94 |
48+
| Threading a context of undecidable elevation | 95 |
4949

5050
## Subtractions the census could NOT defend — enforced
5151

@@ -90,11 +90,11 @@ holds still. They are required to be HERE and to say WHEN they were true;
9090
their values are not compared. The reasoning, and the measurement behind it,
9191
are in `scripts/check-tenant-audit-census.mjs`.
9292

93-
Measured on 2026-10-23 at `fb63d396b`.
93+
Measured on 2026-10-23 at `d45e01440`.
9494

9595
| corpus scale (not enforced) | count |
9696
| :--- | ---: |
97-
| tracked non-test sources scanned | 714 |
97+
| tracked non-test sources scanned | 716 |
9898
| engine-shaped types recognised | 87 |
9999
| declared objects in the registry | 117 |
100100
| same-named calls subtracted as non-engine | 172 |
@@ -128,6 +128,7 @@ Measured on 2026-10-23 at `fb63d396b`.
128128
| `packages/plugins/plugin-auth/src/admin-set-user-manager.ts` | `update` | `sys_user` | enabled | context, elevation undecidable | 1 |
129129
| `packages/plugins/plugin-auth/src/admin-user-endpoints.ts` | `insert` | `sys_audit_log` | enabled | elevated | 1 |
130130
| `packages/plugins/plugin-auth/src/admin-user-endpoints.ts` | `update` | `sys_user` | enabled | elevated | 1 |
131+
| `packages/plugins/plugin-auth/src/admin-user-endpoints.ts` | `insert` | `sys_user_permission_set` | enabled | context, elevation undecidable | 1 |
131132
| `packages/plugins/plugin-auth/src/adopt-membership.ts` | `update` | `SystemObjectName.MEMBER` | undecidable | PROVABLY NONE | 1 |
132133
| `packages/plugins/plugin-auth/src/audience-gate-test-support.ts` | `insert` | `sys_invitation` | enabled | elevated | 1 |
133134
| `packages/plugins/plugin-auth/src/auth-manager-database-hooks.ts` | `update` | `sys_user` | enabled | options unreadable | 2 |

‎packages/platform-objects/src/apps/translations/en.objects.generated.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,10 @@ export const enObjects: NonNullable<TranslationData['objects']> = {
182182
},
183183
mustChangePassword: {
184184
label: "Require Password Change On First Login"
185+
},
186+
permissionSet: {
187+
label: "Permission Set",
188+
helpText: "Optional. Grants this permission set to the new user, so they can open their apps on first sign-in."
185189
}
186190
},
187191
resultDialog: {
@@ -191,6 +195,7 @@ export const enObjects: NonNullable<TranslationData['objects']> = {
191195
fields: {
192196
"user.email": "Email",
193197
"user.phoneNumber": "Phone Number",
198+
permissionSet: "Permission Set",
194199
temporaryPassword: "Temporary Password"
195200
}
196201
}

‎packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,10 @@ export const esESObjects: NonNullable<TranslationData['objects']> = {
182182
},
183183
mustChangePassword: {
184184
label: "Exigir cambio de contraseña en el primer inicio de sesión"
185+
},
186+
permissionSet: {
187+
label: "Conjunto de permisos",
188+
helpText: "Opcional. Concede este conjunto de permisos al nuevo usuario para que pueda abrir sus aplicaciones desde el primer inicio de sesión."
185189
}
186190
},
187191
resultDialog: {
@@ -191,6 +195,7 @@ export const esESObjects: NonNullable<TranslationData['objects']> = {
191195
fields: {
192196
"user.email": "Correo electrónico",
193197
"user.phoneNumber": "Número de teléfono",
198+
permissionSet: "Conjunto de permisos",
194199
temporaryPassword: "Contraseña temporal"
195200
}
196201
}

‎packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,10 @@ export const jaJPObjects: NonNullable<TranslationData['objects']> = {
182182
},
183183
mustChangePassword: {
184184
label: "初回サインイン時にパスワード変更を必須にする"
185+
},
186+
permissionSet: {
187+
label: "権限セット",
188+
helpText: "任意。この権限セットを新しいユーザーに付与し、初回サインインから自分のアプリを開けるようにします。"
185189
}
186190
},
187191
resultDialog: {
@@ -191,6 +195,7 @@ export const jaJPObjects: NonNullable<TranslationData['objects']> = {
191195
fields: {
192196
"user.email": "メールアドレス",
193197
"user.phoneNumber": "電話番号",
198+
permissionSet: "権限セット",
194199
temporaryPassword: "一時パスワード"
195200
}
196201
}

‎packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,10 @@ export const zhCNObjects: NonNullable<TranslationData['objects']> = {
182182
},
183183
mustChangePassword: {
184184
label: "首次登录时必须修改密码"
185+
},
186+
permissionSet: {
187+
label: "权限集",
188+
helpText: "可选。将该权限集授予新用户,使其首次登录即可打开自己的应用。"
185189
}
186190
},
187191
resultDialog: {
@@ -191,6 +195,7 @@ export const zhCNObjects: NonNullable<TranslationData['objects']> = {
191195
fields: {
192196
"user.email": "邮箱",
193197
"user.phoneNumber": "手机号",
198+
permissionSet: "权限集",
194199
temporaryPassword: "临时密码"
195200
}
196201
}

‎packages/platform-objects/src/identity/create-user-result-dialog-copy.test.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,8 @@ describe('create_user — the result dialog holds for a typed and a generated pa
5454
expect(action('create_user')?.resultDialog?.fields?.map((f) => [f.path, f.format])).toEqual([
5555
['user.email', 'text'],
5656
['user.phoneNumber', 'text'],
57+
// #25338: present only when the request granted a permission set.
58+
['permissionSet', 'text'],
5759
['temporaryPassword', 'secret'],
5860
]);
5961
});

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

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -286,6 +286,24 @@ export const SysUser = ObjectSchema.create({
286286
required: false,
287287
defaultValue: true,
288288
},
289+
// [#25338] Optional: grant one permission set in the same step, so a
290+
// new user does not sign in to a screen of refusals. INLINE, not
291+
// field-backed: the grant object lives in plugin-security, whose
292+
// metadata the console need not have loaded on this list, and an
293+
// unresolvable field-backed param makes the dialog refuse the whole
294+
// action. The grant column is a text field anyway (its picker is a
295+
// widget hint params do not inherit), so nothing is lost.
296+
// The route writes the grant under the administrator's OWN context in
297+
// the same transaction as the account: the delegated-administration
298+
// gate and the catalog name check judge it as they judge Setup's
299+
// grant form, and a refusal creates no user.
300+
{
301+
name: 'permissionSet',
302+
type: 'text',
303+
label: 'Permission Set',
304+
required: false,
305+
helpText: 'Optional. Grants this permission set to the new user, so they can open their apps on first sign-in.',
306+
},
289307
],
290308
resultDialog: {
291309
title: 'User Created',
@@ -301,6 +319,7 @@ export const SysUser = ObjectSchema.create({
301319
// phone-based users, temporaryPassword only when the server minted one.
302320
{ path: 'user.email', label: 'Email', format: 'text' },
303321
{ path: 'user.phoneNumber', label: 'Phone Number', format: 'text' },
322+
{ path: 'permissionSet', label: 'Permission Set', format: 'text' },
304323
{ path: 'temporaryPassword', label: 'Temporary Password', format: 'secret' },
305324
],
306325
},

0 commit comments

Comments
 (0)