Skip to content

Commit d61897e

Browse files
committed
feat(plugin-security,platform-objects): grant forms pick a position or permission set by name from the catalog (#15204 B2)
The user page's related-list add pickers listed sys_position / sys_permission_set rows, which a deployment need not hold (ADR-0131 D3/D4). Removed; New opens the grant form, whose name field declares widget position-ref / permission-ref. The legacy permission_set_id lookup is hidden from the forms. Remove is the host's row delete, measured independent of add. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013XKtmoR5QCUSTMjiQ3os49
1 parent 018b077 commit d61897e

8 files changed

Lines changed: 130 additions & 50 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/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)