Skip to content

Commit 02f6af4

Browse files
os-zhuangclaude
andauthored
feat(security)!: enforce book audience at the REST read layer; finish the ADR-0090 D2/D3 cleanup the P1 wave missed (#2774)
* feat(security)!: enforce book audience at the REST read layer; finish the ADR-0090 D2/D3 cleanup the P1 wave missed Follow-through on the { permissionSet } book-audience rename (#2732), in ADR-0049 discipline — the gated arm existed but nothing enforced it: - rest: /meta/book, /meta/doc, and /meta/book/:name/tree now enforce the ADR-0046 §6.7 audience model. Anonymous callers see only public books/docs; { permissionSet }-gated books require holding the named set; a doc's effective audience is the union over the books that CLAIM it (unclaimed → org; orphan rendering never inherits public). Fails CLOSED when holdings cannot be resolved. doc/book item reads bypass the shared meta cache (per-caller gate vs shared ETag). Nine new route tests. - spec: pure helpers powering the gate (audienceAllows, resolveDocAudiences, docAudienceAllows, resolveBookClaimedDocs) with unit tests; the REST layer and any future portal share ONE semantics. - plugin-security: security service exposes resolvePermissionSetNames — the same resolution as data-plane enforcement. - D2/D3 leftovers: METADATA_FORM_REGISTRY role→position (the position type had LOST its form layout in the P1 rename) and profile removed; artifact ingestion maps positions→'position' (stale roles→'role' matched nothing and silently dropped compiled positions); EnvironmentArtifactMetadataSchema declares positions; metadata-form translations regain position and drop role/profile in all four locales (+ vocabulary regression test); position.test/audit.zod/security-plugin identifiers and comments de-role'd; eslint + doc-authoring domain lists Role→Position; content/docs/ui/role-based-interfaces.mdx renamed to audience-based-interfaces.mdx with stale permission-vocabulary copy fixed across five docs. - lint: books join the D3 role-word scan; new advisory rule security-book-audience-unknown-set flags a gated audience naming a set the stack does not declare (runtime fails closed — surface the typo at author time). - scripts/check-role-word.mjs: ADR-0090 D3 vocabulary RATCHET over content/docs + skills (baseline freezes the 45 current files; new occurrences fail CI; improvements ratchet the baseline down). Wired into the lint workflow. Verified: spec/objectql(808)/cli(464)/rest(227+9)/metadata(260)/ platform-objects(76)/plugin-security/lint(32) suites green; eslint, check:role-word, check:doc-authoring, check:api-surface (regenerated), check:liveness, check:skill-docs, check-changeset-fixed all pass. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ph4jEAfWDh6taMbZweWhom * test(lint): de-flake lazy-deps in-process gate test — explicit 30s timeout The in-process variant cold-loads sucrase + typescript and took 5.6s on a loaded CI runner, tripping vitest's default 5s per-test timeout (Test Core failure on this PR's first run). The test asserts a loading CONTRACT, not latency — give it an explicit generous timeout like the sibling dist-based variants effectively have via their spawn overhead. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ph4jEAfWDh6taMbZweWhom --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 4ab9958 commit 02f6af4

37 files changed

Lines changed: 996 additions & 345 deletions
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
---
2+
'@objectstack/spec': major
3+
'@objectstack/rest': minor
4+
'@objectstack/plugin-security': minor
5+
'@objectstack/lint': minor
6+
'@objectstack/metadata': minor
7+
'@objectstack/platform-objects': patch
8+
---
9+
10+
ADR-0090 follow-through wave: enforce book audience at the read layer; finish the D2/D3 cleanup the P1 rename missed.
11+
12+
- **rest**: `/meta/book`, `/meta/doc`, and `/meta/book/:name/tree` now ENFORCE
13+
the ADR-0046 §6.7 audience model (ADR-0049 — no unenforced security
14+
properties): anonymous callers see only `public` books/docs;
15+
`{ permissionSet }`-gated books require the caller to hold the named set;
16+
a doc's effective audience is the union over the books that CLAIM it
17+
(unclaimed docs default to `org`; orphan rendering never inherits `public`).
18+
Gated evaluation fails CLOSED when holdings cannot be resolved. `doc`/`book`
19+
single-item reads bypass the shared meta cache (per-caller gate vs shared ETag).
20+
- **spec**: new pure helpers powering that gate — `audienceAllows`,
21+
`resolveDocAudiences`, `docAudienceAllows`, `resolveBookClaimedDocs`
22+
(+ `AudienceCaller`/`AudienceBook` types). BREAKING (launch window):
23+
`METADATA_FORM_REGISTRY` keys `role`/`profile` are gone — `position` is the
24+
registered form (the `position` type had LOST its form layout in the P1
25+
rename); `EnvironmentArtifactMetadataSchema` declares `positions` instead of
26+
retired `roles`/`profiles`.
27+
- **plugin-security**: the `security` service exposes
28+
`resolvePermissionSetNames(ctx)` — the same resolution as data-plane
29+
enforcement, for the docs gate.
30+
- **metadata**: artifact ingestion maps `positions → 'position'` (the stale
31+
`roles → 'role'` mapping matched nothing since the P1 rename, silently
32+
dropping compiled positions from metadata registration).
33+
- **lint**: books join the D3 role-word scan (their `audience` is a
34+
permission-model reference now), and a new advisory rule
35+
`security-book-audience-unknown-set` flags a `{ permissionSet }` audience
36+
naming a set the stack does not declare (runtime fails closed — the typo
37+
cost is "nobody can read the book", so say it at author time).
38+
- **platform-objects**: metadata-form translations regain `position` (all four
39+
locales) and drop the retired `role`/`profile` groups, with a vocabulary
40+
regression test.

.github/workflows/lint.yml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,13 @@ jobs:
5858
- name: Doc/skill authoring guard
5959
run: pnpm check:doc-authoring
6060

61+
# ADR-0090 D3 vocabulary ratchet: "role" is reserved-forbidden in docs
62+
# and skills. Existing occurrences are frozen in the baseline (better-auth
63+
# boundary, ARIA samples, educational mentions); NEW occurrences fail.
64+
# Improvements ratchet the baseline down via --update.
65+
- name: Reserved-word ("role") docs ratchet
66+
run: pnpm check:role-word
67+
6168
# Authorization resolution must stay single-sourced (resolveAuthzContext,
6269
# @objectstack/core). Guards against a duplicate resolver copy drifting on a
6370
# security path (the REST-vs-dispatcher sys_user_role drift) and against an

content/docs/automation/approvals.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ An approval is a **flow** with an **approval node**. Two access decisions matter
1515

1616
### 1. Who can configure it
1717

18-
Authoring flows/automations is a builder capability — it needs `manage_metadata` (typically Studio users). End users **submit** records and **act on** approval requests, but they do not edit the automation. Keep the automation surfaces out of consumer apps (see [role-based interfaces](/docs/ui/role-based-interfaces)).
18+
Authoring flows/automations is a builder capability — it needs `manage_metadata` (typically Studio users). End users **submit** records and **act on** approval requests, but they do not edit the automation. Keep the automation surfaces out of consumer apps (see [audience-based interfaces](/docs/ui/audience-based-interfaces)).
1919

2020
### 2. As whom does it run — the safety decision
2121

content/docs/permissions/access-recipes.mdx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ description: Map a concrete access requirement onto the platform's layers — ob
1111
1212
## Recommended solution
1313

14-
Authorization splits into three decoupled concerns (ADR-0066): **capability** (what can be done), **assignment** (who holds it — permission sets / roles, maintained at runtime), and **requirement** (what a resource declares it needs). A resource declares *what is required*; it never bakes in *who*.
14+
Authorization splits into three decoupled concerns (ADR-0066): **capability** (what can be done), **assignment** (who holds it — permission sets / positions, maintained at runtime), and **requirement** (what a resource declares it needs). A resource declares *what is required*; it never bakes in *who*.
1515

1616
Pick the layer that matches the requirement:
1717

@@ -45,7 +45,7 @@ definePermissionSet({
4545

4646
Two separate questions:
4747

48-
1. **Who can configure it?** Editing flows/automations needs `manage_metadata` (typically Studio users). Don't expose automation config to end users — see [role-based interfaces](/docs/ui/role-based-interfaces).
48+
1. **Who can configure it?** Editing flows/automations needs `manage_metadata` (typically Studio users). Don't expose automation config to end users — see [audience-based interfaces](/docs/ui/audience-based-interfaces).
4949
2. **As whom does it run?** A flow's `runAs` (ADR-0049):
5050
- `runAs: 'user'` (default) — runs as the triggering user; CRUD nodes respect that user's RLS. **Safer default.**
5151
- `runAs: 'system'` — elevated, bypasses RLS. Make elevation *explicit*, and surface it in the UI as "runs as system".

content/docs/permissions/authorization.mdx

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -105,13 +105,13 @@ package (metadata); subject bindings and env-specific values stay as config.**
105105
seeds `stack.permissions` at boot — idempotent, re-seeded on upgrade, and it
106106
**never clobbers** env-authored (`platform`/`user`/legacy) rows. A package
107107
never writes into a foreign record.
108-
- The environment admin **assigns** sets to roles/users; the runtime unions
108+
- The environment admin **assigns** sets to positions/users; the runtime unions
109109
them. One shared set with hand-picked cross-package grants remains an
110110
env-admin-only construct.
111111
- This is what makes package **uninstall** well-defined (drop the package's
112112
own sets) and the objectui Access matrix scopable to `{ packageId }`.
113-
- Declared roles and sharing rules seed the same way
114-
(`bootstrapDeclaredRoles`, ADR-0057 D6) — a declarable-but-never-seeded
113+
- Declared positions and sharing rules seed the same way
114+
(`bootstrapDeclaredPositions`, ADR-0057 D6) — a declarable-but-never-seeded
115115
array is exactly the inert-metadata smell ADR-0078 prohibits.
116116

117117
### Two doors, one metadata (ADR-0086 D6/D7)
@@ -147,7 +147,7 @@ one of two doors, each writing only what it owns:
147147

148148
| Stage | What holds today | Owned by |
149149
|---|---|---|
150-
| 1 · Package development | Zod-validated authoring; roles / sharingRules / permissions seeded at boot with provenance | ADR-0057 D6, ADR-0086 D5, ADR-0049/0078 gates |
150+
| 1 · Package development | Zod-validated authoring; positions / sharingRules / permissions seeded at boot with provenance | ADR-0057 D6, ADR-0086 D5, ADR-0049/0078 gates |
151151
| 2 · Distribution / install / upgrade / uninstall | Install-consent scopes (ADR-0025 — consent ≠ RBAC grants); namespaced, collision-free composition; provenance axis makes uninstall well-defined | ADR-0025/0028/0048/0086 |
152152
| 3 · Environment composition / assignment | Platform-owned assignment records (`sys_user_position` etc.); anti-escalation; union semantics | ADR-0057 D4 |
153153
| 4 · Runtime enforcement | The six-gate chain above; ~18 primitives enforced and CI-guarded | ADR-0056 D10 matrix |
@@ -261,7 +261,7 @@ The complete, prioritized gap map lives in issue **#2561** (the production
261261
| [0056](/adr/0056-permission-model-landing-verification) | Permission-model landing: OWD, anonymous deny default, D10 matrix |
262262
| [0057](/adr/0057-erp-authorization-core-business-units-and-scope-depth) | Business units, scope depth, declarative RBAC seeding, platform-owned assignment |
263263
| [0066](/adr/0066-unified-authorization-model) | Unified model: capability registry, posture, precedence, future refinements |
264-
| [0068](/adr/0068-identity-roles) | Built-in identity roles, `EvalUser` |
264+
| [0068](/adr/0068-identity-roles) | Built-in identity positions (formerly "identity roles"), `EvalUser` |
265265
| [0069](/adr/0069-enterprise-authentication-hardening) | Enterprise authentication hardening (phased) |
266266
| [0078](/adr/0078-no-inert-declarable-metadata) | No inert declarable metadata |
267267
| [0086](/adr/0086-authz-metadata-config-boundary-and-cross-package-composition) | Metadata↔config boundary, package provenance, cross-package composition |

content/docs/references/system/environment-artifact.mdx

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -173,8 +173,7 @@ Compiled environment metadata grouped by category
173173
| **skills** | `any[]` | optional | |
174174
| **permissions** | `any[]` | optional | |
175175
| **permissionSets** | `any[]` | optional | |
176-
| **roles** | `any[]` | optional | |
177-
| **profiles** | `any[]` | optional | |
176+
| **positions** | `any[]` | optional | |
178177
| **translations** | `any[]` | optional | |
179178
| **datasources** | `any[]` | optional | |
180179
| **datasets** | `any[]` | optional | |
File renamed without changes.

content/docs/ui/field-grouping-and-order.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ Conflating these two is the single most common source of exactly this confusion.
6464

6565
## Why
6666

67-
Keeping grouping off the field (beyond an optional semantic hint) is what lets **one model project to many surfaces**: a create form, a full edit form, a mobile form, a public intake form, role-specific layouts, a flat table, and an API — each arranging the *same* fields differently. Weld a single layout onto the field and you get exactly one arrangement everywhere, which every surface must then share. The semantic `field.group` is the deliberate middle ground: a default that travels, without dictating layout.
67+
Keeping grouping off the field (beyond an optional semantic hint) is what lets **one model project to many surfaces**: a create form, a full edit form, a mobile form, a public intake form, audience-specific layouts, a flat table, and an API — each arranging the *same* fields differently. Weld a single layout onto the field and you get exactly one arrangement everywhere, which every surface must then share. The semantic `field.group` is the deliberate middle ground: a default that travels, without dictating layout.
6868

6969
## Runnable example
7070

content/docs/ui/index.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ export const CrmApp = App.create({
5252
<Cards>
5353
<Card href="/docs/ui/create-vs-edit-form" title="Create form ≠ edit form" description="Different layouts per form mode" />
5454
<Card href="/docs/ui/field-grouping-and-order" title="Field grouping & order" description="Control form structure" />
55-
<Card href="/docs/ui/role-based-interfaces" title="Audience-based interfaces" description="Different UIs per audience" />
55+
<Card href="/docs/ui/audience-based-interfaces" title="Audience-based interfaces" description="Different UIs per audience" />
5656
<Card href="/docs/ui/public-data-collection" title="Collect data from the public" description="Anonymous portal routes end to end" />
5757
</Cards>
5858

content/docs/ui/meta.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
"---Recipes---",
1414
"create-vs-edit-form",
1515
"field-grouping-and-order",
16-
"role-based-interfaces",
16+
"audience-based-interfaces",
1717
"public-data-collection"
1818
]
1919
}

0 commit comments

Comments
 (0)