Skip to content

Commit 48cca4a

Browse files
feat(plugin-security,platform-objects): grant forms pick a position or permission set by name from the catalog (#15204 B2) (#22939)
Part of #15204 Clause-②: no **B2, objectstack half (objectui#7611 names it).** Setup's user page grants a position or a permission set by picking its NAME from the security catalog, instead of an `add` picker that listed `sys_position` / `sys_permission_set` rows (ADR-0131 D3/D4). This PR is one of the pair that gates #22900 (the boot stops writing `sys_permission_set` rows): once #22900 lands, a fresh database holds no such row, and the old pickers would offer nothing. ## What changes | Surface | Before | Now | |:--|:--|:--| | `sys-user.page.ts` Positions / Permission Sets lists | "Assign position" / "Grant permission set" `add` pickers over `sys_position` / `sys_permission_set` rows, beside **New** | the two `add` blocks are deleted. **New** opens the grant form. Remove is the host's row delete (`RelatedRecordActionsBridge`), unchanged. The Business Units list keeps its `add` | | `sys_user_position.position` | a text input (type `text`) | `widget: 'position-ref'`: the form lists `GET /api/v1/meta/position` and stores the chosen name | | `sys_user_permission_set.permission_set` | a text input | `widget: 'permission-ref'`: the form lists `GET /api/v1/meta/permission` and stores the chosen name | | `sys_user_permission_set.permission_set_id` ("Permission Set (legacy id)") | an editable lookup on the grant form, though it grants nothing | `hidden: true`, so it is off the create and edit forms (maintainer, 2026-10-12, 「legacy id 字段隐藏,同意」). The column stays until ADR-0131 C7b drops its data | **Until the console pin moves, the two fields render as text inputs** (the type renderer: a text box for the name). `position-ref` / `permission-ref` ship in objectui#12175 (PR-5, merged as `088e0f705520`), and this repo's `.objectui-sha` still pins `4997995022f8`. An unregistered widget falls back to the `text` renderer, so nothing breaks. **The pin follows #22874:** `.objectui-sha` is that PR's single-writer path, and objectui from `e5311165` on reads `allowEnvironmentOverlay` only, which `main`'s server sends once #22874 lands. A follow-up PR then moves the pin to `088e0f705` (or later), re-measuring only that hop. `main` is unreleased, so no released build shows the gap. This branch briefly carried that pin move (`e486abb9c`), reverted by `7de0f00c9`. The net diff is phase 2 only: 9 files, tree-identical to `d4c22c8fe`. ## Measured before the build (phase 1, #15204 6116275825) On a booted showcase, as the admin: - both grant lists already offered **New**; - with the two `add` blocks deleted, every row still offers **Delete**, and a delete was exercised end to end (UI 4 → 3 rows; the API shows the row gone). The renderer's `onRowDelete` prefers the host's `onDelete`; the `add`-based remove is only its fallback when no host bridge is mounted. ## Pins - `packages/qa/dogfood/test/grant-name-pickers.dogfood.test.ts` reads the SERVED `GET /meta/object/<name>` on a booted showcase: - `position` carries `position-ref`; - `permission_set` carries `permission-ref`; - `permission_set_id` is `hidden`, with `permission_set` not hidden as the control. - Ablation: stripping the two hints and `hidden` from the objects, then rebuilding, turned all 3 cases red (`expected undefined to be 'position-ref'`, …); restoring turned them green. - `sys-user-permission-set-window-columns.test.ts` asserts that neither grant list carries `add`, with the Business Units list's `add` as the control. Ablation: re-adding an `add` to the Permission Sets list turned that case red. - `format.server-ready-dev-admin-audience.test.ts`: the CLI banner's "grant it a permission set" pin is re-anchored on the Permission Sets list (its New), since the "Grant permission set" label is gone. - `platform-page-i18n-parity.test.ts`: the anti-vacuity floor of inline locale maps is 44 (was 45), because the two `add` labels went with their blocks. ## Browser check against the widget (objectui#12175 head `8237a1cd886d`, built locally before it merged; the merge `088e0f705520` carries the same widget files) - The **Position** and **Permission Set** fields render as registry comboboxes (`registry-ref-position` / `registry-ref-permission`), listing 16 positions and 17 permission sets. - Picking `auditor` and `mcp_agent_data_read`, then pressing Create, wrote both grant rows (verified through the data API). - "Permission Set (legacy id)" is gone from the form, and both list headers show only **New**. ## Verification (local, at this head) - dogfood in full: 253 files passed, 1 skipped; 2,039 tests passed; - plugin-security: 197 files, 4,045 passed; - platform-objects: 69 files, 1,086 passed; - cli unit tier: 282 files, all passed after the floor fix (`platform-page-i18n-parity` 27/27); - typecheck green: platform-objects, plugin-security, cli, dogfood; - `dispatch-gates --ran`: 72 derived families, all recorded with exit 0. ## Changeset `@objectstack/platform-objects` and `@objectstack/plugin-security` **patch**, `Clause-②: no`. This changes shipped metadata only; no key anyone authors moves. ## Acceptance notes - **objectui finding (carrier: none yet; the seat routes it):** the `position-ref` picker lists the `everyone` audience anchor among positions, and the server refuses it as a grant target. Not filtered here. - Tier: no governed surface. #22900 waits for this PR and for the pin PR that follows #22874. --- 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_013XKtmoR5QCUSTMjiQ3os49 --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 4806b18 commit 48cca4a

9 files changed

Lines changed: 133 additions & 52 deletions

File tree

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
'@objectstack/platform-objects': patch
3+
'@objectstack/plugin-security': patch
4+
---
5+
6+
Setup's user page grants a position or a permission set by picking its name from the security catalog (ADR-0131 D4)
7+
8+
Clause-②: no
9+
10+
- `@objectstack/plugin-security`: `sys_user_position.position` declares `widget: 'position-ref'` and `sys_user_permission_set.permission_set` declares `widget: 'permission-ref'`, so the grant form offers a picker over the positions and permission sets the catalog serves, and stores the chosen name. A console without those widgets keeps the text input. `sys_user_permission_set.permission_set_id`, the legacy id that grants nothing, is now `hidden` and no longer appears on the grant form; the column stays until a later release drops its data.
11+
- `@objectstack/platform-objects`: the user page's Positions and Permission Sets lists no longer carry the "Assign position" / "Grant permission set" buttons. Those buttons listed `sys_position` / `sys_permission_set` rows, which a deployment need not hold. Grant with the list's **New**, which opens the grant form; removing a grant is unchanged.

‎packages/cli/src/utils/format.server-ready-dev-admin-audience.test.ts‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -174,10 +174,12 @@ describe('#17081 — the claim and the route are the platform\'s own declaration
174174
});
175175

