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 0253192
Browse filesBrowse the repository at this point in the historyBrowse 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>
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.
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.
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.
Copy file name to clipboardExpand all lines: content/docs/permissions/system-context.mdx
+8-8Lines changed: 8 additions & 8 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -10,7 +10,7 @@ the seed loader replaying package fixtures, a plugin's boot reconciler, a
10
10
service self-write, a migration.
11
11
12
12
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
14
14
distinct sites across 20 packages**, and knowing three of those behaviours gives
15
15
no hint that the other hundred-and-four exist. Every documented app-side bug
16
16
traced to `isSystem` had the same shape — the metadata was complete and correct,
@@ -129,7 +129,7 @@ that silently does not happen.
129
129
| 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`|
130
130
| 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`|
131
131
| 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`|
133
133
| 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` |
134
134
| 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`|
0 commit comments