From ee6bd9ec96887df0b47a0b0bdd0f6d0770125ce7 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 20 Aug 2026 18:07:26 +0000 Subject: [PATCH 1/3] docs(skills): name `permissions` as the defineStack key at the permission-set authoring site MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The skills bundle taught `definePermissionSet()` without ever naming the stack collection that registers its output, so an author reaching for permission sets from the skills alone guesses `permissionSets` from the factory name and is caught by the strict top level. Every other authoring factory in the bundle is paired with its registration key at the point of authoring (`defineStack({ translations })`, `{ hooks }`, `{ objectExtensions }`, `{ apps }`, `{ pages }`, `{ actions }`, `{ flows }`); `definePermissionSet` was the exception. Bundle sweep: exactly one full `defineStack` key enumeration exists (skills/objectstack-platform/SKILL.md), and it already names `permissions` — so the fix belongs at the authoring site, not in the enumeration. Fixes #10327 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_019T1sSZbQTnLhrK9HhNdNiB --- skills/objectstack-data/SKILL.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/skills/objectstack-data/SKILL.md b/skills/objectstack-data/SKILL.md index 113b67787d..0159d0a099 100644 --- a/skills/objectstack-data/SKILL.md +++ b/skills/objectstack-data/SKILL.md @@ -593,8 +593,19 @@ export const salesUser = definePermissionSet({ contact: { allowRead: true }, }, }); + +// Register it on the stack root under `permissions` — NOT `permissionSets`: +// defineStack({ permissions: [salesUser], ... }) ``` +- **Stack key: `permissions`.** The collection is named for the metadata kind, + not for the factory, so `definePermissionSet()` output goes into + `defineStack({ permissions: [...] })`. `permissionSets:` is **refused at + load** — the top level is strict, so the stack fails with an + `Unrecognized key(s) on this stack definition` error naming the key, never a + silent drop. `ObjectStackDefinitionSchema` + (`node_modules/@objectstack/spec/src/stack.zod.ts`) is the enumeration of + record; `objectstack-platform` lists every top-level key. - Bits: `allowCreate` / `allowRead` / `allowEdit` / `allowDelete`, plus `allowTransfer` (ownership change), `viewAllRecords` / `modifyAllRecords` (super-user, bypass sharing). From c43df0067eb85ae815929af857283d8236f557a4 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 20 Aug 2026 18:09:39 +0000 Subject: [PATCH 2/3] docs(skills): document how to assign a permission set to a user MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Authoring a permission set was covered well; assigning one to a user was absent from all 11 skills in the bundle. The capability works and is correct in both directions — only the path to exercising it was undocumented, which is exactly what an enterprise evaluator reaches for when checking whether a permission story is real. Adds an "Assigning a permission set to a user" subsection to the objectstack-data security section, grounded in the platform source: - `sys_user_permission_set` is the join object. - A set declared in `permissions` is upserted into `sys_permission_set` on `kernel:ready` with a per-environment generated id (ADR-0086 D5). - `permission_set_id` takes that RECORD ID, not the `name` — the resolver loads `sys_permission_set` by `id`, so a name written there matches nothing and grants nothing, silently. Hence assignment is always two calls: resolve name -> id, then insert the grant. - Field table (`organization_id` null semantics, the half-open validity window enforced at resolution time, `granted_by` stamped by the gate, `id` minted by the driver). - Who may write the row: tenant administrator or a delegated adminScope with `manageAssignments` (ADR-0090 D12) — CRUD bits on the table are not enough. - The minimal two-user prove-the-deny flow, an ordered list of the reasons a grant can be inert, and `GET /api/v1/security/explain` as the same-code-path answer to "why". - Notes that system objects are not exposed over MCP by default, so the agent surface cannot discover this either. objectstack-platform carries the same gap check: it has no security authoring section, so it gets a pointer row in "Common ops pitfalls" rather than a second copy of the text. Fixes #10318 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_019T1sSZbQTnLhrK9HhNdNiB --- skills/objectstack-data/SKILL.md | 99 ++++++++++++++++++++++++++++ skills/objectstack-platform/SKILL.md | 1 + 2 files changed, 100 insertions(+) diff --git a/skills/objectstack-data/SKILL.md b/skills/objectstack-data/SKILL.md index 0159d0a099..939c6d41dc 100644 --- a/skills/objectstack-data/SKILL.md +++ b/skills/objectstack-data/SKILL.md @@ -612,6 +612,105 @@ export const salesUser = definePermissionSet({ - Source: `node_modules/@objectstack/spec/src/security/permission.zod.ts` - Combine with `enable.apiMethods` to also restrict the HTTP surface. +### Assigning a permission set to a user + +Declaring a set grants nobody anything. An assignment is **data**, not +metadata: one row in the join object **`sys_user_permission_set`** +(`@objectstack/plugin-security`). Three facts you cannot guess from the type +surface, in the order they bite: + +1. **The declared set becomes a record at boot.** Everything in + `defineStack({ permissions })` is upserted into `sys_permission_set` by + `name`, with a generated `id`, on `kernel:ready` (ADR-0086 D5). That `id` + is minted per environment — it is never the same across dev / test / prod. +2. **`permission_set_id` takes that RECORD ID, not the `name`.** It is a + lookup to `sys_permission_set.id`. The resolver collects + `permission_set_id` from the grant rows and then loads + `sys_permission_set` **by `id`** — so a `name` written into this field + matches nothing, no error is raised at grant time, and the user quietly + ends up with no capability. This is the single most expensive mistake on + this object. +3. **So assigning is always two calls:** resolve `name` → `id`, then insert + the grant. + +```bash +# 1. name → record id (POST /query, QueryAST in the body; the path names the object) +curl -sX POST "$BASE/api/v1/data/sys_permission_set/query" \ + -H 'content-type: application/json' -H "authorization: Bearer $ADMIN_TOKEN" \ + -d '{"where":{"name":"sales_user"},"fields":["id","name","active"],"limit":1}' +# → {"object":"sys_permission_set","records":[{"id":"ps_m4k…","name":"sales_user","active":true}]} + +# 2. grant it — permission_set_id is the id from step 1 +curl -sX POST "$BASE/api/v1/data/sys_user_permission_set" \ + -H 'content-type: application/json' -H "authorization: Bearer $ADMIN_TOKEN" \ + -d '{"user_id":"usr_alice","permission_set_id":"ps_m4k…","organization_id":null}' +``` + +The same two steps through ObjectQL (`ql.find` then `ql.insert`) inside a +plugin or seed script; the platform's own auto-grant writer does exactly this. + +**Row fields worth knowing** (`sys-user-permission-set.object.ts`): + +| Field | Notes | +|:--|:--| +| `user_id` | Required. Lookup to `sys_user`. | +| `permission_set_id` | Required. Lookup to `sys_permission_set` — the **id**. | +| `organization_id` | Optional. `null` = the grant applies in **every** org context; set it to scope the grant to one org. | +| `valid_from` / `valid_until` | Optional half-open window `[from, until)`, UTC. Enforced **at resolution time** — an expired grant stops granting immediately, with no background job. | +| `reason` | Free text. Required by the platform on delegation and break-glass grants. | +| `granted_by` | **Do not author it** — the security gate stamps the calling user on insert. | +| `id` | Omit it; the driver mints one. | + +Uniqueness is `(user_id, permission_set_id, organization_id)`, so the same set +can be granted independently per org context. + +**Who may write this row.** Not whoever holds CRUD bits on the table — the +gate says so in as many words: *"plain CRUD grants on RBAC tables do not make +a permission administrator"*. Writes are accepted from a tenant administrator, +or from a delegated `adminScope` that carries `manageAssignments`, allowlists +that specific set, and whose business-unit subtree covers the target user +(ADR-0090 D12). Anonymous and principal-less writes fail closed. + +#### Proving the deny — the minimal two-user check + +The reason to document this at all is that a permission story is only credible +when you have watched it refuse someone. The smallest honest exercise: + +1. Two users, **A** and **B**. Grant the set to **A** only, with the two calls + above. +2. As **A**, hit the object the set opens — `GET /api/v1/data/account` → + expect `200` with records. +3. As **B**, hit the same object → expect `403`. That is the deny, and it is + the half people forget to run. +4. Revoke: `DELETE /api/v1/data/sys_user_permission_set/{id}` (or set + `valid_until` to now). Re-run step 2 → **A** is now denied too. + +If a grant appears to do nothing, the causes are enumerable — check them in +this order before suspecting the evaluator: + +- `permission_set_id` holds a `name` instead of an `id` (fact 2 above); +- the `sys_permission_set` row has `active: false` — a deactivated set keeps + its assignments and grants nothing; +- `valid_from` / `valid_until` puts the grant outside its window; +- `organization_id` names an org other than the caller's active one. + +`GET /api/v1/security/explain?object=&operation=&userId=` answers +"why" from the same code path that enforces, so its verdict cannot drift from +the real one. Explaining **another** user needs `manage_users` or a delegated +`adminScope` covering them. + +> ⚠️ `sys_user_permission_set` is a system object, and system objects are +> **not exposed over MCP** unless a deployment opts in with +> `allowSystemObjects` — the tool answers *"Object … is a system object and is +> not exposed via MCP"*. So "ask the agent" will not find this for you; the +> data door above is the path. + +Grants also arrive **indirectly**: a set bound to a position +(`sys_position_permission_set`) is held by everyone assigned that position +(`sys_user_position`), and every authenticated principal implicitly holds the +`everyone` position. When auditing what a user actually has, read both sources +— `explain` already does. + ### Access depth (scope-depth) — the ERP "see my unit / my unit and below" axis For owner-scoped (`private`) objects, a per-object grant on a permission set can diff --git a/skills/objectstack-platform/SKILL.md b/skills/objectstack-platform/SKILL.md index 06f724c755..07b88b555a 100644 --- a/skills/objectstack-platform/SKILL.md +++ b/skills/objectstack-platform/SKILL.md @@ -1273,6 +1273,7 @@ describe('stack boot', () => { | LiteKernel test passes, ObjectKernel boot fails | Test missed a plugin the CLI auto-registers — compare your test's `use()` list against the `os dev` boot log | | Hot reload misses new objects | Barrel `src/objects/index.ts` not re-exporting — check the file | | Login works but **Setup / Studio missing** | The logged-in user isn't a platform admin. Setup/Studio are gated by `setup.access` / `studio.access` on `admin_full_access`, auto-granted only to the first registered **human** (`bootstrapPlatformAdmin`). The `usr_system` seed identity is skipped, so it can't steal the grant. Either sign up first (`--seed-admin`/`--fresh` does this) or check `sys_user_permission_set` for a cross-tenant (`organization_id = NULL`) `admin_full_access` link on your user. Don't edit nav code first. | +| A permission set is declared but grants nobody anything | Declaring a set is not assigning it. Assignment is a `sys_user_permission_set` row whose `permission_set_id` is the `sys_permission_set` **record id**, never the set's `name` — see "Assigning a permission set to a user" in **objectstack-data**. | --- From 7228d6c258aa09acf14ecbee02000e49df375371 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 21 Aug 2026 00:13:46 +0000 Subject: [PATCH 3/3] docs(skills): compress the permission-set addition per maintainer length ruling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The published skills bundle is loaded whole into an agent's context window, so its total length is a real cost that a small feature must not buy. Maintainer ruling, 2026-08-21, verbatim and untranslated: 10402 需要整体考虑 skills 的长度,不能为了一个小功能扩写很多。 Member 1 (#10327, the `permissions` defineStack key at the authoring site) is already small and is unchanged. Member 2 (#10318) is rewritten as a tight subsection keeping only the load-bearing facts, one crisp statement each: - `sys_user_permission_set` as the join object, with its minimal field list folded into prose (was a seven-row table); - the `permission_set_id`-takes-the-record-id-not-the-`name` pitfall, kept at full strength — it is the highest-value sentence in the addition; - the write path `POST /api/v1/data/{object}` (was two curl blocks); - the `GET /api/v1/security/explain` pointer. Cut: the claim-to-source mapping table (it belongs in the PR body, where it already is), the two-user prove-the-deny walkthrough, the ordered inert-grant checklist (compressed to one line), the MCP-exposure callout, and the indirect position-binding paragraph. The `objectstack-platform` pointer row is one line and stays. Whole-file lengths, not just the diff: skills/objectstack-data/SKILL.md 1168 -> 1207 (+39, was +110) skills/objectstack-platform/SKILL.md 1285 -> 1286 (+1, unchanged) net addition across both files: 40 lines (was 111) `check:role-word` pins both files at exact counts and fails in both directions; the rewrite keeps them: objectstack-data 4, objectstack-platform 2. --- skills/objectstack-data/SKILL.md | 121 +++++++------------------------ 1 file changed, 25 insertions(+), 96 deletions(-) diff --git a/skills/objectstack-data/SKILL.md b/skills/objectstack-data/SKILL.md index 939c6d41dc..8033109fcb 100644 --- a/skills/objectstack-data/SKILL.md +++ b/skills/objectstack-data/SKILL.md @@ -614,102 +614,31 @@ export const salesUser = definePermissionSet({ ### Assigning a permission set to a user -Declaring a set grants nobody anything. An assignment is **data**, not -metadata: one row in the join object **`sys_user_permission_set`** -(`@objectstack/plugin-security`). Three facts you cannot guess from the type -surface, in the order they bite: - -1. **The declared set becomes a record at boot.** Everything in - `defineStack({ permissions })` is upserted into `sys_permission_set` by - `name`, with a generated `id`, on `kernel:ready` (ADR-0086 D5). That `id` - is minted per environment — it is never the same across dev / test / prod. -2. **`permission_set_id` takes that RECORD ID, not the `name`.** It is a - lookup to `sys_permission_set.id`. The resolver collects - `permission_set_id` from the grant rows and then loads - `sys_permission_set` **by `id`** — so a `name` written into this field - matches nothing, no error is raised at grant time, and the user quietly - ends up with no capability. This is the single most expensive mistake on - this object. -3. **So assigning is always two calls:** resolve `name` → `id`, then insert - the grant. - -```bash -# 1. name → record id (POST /query, QueryAST in the body; the path names the object) -curl -sX POST "$BASE/api/v1/data/sys_permission_set/query" \ - -H 'content-type: application/json' -H "authorization: Bearer $ADMIN_TOKEN" \ - -d '{"where":{"name":"sales_user"},"fields":["id","name","active"],"limit":1}' -# → {"object":"sys_permission_set","records":[{"id":"ps_m4k…","name":"sales_user","active":true}]} - -# 2. grant it — permission_set_id is the id from step 1 -curl -sX POST "$BASE/api/v1/data/sys_user_permission_set" \ - -H 'content-type: application/json' -H "authorization: Bearer $ADMIN_TOKEN" \ - -d '{"user_id":"usr_alice","permission_set_id":"ps_m4k…","organization_id":null}' -``` - -The same two steps through ObjectQL (`ql.find` then `ql.insert`) inside a -plugin or seed script; the platform's own auto-grant writer does exactly this. - -**Row fields worth knowing** (`sys-user-permission-set.object.ts`): - -| Field | Notes | -|:--|:--| -| `user_id` | Required. Lookup to `sys_user`. | -| `permission_set_id` | Required. Lookup to `sys_permission_set` — the **id**. | -| `organization_id` | Optional. `null` = the grant applies in **every** org context; set it to scope the grant to one org. | -| `valid_from` / `valid_until` | Optional half-open window `[from, until)`, UTC. Enforced **at resolution time** — an expired grant stops granting immediately, with no background job. | -| `reason` | Free text. Required by the platform on delegation and break-glass grants. | -| `granted_by` | **Do not author it** — the security gate stamps the calling user on insert. | -| `id` | Omit it; the driver mints one. | - -Uniqueness is `(user_id, permission_set_id, organization_id)`, so the same set -can be granted independently per org context. - -**Who may write this row.** Not whoever holds CRUD bits on the table — the -gate says so in as many words: *"plain CRUD grants on RBAC tables do not make -a permission administrator"*. Writes are accepted from a tenant administrator, -or from a delegated `adminScope` that carries `manageAssignments`, allowlists -that specific set, and whose business-unit subtree covers the target user -(ADR-0090 D12). Anonymous and principal-less writes fail closed. - -#### Proving the deny — the minimal two-user check - -The reason to document this at all is that a permission story is only credible -when you have watched it refuse someone. The smallest honest exercise: - -1. Two users, **A** and **B**. Grant the set to **A** only, with the two calls - above. -2. As **A**, hit the object the set opens — `GET /api/v1/data/account` → - expect `200` with records. -3. As **B**, hit the same object → expect `403`. That is the deny, and it is - the half people forget to run. -4. Revoke: `DELETE /api/v1/data/sys_user_permission_set/{id}` (or set - `valid_until` to now). Re-run step 2 → **A** is now denied too. - -If a grant appears to do nothing, the causes are enumerable — check them in -this order before suspecting the evaluator: - -- `permission_set_id` holds a `name` instead of an `id` (fact 2 above); -- the `sys_permission_set` row has `active: false` — a deactivated set keeps - its assignments and grants nothing; -- `valid_from` / `valid_until` puts the grant outside its window; -- `organization_id` names an org other than the caller's active one. - -`GET /api/v1/security/explain?object=&operation=&userId=` answers -"why" from the same code path that enforces, so its verdict cannot drift from -the real one. Explaining **another** user needs `manage_users` or a delegated -`adminScope` covering them. - -> ⚠️ `sys_user_permission_set` is a system object, and system objects are -> **not exposed over MCP** unless a deployment opts in with -> `allowSystemObjects` — the tool answers *"Object … is a system object and is -> not exposed via MCP"*. So "ask the agent" will not find this for you; the -> data door above is the path. - -Grants also arrive **indirectly**: a set bound to a position -(`sys_position_permission_set`) is held by everyone assigned that position -(`sys_user_position`), and every authenticated principal implicitly holds the -`everyone` position. When auditing what a user actually has, read both sources -— `explain` already does. +Declaring a set grants nobody anything — an assignment is **data**: one row in +the join object **`sys_user_permission_set`** (`@objectstack/plugin-security`), +carrying `user_id`, `permission_set_id`, and an optional `organization_id` +(`null` = every org context). Optional `valid_from` / `valid_until` bound a +half-open window checked at resolution time; `granted_by` is stamped by the +gate on insert — never author it. + +⚠️ **`permission_set_id` takes the `sys_permission_set` RECORD ID, not the set's +`name`.** Grants resolve by loading `sys_permission_set` **by `id`**, so a `name` +in that field matches nothing, raises no error, and silently grants nothing. +Declared sets are upserted by `name` with a **generated** `id` on `kernel:ready` +(ADR-0086 D5) — that id differs per environment, so resolve it first. + +Assignment is therefore two calls, both `POST /api/v1/data/{object}` +(`…/query` with a QueryAST body for the read): look up the set's `id` in +`sys_permission_set` by `name`, then insert +`{ user_id, permission_set_id, organization_id }` into +`sys_user_permission_set`. Only a tenant admin — or a delegated `adminScope` +carrying `manageAssignments` for that set and user (ADR-0090 D12) — may write +it; plain CRUD bits on the table are not enough. + +**Grant looks inert?** Check in order: a `name` in `permission_set_id`; the set +is `active: false`; the validity window has passed; `organization_id` mismatch. +`GET /api/v1/security/explain?object=&operation=&userId=` answers from the +enforcing code path (explaining another user needs `manage_users`). ### Access depth (scope-depth) — the ERP "see my unit / my unit and below" axis