Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
1a8d697
wip(plugin-security,plugin-auth,verify,platform-objects): grants name…
claude Oct 11, 2026
49e9066
wip(plugin-security): pin the by-name grant refusal
claude Oct 11, 2026
13f9649
wip(plugin-security): tests follow grant-by-name (tenant wall, org-ad…
claude Oct 11, 2026
a9be97d
wip(plugin-security): fixtures grant by name against registered sets
claude Oct 11, 2026
3b266b9
wip(plugin-security): golden and gate fixtures grant by name
claude Oct 11, 2026
375e419
wip(plugin-security): legacy unnamed grants are written beneath the e…
claude Oct 11, 2026
7c24854
wip(plugin-auth): tests follow grant-by-name — self-registration name…
claude Oct 11, 2026
e612138
wip(verify,platform-objects): the RLS persona and Setup's grant picke…
claude Oct 11, 2026
58dd8c5
wip(tests): downstream fixtures grant by name
claude Oct 11, 2026
eea18d8
wip: changesets and translations for grant-by-name
claude Oct 11, 2026
5351a1a
docs(permissions,skills): a grant names its permission set; the syste…
claude Oct 11, 2026
f768b83
wip(tests): dogfood grant fixtures by name; junction rows keep their …
claude Oct 11, 2026
fc6e8f7
wip(tests): fake stored grants keep their legacy id beside the name; …
claude Oct 11, 2026
c9a19e6
wip(cli): the uninstall integration grants and reads the package set …
claude Oct 11, 2026
92bda9e
Merge remote-tracking branch 'origin/main' into claude/issue-15204-s6…
claude Oct 11, 2026
d453ea5
docs(spec,api): the related-list Add example and the dangling-referen…
claude Oct 11, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions .changeset/15204-grant-by-name-security.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
---
'@objectstack/plugin-security': minor
---

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

Clause-②: yes (narrowing)

<!-- 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). -->

**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.

- **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.
- **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.
- **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.
- **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.

**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.
5 changes: 5 additions & 0 deletions .changeset/15204-grant-by-name-spec-tsdoc.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@objectstack/spec': patch
---

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.
11 changes: 11 additions & 0 deletions .changeset/15204-grant-by-name-writers.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
'@objectstack/plugin-auth': patch
'@objectstack/verify': patch
'@objectstack/platform-objects': patch
---

Grants name their permission set, with no `sys_permission_set` row read (ADR-0131 D4)