176176
it('"grant it a permission set" is a surface the sys_user page really offers', () => {
177+
// [#15204 B2] The grant is the Permission Sets list's "New" (the host's
178+
// create on `sys_user_permission_set`), so the pin is that list, by title.
177179
const page = JSON.stringify(SysUserDetailPage);
178-
expect(page).toContain('sys_user_permission_set');
179-
expect(page).toContain('Grant permission set');
180+
expect(page).toContain('"objectName":"sys_user_permission_set"');
181+
expect(page).toContain('"en":"Permission Sets"');
180182
// Fabricated control: the same haystack does not answer to an invented name.
181-
expect(page).not.toContain('Grant fabricated permission set');
183+
expect(page).not.toContain('"objectName":"sys_user_fabricated_grant"');
182184
});
183185
});

‎packages/cli/src/utils/format.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1136,7 +1136,7 @@ export function printServerReady(opts: ServerReadyOptions) {
11361136
// `Setup → Users` is `SETUP_APP` (`requiredPermissions:
11371137
// ['setup.access']`, which this account holds) → the `nav_users`
11381138
// contribution (`sys_user`, ungated), whose detail page carries the
1139-
// "Grant permission set" related list. Pinned against those
1139+
// "Permission Sets" related list (its New grants a set). Pinned against those
11401140
// declarations in `format.server-ready-dev-admin-audience.test.ts`, so
11411141
// a rename there cannot leave this sentence pointing at nothing.
11421142
// • It changes no seed. What the first run CREATES is a product-shape

‎packages/cli/test/platform-page-i18n-parity.test.ts‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -838,8 +838,9 @@ describe('shipped platform record pages -- i18n ownership (#14817)', () => {
838838
const maps = RECORD_PAGES.flatMap((page) => inlineLocaleMaps(page, page.name));
839839

840840
// Same refusal as the population floor: zero maps means the walk broke, not
841-
// that the pages went monolingual.
842-
expect(maps.length).toBeGreaterThanOrEqual(45);
841+
// that the pages went monolingual. [#15204 B2] 44: the user page's two
842+
// grant lists dropped their `add` pickers, and with them two label maps.
843+
expect(maps.length).toBeGreaterThanOrEqual(44);
843844

844845
const incomplete = maps
845846
.filter((m) => SHIPPED_LOCALES.some((locale) => !m.locales.includes(locale)))

‎packages/platform-objects/src/pages/sys-user-permission-set-window-columns.test.ts‎

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -47,10 +47,32 @@ describe('sys_user page — the permission-set assignments list', () => {
4747
expect(columns).toEqual(expect.arrayContaining(['permission_set', 'valid_from', 'valid_until']));
4848
});
4949

50-
it('[ADR-0131 D4] the Add picker writes the set BY NAME, and the list never shows the legacy id', () => {
50+
it('[ADR-0131 D4] the list never shows the legacy id', () => {
5151
const list = lists[0] as any;
52-
expect(list.add.linkField).toBe('permission_set');
53-
expect(list.add.picker.valueField).toBe('name');
5452
expect(list.columns).not.toContain('permission_set_id');
5553
});
5654
});
55+
56+
/**
57+
* [#15204 B2] Neither grant list carries an `add` picker. Such a picker lists
58+
* object ROWS, and positions and permission sets live in the security catalog,
59+
* so it would offer nothing on a database that holds no such row. "New" opens
60+
* the grant form, whose name field picks from the catalog (`widget:
61+
* 'position-ref'` / `'permission-ref'` on the objects); Remove is the host's
62+
* row delete, which does not depend on `add` (measured on #15204, 6116275825).
63+
*/
64+
describe('sys_user page — the grant lists have no row-object add picker', () => {
65+
for (const objectName of ['sys_user_position', 'sys_user_permission_set']) {
66+
it(`${objectName}: one related list, and no \`add\``, () => {
67+
const found = relatedListsOf(SysUserDetailPage, objectName);
68+
expect(found).toHaveLength(1);
69+
expect(found[0]).not.toHaveProperty('add');
70+
});
71+
}
72+
73+
it('control: the business-unit list keeps its add picker (an object-row binding)', () => {
74+
const found = relatedListsOf(SysUserDetailPage, 'sys_business_unit_member');
75+
expect(found).toHaveLength(1);
76+
expect(found[0]).toHaveProperty('add');
77+
});
78+
});

‎packages/platform-objects/src/pages/sys-user.page.ts‎

Lines changed: 17 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -189,11 +189,14 @@ export const SysUserDetailPage: Page = {
189189
icon: 'shield-check',
190190
children: [
191191
{
192-
// [ADR-0090 D3] Position assignments (岗位分派) — pure SDUI:
193-
// the Add picker creates sys_user_position rows storing the
194-
// position's MACHINE NAME (valueField: 'name'), and every
195-
// server-side rule (the D12 delegated-admin gate, audience-
196-
// anchor rejection) surfaces its error in the dialog.
192+
// [ADR-0090 D3] Position assignments (岗位分派) — pure SDUI.
193+
// [ADR-0131 D4, #15204 B2] No `add` picker: it could list
194+
// `sys_position` ROWS only, and positions live in the security
195+
// catalog. "New" opens the assignment form, whose `position`
196+
// field picks a catalog position by name (`widget:
197+
// 'position-ref'` on the object); Remove is the host's row
198+
// delete. Every server-side rule (the D12 delegated-admin gate,
199+
// audience-anchor rejection) surfaces its error in the form.
197200
type: 'record:related_list',
198201
properties: {
199202
objectName: 'sys_user_position',
@@ -203,42 +206,27 @@ export const SysUserDetailPage: Page = {
203206
limit: 25,
204207
showViewAll: true,
205208
title: { en: 'Positions', 'zh-CN': '岗位', 'ja-JP': 'ポジション', 'es-ES': 'Puestos' },
206-
add: {
207-
picker: {
208-
object: 'sys_position',
209-
valueField: 'name',
210-
labelField: 'label',
211-
},
212-
linkField: 'position',
213-
label: {
214-
en: 'Assign position',
215-
'zh-CN': '分配岗位',
216-
'ja-JP': 'ポジションを割り当て',
217-
'es-ES': 'Asignar puesto',
218-
},
219-
},
220209
},
221210
},
222211
],
223212
},
224213
{
225214
// [ADR-0091 D1] Direct permission-set grants (直接授权) — pure SDUI.
226215
// The list keys on `relationshipField: 'user_id'` (a lookup keyed by
227-
// record id, no relationshipValueField). [ADR-0131 D4] A grant names
228-
// its set BY NAME, so the Add picker stores the set's MACHINE NAME
229-
// (valueField: 'name', linkField: 'permission_set'), exactly as the
230-
// position picker above does; server-side gates (the catalog name
231-
// refusal, delegated-admin D12) surface their reason in the dialog.
232-
// The picker still lists `sys_permission_set` rows: the related
233-
// list's Add picker reads object records only, and listing the
234-
// security catalog itself is an objectui change.
216+
// record id, no relationshipValueField). [ADR-0131 D4, #15204 B2] A
217+
// grant names its set BY NAME, and the set lives in the security
218+
// catalog, not in a `sys_permission_set` row, so there is no `add`
219+
// picker (it could list object rows only). "New" opens the grant
220+
// form, whose `permission_set` field picks a catalog set by name
221+
// (`widget: 'permission-ref'` on the object); Remove is the host's
222+
// row delete. Server-side gates (the catalog name refusal,
223+
// delegated-admin D12) surface their reason in the form.
235224
//
236225
// [ADR-0091 D1/D2] `valid_from` / `valid_until` are listed because
237226
// they decide whether the grant confers anything: the resolver drops
238227
// an assignment outside its half-open window at every evaluation, so
239228
// a row showing only the set would read as a live grant when it is
240-
// not. The Add affordance writes the two link keys alone; the window
241-
// is set by editing the assignment row, whose object refuses a
229+
// not. The same form sets the window, and the object refuses a
242230
// window whose end is not after its start.
243231
label: { en: 'Permission Sets', 'zh-CN': '权限集', 'ja-JP': '権限セット', 'es-ES': 'Conjuntos de permisos' },
244232
icon: 'lock',
@@ -253,20 +241,6 @@ export const SysUserDetailPage: Page = {
253241
limit: 25,
254242
showViewAll: true,
255243
title: { en: 'Permission Sets', 'zh-CN': '权限集', 'ja-JP': '権限セット', 'es-ES': 'Conjuntos de permisos' },
256-
add: {
257-
picker: {
258-
object: 'sys_permission_set',
259-
valueField: 'name',
260-
labelField: 'label',
261-
},
262-
linkField: 'permission_set',
263-
label: {
264-
en: 'Grant permission set',
265-
'zh-CN': '授予权限集',
266-
'ja-JP': '権限セットを付与',
267-
'es-ES': 'Otorgar conjunto de permisos',
268-
},
269-
},
270244
},
271245
},
272246
],

‎packages/plugins/plugin-security/src/objects/sys-user-permission-set.object.ts‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,10 @@ export const SysUserPermissionSet = ObjectSchema.create({
8181
permission_set_id: Field.lookup('sys_permission_set', {
8282
label: 'Permission Set (legacy id)',
8383
required: false,
84+
// [#15204 B2] Off the forms: it grants nothing, so an editable lookup
85+
// beside `permission_set` only invites a meaningless value. The column
86+
// stays until its data is dropped (ADR-0131 D10).
87+
hidden: true,
8488
description:
8589
'[ADR-0131 D10] Legacy id reference to a sys_permission_set row, dropped with its data by a later stage. ' +
8690
'Not written and not read to grant anything: the grant names its set by permission_set.',
@@ -100,6 +104,10 @@ export const SysUserPermissionSet = ObjectSchema.create({
100104
label: 'Permission Set',
101105
required: true,
102106
maxLength: 100,
107+
// [ADR-0131 D4, #15204 B2] Picked, not typed: the form lists the sets
108+
// the security catalog serves and stores the chosen NAME. Falls back to
109+
// a text input where the widget is not registered.
110+
widget: 'permission-ref',
103111
description:
104112
'[ADR-0131 D4] Machine name of the permission set this grant holds, as the security catalog names it. ' +
105113
'A name the catalog does not hold is refused. NULL only on a grant written before the column existed.',

‎packages/plugins/plugin-security/src/objects/sys-user-position.object.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,10 @@ export const SysUserPosition = ObjectSchema.create({
8585
label: 'Position',
8686
required: true,
8787
maxLength: 100,
88+
// [ADR-0131 D4, #15204 B2] Picked, not typed: the form lists the
89+
// positions the security catalog serves and stores the chosen NAME.
90+
// Falls back to a text input where the widget is not registered.
91+
widget: 'position-ref',
8892
description: 'Position machine name (references sys_position.name).',
8993
}),
9094

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#15204 B2, ADR-0131 D4] The two grant forms pick their name from the
5+
* security catalog, as SERVED.
6+
*
7+
* A grant names its position / permission set, and the catalog (not a
8+
* `sys_position` / `sys_permission_set` row) is where those names live. The
9+
* user page's related-list `add` pickers could list object rows only, so they
10+
* are gone; "New" opens the grant form, and the form's name field carries a
11+
* widget hint the console resolves to a catalog picker (`field:position-ref`
12+
* lists `GET /meta/position`, `field:permission-ref` lists
13+
* `GET /meta/permission`). The legacy `permission_set_id` lookup grants
14+
* nothing and is hidden from the forms (maintainer ruling, 2026-10-12).
15+
*
16+
* Read from `GET /meta/object/<name>` on a booted showcase: what the console
17+
* actually receives, not the source declaration, so the pin fails if the spec
18+
* strips the hint or a later layer drops it.
19+
*/
20+
21+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
22+
import { type VerifyStack } from '@objectstack/verify';
23+
import { bootShowcase } from './showcase-boot.js';
24+
25+
type ServedField = { widget?: unknown; hidden?: unknown; type?: unknown };
26+
27+
describe('[#15204 B2] grant forms pick names from the catalog', () => {
28+
let stack: VerifyStack;
29+
const served: Record<string, Record<string, ServedField>> = {};
30+
31+
beforeAll(async () => {
32+
stack = await bootShowcase({});
33+
const token = await stack.signIn();
34+
for (const object of ['sys_user_position', 'sys_user_permission_set']) {
35+
const res = await stack.apiAs(token, 'GET', `/meta/object/${object}`);
36+
expect(res.status, `GET /meta/object/${object}`).toBe(200);
37+
const body = (await res.json()) as { item?: { fields?: Record<string, ServedField> } };
38+
served[object] = body.item?.fields ?? {};
39+
}
40+
}, 240_000);
41+
42+
afterAll(async () => {
43+
await stack?.stop?.();
44+
});
45+
46+
it('sys_user_position.position is served with the position picker hint', () => {
47+
expect(served.sys_user_position?.position?.widget).toBe('position-ref');
48+
});
49+
50+
it('sys_user_permission_set.permission_set is served with the permission picker hint', () => {
51+
expect(served.sys_user_permission_set?.permission_set?.widget).toBe('permission-ref');
52+
});
53+
54+
it('permission_set_id is served hidden, and the name field it sits beside is not', () => {
55+
expect(served.sys_user_permission_set?.permission_set_id?.hidden).toBe(true);
56+
// Control: the read is not answering `hidden` for every field.
57+
expect(served.sys_user_permission_set?.permission_set?.hidden).not.toBe(true);
58+
});
59+
});

0 commit comments

Comments
 (0)