Skip to content

Commit 0253192

Browse files
feat(plugin-security,plugin-auth,verify,platform-objects)!: a permission-set grant names its set by name — no writer fills permission_set_id (#15204 stage 6b-2-pre) (#22863)
Part of #15204 Clause-②: yes (narrowing) Stage **6b-2-pre, grant by name** (ADR-0131 D10's first step: every grant names its set, then the id column goes). This PR leaves #15204 open: stage 6b-2 (the seeders), stage 8 / C7b (the `permission_set_id` column and its data) and the rest of the epic remain. ## What changes `sys_user_permission_set.permission_set` is the grant's one reference to its permission set, by machine name. `permission_set_id` is optional; no writer fills it, and no write derives anything from it. | Surface | Before | Now | |:--|:--|:--| | `objects/sys-user-permission-set.object.ts` | `permission_set_id` required lookup; `permission_set` read-only, derived | `permission_set` required and writable; `permission_set_id` optional ("legacy id"); title reads the name | | `grant-permission-set-name.ts` | hooks derived the name from the id row, refused a disagreeing name | hooks refuse a non-system write naming a set the security catalog does not hold: `400 VALIDATION_FAILED`, `reference_not_found` at `permission_set` (sibling of `position-catalog-refusal.ts`); a missing name is the engine's `400 VALIDATION_FAILED` / `required` | | `bootstrap-platform-admin.ts` (promotion) | needed the seeded `admin_full_access` row id | writes `{ permission_set: 'admin_full_access' }`; `findExistingPlatformAdmin` reads by name only | | `auto-org-admin-grant.ts` | resolved the organization's own row id (per-organization copy under a wall) | writes the name; checks the bound security catalog holds the set; no row read to grant | | `plugin-auth/auth-manager.ts` (self-registration) | read `sys_permission_set` rows for the id | writes the staged name; admission's catalog + ledger check is the only read | | `plugin-auth/last-admin-guard.ts` | a write re-pointing the id was read as losing standing | standing rides on the name alone; `permission_set_id` left `GRANT_STANDING_KEYS` | | `delegated-admin-gate.ts` | an insert / re-point was judged by the id's row name | judged by the written name, against the catalog | | `cleanup-package-permissions.ts` (uninstall) | revoked grants by the set row's id | revokes grants naming the set, plus legacy grants by id | | `verify/src/rls.ts` | refused when the boot seeded no row; wrote the row id | writes the name; no row read | | `platform-objects/pages/sys-user.page.ts` | picker stored the row id (`linkField: permission_set_id`) | picker stores the name (`valueField: 'name'`, `linkField: 'permission_set'`); list shows the name | **The property 6b-2 needs, pinned:** no grant path needs a `sys_permission_set` row. Fresh-database pins with **zero** rows: the organization-admin reconcile under a wall and under `single` (`grant-permission-set-name.writers.test.ts`, `auto-org-admin-grant.test.ts`), the data door (`grant-permission-set-name.test.ts`), self-registration (`plugin-auth/audience-posture.test.ts`), the verify persona (`verify/rls-persona-grant.test.ts`). The first-platform-admin promotion still runs inside `bootstrapPlatformAdmin`, which seeds rows until 6b-2; its grant is pinned name-only and the holder read is pinned with every row deleted afterwards. **Restrict-only legacy reach (kept, deliberately).** A grant written before the name column carries only its id until the existing one-time backfill names it (at `kernel:bootstrapped`). These reads reach such a grant through its id **only to restrict**: the platform-admin bootstrap's withheld promotion (leg C, now only when a legacy row exists), the org-admin revoke / superseded-variant sweep / duplicate check, and the uninstall sweep. Nothing confers through the id. They go with the column (C7b). The backfill itself and the D10 reference report are unchanged. ## Not in this stage (measured) - **`sys_position_permission_set.permission_set_id`** (the junction lookup) stays: since stage 1 it grants nothing; its writers left on this tree are Setup's position page, the app-crm / app-showcase `bind-position-sets.ts`, U2's suggestion accept, and the anchor-binding gate in `security-plugin.ts`. It goes with the junction object in stage 8. - **`packages/spec/src/ui/component.zod.ts` (~`:1597`)**: the TSDoc's "Assigned Users" example named `relationshipField=permission_set_id`, which this PR's head refuses. The patch round (the seat's review 6110944288, commit `d453ea50d7`) rewrote it by name (`relationshipField=permission_set`, `relationshipValueField=name`) with a `@objectstack/spec` patch changeset. It is text only, and no generated artifact moved. The same round moved `content/docs/api/error-catalog.mdx`'s dangling-reference example to `sys_user_permission_set.user_id` → `sys_user`. (Updated by the seat.) - **`suggested-audience-bindings.ts`** untouched (U2's; #22854 landed and is merged into this branch). ## Setup's grant picker and objectui The picker now **writes the name** with objectstack metadata alone. It still **lists `sys_permission_set` rows**: objectui's `RelatedList` Add picker (pinned `0df67f237c5a`, `packages/plugin-detail/src/RelatedList.tsx`) reads object records only (`add.picker.object` through the data source); C9's registry reads live in the metadata-admin pages, not in this picker. Listing the security catalog needs an objectui change; reported to the seat for objectui#7611. ## Size, tiers and review - 94 files, 845 additions / 1,867 deletions = **2,712 changed lines** (under 3,000; generated translations and test deletions included; updated by the seat after the patch round). - **Tier H:** `skills/objectstack-data/rules/security.md` is edited — it prescribed writing the record id into `permission_set_id`, which this PR refuses. Readings: that file 216 → 213 lines; the `skills/objectstack-data` package (all `.md`) 3,721 → 3,718. - A same-head contract-tier review is owed (`Clause-②: yes (narrowing)`); the PM spawns it. - Changesets: `@objectstack/plugin-security` minor (BREAKING, ADR-0087 `not-required (no-migration-prescription)`), `@objectstack/plugin-auth`, `@objectstack/verify`, `@objectstack/platform-objects` patch, and `@objectstack/spec` patch (TSDoc text only). ## Verification (local) plugin-security and the dogfood files below were re-run on the merged tree (`origin/main` `55382dc02`, U2 included); the other suites ran on this branch before that merge, which brought no change to plugin-auth, verify, platform-objects, plugin-hono-server, plugin-sharing or organizations source (it did touch rest and cli; CI re-runs them). - Build: closure of `@objectstack/dogfood` (includes all four packages) after the `origin/main` merge. - `@objectstack/plugin-security` full suite on the merged tree: 196 files, 3,973 passed. `@objectstack/plugin-auth`: 136 files, 2,774 passed. `@objectstack/verify`: 31 files. `@objectstack/platform-objects`: 69 files, 1,083 passed. `@objectstack/rest`: 277 files, 5,454 passed. `plugin-hono-server` 28, `plugin-sharing` 41, `organizations` 12 files green. `@objectstack/cli`: unit tier 281 files, 4,185 passed; integration tier `package-install-local-uninstall-cleanups` 16 passed (it grants the package set by name and proves the uninstall revokes it). - Dogfood, full: on the first run 235 files passed, 1 was skipped and 11 were red. The 11 red files were fixture grants by id; they were fixed and re-run green (11/11), plus U2's `suggested-binding-acceptance` after the merge. (Count corrected by the seat; the dev's report flagged it.) - Typecheck: plugin-security, plugin-auth, verify, platform-objects, dogfood, rest, plugin-hono-server, plugin-sharing, organizations, cli: all green. - `dispatch-gates --ran`: 106 derived families accounted for, 106 run; every one green or re-run green after a fix (`check-system-context-census` row 23c rewritten; `check:query-options-erasure` back at 236 after removing three test casts). Three first runs were prerequisite-blocked (shallow clone, unbuilt dists) and re-measured green after fetching the fixture commit and building. ## Acceptance notes - **Uniqueness:** the declared unique index still names `(user_id, permission_set_id, organization_id)`. A by-name grant stores NULL there, which SQL keys apart, so two by-name grants of one set to one user in one organization are not refused by the database; every platform writer checks existence by name first. A unique key on the name belongs with the column drop (C7b), which can dedupe the data. - **Pre-ADR-0113 databases:** a column created `NOT NULL` before protocol 17 for `permission_set_id` would refuse a name-only insert at the driver. ADR-0113's ruling records such databases as not measured to exist; the drift report already offers `relax_not_null` for it. - **Writing `permission_set_id`** is not refused; it is stored and read by nothing that grants. Refusing it is left to the column's retirement rather than built here. - Translated labels and help (`zh-CN`, `ja-JP`, `es-ES`) for the two columns were rewritten so they no longer describe the retired derivation. ## 维护者速读(草稿) **改了什么**:用户↔权限集的直接授权(`sys_user_permission_set`)改为只按权限集的名称记录。所有写授权的地方(首个平台管理员、组织管理员自动授权、自助注册、verify 探针、Setup 用户页的「授予权限集」)都只写名称,不再读 `sys_permission_set` 行去取 id;`permission_set_id` 变为可选,不再被写入或用于推导任何东西。非系统写入若名称不在安全目录中,返回 400 `VALIDATION_FAILED`(`reference_not_found`)。 **为什么改**:ADR-0131 D10 规定先让每条授权按名称引用权限集,再删除 id 列。下一阶段 6b-2 要删除写这些行的种子程序;若授权仍依赖这些行,新库将没有首个平台管理员、墙内没有组织管理员授权、Setup 授权选择器为空。本 PR 让授权路径不再需要这些行。 **风险与代价(含回滚)**:外部调用方若仍只写 `permission_set_id` 会被 400 拒绝(需改写为 `permission_set: '集名'`),changeset 已写明。旧数据的授权仍由已有的一次性回填命名,旧 id 仅用于撤销与去重。数据库层面对按名称的重复授权没有唯一约束,由写入方先查后写。回滚:直接 revert 本 PR,无数据迁移、无列变更。另:改了对外发布的技能文件 `skills/objectstack-data/rules/security.md`(原文指导写 id,现已不成立),因此本 PR 为 Tier H。 **席位意见**: **你要做的**:契约复核(PM 安排)通过后,批准合并(Tier H)。另需决定 Setup 授权选择器何时改为直接列出安全目录(需 objectui 改动)。 --- _Generated by [Claude Code](https://claude.ai/code/session_01MZuVPXoYeF1Pa3G4ERmTPz)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent fe58bbc commit 0253192

94 files changed

Lines changed: 845 additions & 1867 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
'@objectstack/plugin-security': minor
3+
---
4+
5+
A permission-set grant names its set by name: `sys_user_permission_set.permission_set` is the grant's one reference, and `permission_set_id` is no longer written or required
6+
7+
Clause-②: yes (narrowing)
8+
9+
<!-- adr-0087: not-required (no-migration-prescription) A data-door contract change on a platform object's rows, not on any metadata schema: no key of any spec schema is removed, renamed or re-shaped, so there is nothing for `objectstack migrate meta` to rewrite and no tombstone. Stored grants written before the name column are named by the existing one-time backfill at boot; the id column and its data are dropped by a later ADR-0131 D10 stage, not here. The package publishes (not unpublished); no ADR-0087 id covers this rule and this diff adds none (not registered / already-registered). -->
10+
11+
**BREAKING** accept-set narrowing on `sys_user_permission_set`, shipped as `minor` under the repo's launch-window convention for breaking changes. ADR-0131 D10's first step: every grant names its set, then the id column goes.
12+
13+
- **Before.** A grant was written with `permission_set_id`, a required lookup to a `sys_permission_set` row, and the platform derived `permission_set` (read-only) from that row. A write carrying only the id was accepted and named; a supplied name had to agree with the id's row.
14+
- **Now.** `permission_set` is required and writable: it holds the permission set's machine name, and the set is the security catalog's definition of that name (ADR-0131 D3). `permission_set_id` is optional, and nothing writes it or derives anything from it. A grant that names no set is refused `400 VALIDATION_FAILED` (`required` at `permission_set`). A non-system write naming a set the security catalog does not hold is refused `400 VALIDATION_FAILED` (`reference_not_found` at `permission_set`), the same envelope `sys_user_position.position` answers with; a deactivated set is still a set and is accepted. System writes are not judged against the catalog.
15+
- **The platform's own grant writers** (the platform-admin promotion and the organization-admin reconcile) write the name alone and read no `sys_permission_set` row to do it, so a database with no such rows still gets its first platform administrator and its organization administrators. A row id is read only to revoke, or not duplicate, a grant written before the name column existed, which names nothing until the one-time backfill names it. Uninstalling a package revokes the grants that name its sets.
16+
- **Unchanged.** Grants written before this release keep their id and are named by the existing backfill; the resolver and every grant reader already read the name.
17+
18+
**The remedy.** A caller that wrote `{ user_id, permission_set_id: <row id> }` writes `{ user_id, permission_set: '<set name>' }`: the `name` of that set, as the metadata door (`GET /api/v1/meta/permission`) lists it.
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@objectstack/spec': patch
3+
---
4+
5+
The TSDoc on `RecordRelatedListProps.add` names its canonical example by name: "Assigned Users" on a permission set keys `sys_user_permission_set` on `permission_set` with `relationshipValueField: 'name'`, since a grant names its permission set (ADR-0131 D4). Text only; no schema changes.
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
'@objectstack/plugin-auth': patch
3+
'@objectstack/verify': patch
4+
'@objectstack/platform-objects': patch
5+
---
6+
7+
Grants name their permission set, with no `sys_permission_set` row read (ADR-0131 D4)
8+
9+
- `@objectstack/plugin-auth`: the self-registration grant names the declared set alone. Admission asks the security catalog and the activation ledger whether the set grants, and the settlement writes `{ user_id, permission_set }` with no row read, so a deployment with no `sys_permission_set` row for the set still grants it. The last-administrator guard reads which set a grant holds from its name only; writing `permission_set_id` moves no standing.
10+
- `@objectstack/verify`: the RLS probe persona's grant names the registered probe set alone; it no longer needs, or refuses for want of, a boot-seeded `sys_permission_set` row.
11+
- `@objectstack/platform-objects`: Setup's user page grants a permission set by name: the Add picker stores the set's machine name (`valueField: 'name'`, `linkField: 'permission_set'`), and the list shows the name column. The picker still lists `sys_permission_set` rows; listing the security catalog itself is a console change.

‎content/docs/api/error-catalog.mdx‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -285,14 +285,14 @@ object is rejected with `400 VALIDATION_FAILED`, and the specifics ride in
285285

286286
```json
287287
{
288-
"error": "Permission Set: no sys_permission_set record has id \"ps_missing\"",
288+
"error": "User: no sys_user record has id \"usr_missing\"",
289289
"code": "VALIDATION_FAILED",
290290
"fields": [{
291-
"field": "permission_set_id",
291+
"field": "user_id",
292292
"code": "reference_not_found",
293-
"label": "Permission Set",
294-
"constraint": { "target": "sys_permission_set" },
295-
"value": "ps_missing"
293+
"label": "User",
294+
"constraint": { "target": "sys_user" },
295+
"value": "usr_missing"
296296
}]
297297
}
298298
```

‎content/docs/permissions/permission-sets.mdx‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -219,10 +219,10 @@ A direct grant is a data row; a position's sets are declared on the position's
219219
definition:
220220

221221
```typescript
222-
// Grant a set to a single user (direct grant)
222+
// Grant a set to a single user (direct grant) — by the set's name
223223
await objectql.object('sys_user_permission_set').insert({
224224
user_id: 'user123',
225-
permission_set_id: psId,
225+
permission_set: 'crm_sales_user',
226226
});
227227

228228
// A position names the sets it distributes (everyone holding it gets them)

‎content/docs/permissions/system-context.mdx‎

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ the seed loader replaying package fixtures, a plugin's boot reconciler, a
1010
service self-write, a migration.
1111

1212
This page is **the authority** for what that flag actually does. It exists
13-
because the flag is not one concept: it is a single boolean read at **122
13+
because the flag is not one concept: it is a single boolean read at **123
1414
distinct sites across 20 packages**, and knowing three of those behaviours gives
1515
no hint that the other hundred-and-four exist. Every documented app-side bug
1616
traced to `isSystem` had the same shape — the metadata was complete and correct,
@@ -129,7 +129,7 @@ that silently does not happen.
129129
| 22 | Strict-drop refusal never fires | objectql | Lose: a caller that opted into loud refusal gets **silence** — strict refuses exactly what the strip would have taken, and the strip took nothing. One exception: a `formula` value, which the `computed` strip takes from every caller, so strict still refuses it | `packages/objectql/src/engine.ts#insert`, `packages/objectql/src/readonly-strict-errors.ts#READONLY_CLASS_REASONS` |
130130
| 23 | **Referential-integrity check skipped** | objectql | Get: writes proceed against unreachable/unresolvable targets. Lose: an `isSystem` caller can write a **dangling reference** | `packages/objectql/src/engine.ts#assertReferencesResolve` |
131131
| 23b | **Position-catalog check skipped** — a `sys_user_position` write whose `position` names no position in the security catalog is not refused | plugin-security | Get: a system writer can store an assignment naming no position — the seed loader writes `stack.data` from `AppPlugin.start()` under a system context, as invitation acceptance and the platform's own bootstraps do. Lose: such a row grants nothing and nothing says so. A non-system insert, or a non-system update that changes `position`, is refused `400 VALIDATION_FAILED` / `reference_not_found` instead when the security catalog holds no position of that name: the environment registry (ADR-0131 D3), one per deployment, which takes no organization, so a name only a `sys_position` row carries — any organization's — is refused like one nobody carries. It is the same stand-down as the engine's referential-integrity check for a lookup column | `packages/plugins/plugin-security/src/position-catalog-refusal.ts#assertPositionNamesCatalogRow` |
132-
| 23c | **Grant-name check relaxed for an unresolvable id** — a `sys_user_permission_set` write that supplies `permission_set` beside a `permission_set_id` naming no set the writer's context reaches keeps the supplied name | plugin-security | Get: a system writer can store a grant naming a set whose row does not exist yet — seed replay and boot provisioning may write the grant before the set row, the ordering the engine's referential-integrity check also stands down for. Lose: such a row's name is unchecked until a backfill names it. Everything else is judged for every caller, system included: the name is derived from the id on every write that carries one, and a supplied name that names any other set is refused `400 VALIDATION_FAILED` / `invalid_value`; a non-system caller's name beside an unresolvable id is refused the same way, read as `{ ...context, isSystem: true }` through the hook's `ctx.api.sudo()`, never a bare `{ isSystem: true }` (ADR-0131 D4) | `packages/plugins/plugin-security/src/grant-permission-set-name.ts#settle` |
132+
| 23c | **Grant-name catalog check skipped** — a `sys_user_permission_set` write whose `permission_set` names no permission set in the security catalog is not refused | plugin-security | Get: a system writer can store a grant naming a set not yet registered — seed replay and boot provisioning may write the grant before the definition it names is registered. Lose: such a row grants nothing and nothing says so. A non-system insert, or a non-system update that changes `permission_set`, is refused `400 VALIDATION_FAILED` / `reference_not_found` instead when the security catalog holds no set by that name; a grant that names no set at all is refused `required` for every caller. The grant names its set by name alone, and nothing derives the name from `permission_set_id` (ADR-0131 D4, D10) | `packages/plugins/plugin-security/src/grant-permission-set-name.ts#onInsert`, `#onUpdate` |
133133
| 23d | **Grant-holder membership check skipped** — a `sys_user_position` or `sys_user_permission_set` write naming a user with no `sys_member` row in the row's organization is not refused | plugin-security | Get: a system writer can store such a grant — invitation acceptance writes the membership and its placement together, and seed replay, the organization-admin reconcile and the platform bootstraps write grants as the system. Lose: such a row takes effect once its user is admitted to the organization. A non-system insert, or a non-system update that moves `user_id` or `organization_id`, is refused `400 VALIDATION_FAILED` / `reference_not_found` at `user_id` instead, for every caller, a platform administrator included; the membership is read as `{ isSystem: true, tenantId }` with `tenantId` the row's own organization, never another one. A row stored with no organization is a global grant and is not judged | `packages/plugins/plugin-security/src/grant-holder-membership-refusal.ts#onInsert`, `#onUpdate` |
134134
| 24 | Tenant-audit warning silenced; `bypassTenantAudit` threaded to the driver | objectql | Get: unscoped system writes stop warning. Lose: the signal that would flag a genuine user-path scoping bug | `packages/objectql/src/engine.ts#buildDriverOptions` |
135135
| 25 | Engine-owned / append-only write guard bypassed | plugin-security | Get: generic writes to `managedBy` engine-owned objects | `packages/plugins/plugin-security/src/system-write-guard.ts#isUserContextWrite`, `#assertEngineOwnedWriteAllowed` |
@@ -142,7 +142,7 @@ that silently does not happen.
142142

143143
### 3. Sharing (`plugin-sharing`)
144144

145-
The largest single consumer — **17 of the 122 sites**.
145+
The largest single consumer — **17 of the 123 sites**.
146146

147147
| # | Behaviour when `isSystem` | What you get / what you lose | Anchor |
148148
|:--|:---|:---|:---|
@@ -285,7 +285,7 @@ Ownership injection, `readonly` bypass and sharing materialisation are
285285
independent decisions, and a seed loader plausibly wants the first two but not
286286
the third. The concept is nevertheless **staying as one boolean**:
287287

288-
- **Shipped semantics.** `isSystem` is a published contract with 122 read sites
288+
- **Shipped semantics.** `isSystem` is a published contract with 123 read sites
289289
in 20 packages. Splitting it is a breaking contract change across all of them.
290290
(The ruling was taken when the census read 80 sites in 18 packages; the count
291291
has grown, which strengthens rather than weakens the argument.)
@@ -359,16 +359,16 @@ still holds equal to the census on every pull request:
359359
| Appearances of the bare identifier `isSystem` in non-test sources | 813 | — |
360360
| — parsed as a declaration | 28 | ✅ |
361361
| — parsed as an object-literal / type key (producers and option objects) | 310 | — |
362-
| — parsed as a property **read** | 128 | ✅ |
362+
| — parsed as a property **read** | 129 | ✅ |
363363
| — parsed in some other syntactic position (a local, a cast, a conditional) | 9 | ✅ |
364364
| — the remainder: text inside comments and string literals | 358 | — |
365365
| Of those reads: reads of one of the unrelated metadata fields | 6 | ✅ |
366-
| Of those reads: reads of `ExecutionContext.isSystem` | **122** | ✅ |
367-
| — behaviour-bearing (rows 1–61 above) | 119 | ✅ |
366+
| Of those reads: reads of `ExecutionContext.isSystem` | **123** | ✅ |
367+
| — behaviour-bearing (rows 1–61 above) | 120 | ✅ |
368368
| — carry the flag onward only (rows 62–64 above) | 3 | ✅ |
369369
| Packages containing at least one elevation read | **20** | ✅ |
370370
| Files containing at least one elevation read | 57 | ✅ |
371-
| — the distinct symbols those reads live in — what this page anchors | 104 | ✅ |
371+
| — the distinct symbols those reads live in — what this page anchors | 105 | ✅ |
372372
| — of those files, the ones holding more than one read in one symbol | 8 | ✅ |
373373

374374
The six rows marked — are a **dated decomposition, not a live claim**: they were

‎packages/cli/test/package-install-local-uninstall-cleanups.integration.test.ts‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -245,14 +245,14 @@ const recordOf = (a: Answer) => a.body?.data ?? a.body?.record ?? a.body;
245245
interface Grants {
246246
/** `GET /data/sys_permission_set?name=…` — the projection the admin surface grants from. */
247247
sets: Answer;
248-
/** `GET /data/sys_user_permission_set?permission_set_id=…` — the user grant made before the uninstall. */
248+
/** `GET /data/sys_user_permission_set?permission_set=…` — the user grant made before the uninstall, by the set's name (ADR-0131 D4). */
249249
bindings: Answer;
250250
}
251251

252-
async function readGrants(live: LiveStart, token: string, setId: string): Promise<Grants> {
252+
async function readGrants(live: LiveStart, token: string, _setId: string): Promise<Grants> {
253253
return {
254254
sets: await http(live, 'GET', `/api/v1/data/sys_permission_set?name=${PERMISSION_SET}`, token),
255-
bindings: await http(live, 'GET', `/api/v1/data/sys_user_permission_set?permission_set_id=${encodeURIComponent(setId)}`, token),
255+
bindings: await http(live, 'GET', `/api/v1/data/sys_user_permission_set?permission_set=${PERMISSION_SET}`, token),
256256
};
257257
}
258258

@@ -299,7 +299,7 @@ async function grantThenUninstall(live: LiveStart, session: Session, run: Run):
299299
run.setId = setId;
300300
run.granted = await http(live, 'POST', '/api/v1/data/sys_user_permission_set', session.token, {
301301
user_id: session.userId,
302-
permission_set_id: setId,
302+
permission_set: PERMISSION_SET,
303303
});
304304
run.before = await readGrants(live, session.token, setId);
305305
run.objectBefore = await http(live, 'GET', `/api/v1/data/${TASK}`, session.token);

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

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,13 @@ describe('sys_user page — the permission-set assignments list', () => {
4444

4545
it('lists valid_from and valid_until beside the set', () => {
4646
const columns = lists[0]?.columns as unknown[];
47-
expect(columns).toEqual(expect.arrayContaining(['permission_set_id', 'valid_from', 'valid_until']));
47+
expect(columns).toEqual(expect.arrayContaining(['permission_set', 'valid_from', 'valid_until']));
48+
});
49+
50+
it('[ADR-0131 D4] the Add picker writes the set BY NAME, and the list never shows the legacy id', () => {
51+
const list = lists[0] as any;
52+
expect(list.add.linkField).toBe('permission_set');
53+
expect(list.add.picker.valueField).toBe('name');
54+
expect(list.columns).not.toContain('permission_set_id');
4855
});
4956
});

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

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -223,13 +223,15 @@ export const SysUserDetailPage: Page = {
223223
},
224224
{
225225
// [ADR-0091 D1] Direct permission-set grants (直接授权) — pure SDUI.
226-
// sys_user_permission_set is an ordinary id-keyed junction:
227-
// user_id / permission_set_id are lookups keyed by record id, so
228-
// the list keys on `relationshipField: 'user_id'` (no
229-
// relationshipValueField). The Add picker binds a permission set by
230-
// its id (linkField: 'permission_set_id'); server-side gates
231-
// (audience-anchor D5/D9, delegated-admin D12) surface their denial
232-
// reason in the Add dialog.
226+
// 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.
233235
//
234236
// [ADR-0091 D1/D2] `valid_from` / `valid_until` are listed because
235237
// they decide whether the grant confers anything: the resolver drops
@@ -246,17 +248,18 @@ export const SysUserDetailPage: Page = {
246248
properties: {
247249
objectName: 'sys_user_permission_set',
248250
relationshipField: 'user_id',
249-
columns: ['permission_set_id', 'organization_id', 'valid_from', 'valid_until', 'granted_by', 'created_at'],
251+
columns: ['permission_set', 'organization_id', 'valid_from', 'valid_until', 'granted_by', 'created_at'],
250252
sort: [{ field: 'created_at', order: 'desc' }],
251253
limit: 25,
252254
showViewAll: true,
253255
title: { en: 'Permission Sets', 'zh-CN': '权限集', 'ja-JP': '権限セット', 'es-ES': 'Conjuntos de permisos' },
254256
add: {
255257
picker: {
256258
object: 'sys_permission_set',
259+
valueField: 'name',
257260
labelField: 'label',
258261
},
259-
linkField: 'permission_set_id',
262+
linkField: 'permission_set',
260263
label: {
261264
en: 'Grant permission set',
262265
'zh-CN': '授予权限集',

‎packages/plugins/organizations/src/open-only-wall-acceptance.test.ts‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -184,9 +184,9 @@ function makeQl() {
184184
{ id: 'u_orgless', email: 'u_orgless@example.com' },
185185
],
186186
sys_user_permission_set: [
187-
{ user_id: 'u_member', permission_set_id: 'ps_shared' },
188-
{ user_id: 'u_exmember', permission_set_id: 'ps_shared' },
189-
{ user_id: 'u_orgless', permission_set_id: 'ps_shared' },
187+
{ user_id: 'u_member', permission_set: 'shared_access' },
188+
{ user_id: 'u_exmember', permission_set: 'shared_access' },
189+
{ user_id: 'u_orgless', permission_set: 'shared_access' },
190190
],
191191
sys_permission_set: [
192192
{ id: 'ps_shared', name: 'shared_access', system_permissions: ['manage_metadata', 'studio.access'] },

0 commit comments

Comments
 (0)