- `@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.
- `@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.
- `@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.
10 changes: 5 additions & 5 deletions content/docs/api/error-catalog.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -285,14 +285,14 @@ object is rejected with `400 VALIDATION_FAILED`, and the specifics ride in

```json
{
"error": "Permission Set: no sys_permission_set record has id \"ps_missing\"",
"error": "User: no sys_user record has id \"usr_missing\"",
"code": "VALIDATION_FAILED",
"fields": [{
"field": "permission_set_id",
"field": "user_id",
"code": "reference_not_found",
"label": "Permission Set",
"constraint": { "target": "sys_permission_set" },
"value": "ps_missing"
"label": "User",
"constraint": { "target": "sys_user" },
"value": "usr_missing"
}]
}
```
Expand Down
4 changes: 2 additions & 2 deletions content/docs/permissions/permission-sets.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -219,10 +219,10 @@ A direct grant is a data row; a position's sets are declared on the position's
definition:

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

// A position names the sets it distributes (everyone holding it gets them)
Expand Down
16 changes: 8 additions & 8 deletions content/docs/permissions/system-context.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ the seed loader replaying package fixtures, a plugin's boot reconciler, a
service self-write, a migration.

This page is **the authority** for what that flag actually does. It exists
because the flag is not one concept: it is a single boolean read at **122
because the flag is not one concept: it is a single boolean read at **123
distinct sites across 20 packages**, and knowing three of those behaviours gives
no hint that the other hundred-and-four exist. Every documented app-side bug
traced to `isSystem` had the same shape — the metadata was complete and correct,
Expand Down Expand Up @@ -129,7 +129,7 @@ that silently does not happen.
| 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` |
| 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` |
| 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` |
| 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` |
| 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` |
| 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` |
| 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` |
| 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` |
Expand All @@ -142,7 +142,7 @@ that silently does not happen.

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

The largest single consumer — **17 of the 122 sites**.
The largest single consumer — **17 of the 123 sites**.

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

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

The six rows marked — are a **dated decomposition, not a live claim**: they were
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -245,14 +245,14 @@ const recordOf = (a: Answer) => a.body?.data ?? a.body?.record ?? a.body;
interface Grants {
/** `GET /data/sys_permission_set?name=…` — the projection the admin surface grants from. */
sets: Answer;
/** `GET /data/sys_user_permission_set?permission_set_id=…` — the user grant made before the uninstall. */
/** `GET /data/sys_user_permission_set?permission_set=…` — the user grant made before the uninstall, by the set's name (ADR-0131 D4). */
bindings: Answer;
}

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

Expand Down Expand Up @@ -299,7 +299,7 @@ async function grantThenUninstall(live: LiveStart, session: Session, run: Run):
run.setId = setId;
run.granted = await http(live, 'POST', '/api/v1/data/sys_user_permission_set', session.token, {
user_id: session.userId,
permission_set_id: setId,
permission_set: PERMISSION_SET,
});
run.before = await readGrants(live, session.token, setId);
run.objectBefore = await http(live, 'GET', `/api/v1/data/${TASK}`, session.token);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,13 @@ describe('sys_user page — the permission-set assignments list', () => {

it('lists valid_from and valid_until beside the set', () => {
const columns = lists[0]?.columns as unknown[];
expect(columns).toEqual(expect.arrayContaining(['permission_set_id', 'valid_from', 'valid_until']));
expect(columns).toEqual(expect.arrayContaining(['permission_set', 'valid_from', 'valid_until']));
});

it('[ADR-0131 D4] the Add picker writes the set BY NAME, and the list never shows the legacy id', () => {
const list = lists[0] as any;
expect(list.add.linkField).toBe('permission_set');
expect(list.add.picker.valueField).toBe('name');
expect(list.columns).not.toContain('permission_set_id');
});
});
21 changes: 12 additions & 9 deletions packages/platform-objects/src/pages/sys-user.page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -223,13 +223,15 @@ export const SysUserDetailPage: Page = {
},
{
// [ADR-0091 D1] Direct permission-set grants (直接授权) — pure SDUI.
// sys_user_permission_set is an ordinary id-keyed junction:
// user_id / permission_set_id are lookups keyed by record id, so
// the list keys on `relationshipField: 'user_id'` (no
// relationshipValueField). The Add picker binds a permission set by
// its id (linkField: 'permission_set_id'); server-side gates
// (audience-anchor D5/D9, delegated-admin D12) surface their denial
// reason in the Add dialog.
// The list keys on `relationshipField: 'user_id'` (a lookup keyed by
// record id, no relationshipValueField). [ADR-0131 D4] A grant names
// its set BY NAME, so the Add picker stores the set's MACHINE NAME
// (valueField: 'name', linkField: 'permission_set'), exactly as the
// position picker above does; server-side gates (the catalog name
// refusal, delegated-admin D12) surface their reason in the dialog.
// The picker still lists `sys_permission_set` rows: the related
// list's Add picker reads object records only, and listing the
// security catalog itself is an objectui change.
//
// [ADR-0091 D1/D2] `valid_from` / `valid_until` are listed because
// they decide whether the grant confers anything: the resolver drops
Expand All @@ -246,17 +248,18 @@ export const SysUserDetailPage: Page = {
properties: {
objectName: 'sys_user_permission_set',
relationshipField: 'user_id',
columns: ['permission_set_id', 'organization_id', 'valid_from', 'valid_until', 'granted_by', 'created_at'],
columns: ['permission_set', 'organization_id', 'valid_from', 'valid_until', 'granted_by', 'created_at'],
sort: [{ field: 'created_at', order: 'desc' }],
limit: 25,
showViewAll: true,
title: { en: 'Permission Sets', 'zh-CN': '权限集', 'ja-JP': '権限セット', 'es-ES': 'Conjuntos de permisos' },
add: {
picker: {
object: 'sys_permission_set',
valueField: 'name',
labelField: 'label',
},
linkField: 'permission_set_id',
linkField: 'permission_set',
label: {
en: 'Grant permission set',
'zh-CN': '授予权限集',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -184,9 +184,9 @@ function makeQl() {
{ id: 'u_orgless', email: 'u_orgless@example.com' },
],
sys_user_permission_set: [
{ user_id: 'u_member', permission_set_id: 'ps_shared' },
{ user_id: 'u_exmember', permission_set_id: 'ps_shared' },
{ user_id: 'u_orgless', permission_set_id: 'ps_shared' },
{ user_id: 'u_member', permission_set: 'shared_access' },
{ user_id: 'u_exmember', permission_set: 'shared_access' },
{ user_id: 'u_orgless', permission_set: 'shared_access' },
],
sys_permission_set: [
{ id: 'ps_shared', name: 'shared_access', system_permissions: ['manage_metadata', 'studio.access'] },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -308,7 +308,7 @@ describe('walled + invite-only: the multi-org default-org bootstrap never joins
const store: Record<string, Row[]> = {
sys_permission_set: seededPermissionSets(),
sys_user_permission_set: [
{ id: 'ups_1', permission_set_id: 'ps_admin', organization_id: null, user_id: 'usr_alice' },
{ id: 'ups_1', permission_set: 'admin_full_access', organization_id: null, user_id: 'usr_alice' },
],
sys_user: [
{ id: 'usr_alice', email: SELF_REGISTRANT_EMAIL, email_verified: true, created_at: '2026-01-01T00:00:00.000Z' },
Expand Down
Loading
Loading