diff --git a/.changeset/adr-0090-docs-audience-enforcement.md b/.changeset/adr-0090-docs-audience-enforcement.md new file mode 100644 index 0000000000..595444ad1e --- /dev/null +++ b/.changeset/adr-0090-docs-audience-enforcement.md @@ -0,0 +1,40 @@ +--- +'@objectstack/spec': major +'@objectstack/rest': minor +'@objectstack/plugin-security': minor +'@objectstack/lint': minor +'@objectstack/metadata': minor +'@objectstack/platform-objects': patch +--- + +ADR-0090 follow-through wave: enforce book audience at the read layer; finish the D2/D3 cleanup the P1 rename missed. + +- **rest**: `/meta/book`, `/meta/doc`, and `/meta/book/:name/tree` now ENFORCE + the ADR-0046 §6.7 audience model (ADR-0049 — no unenforced security + properties): anonymous callers see only `public` books/docs; + `{ permissionSet }`-gated books require the caller to hold the named set; + a doc's effective audience is the union over the books that CLAIM it + (unclaimed docs default to `org`; orphan rendering never inherits `public`). + Gated evaluation fails CLOSED when holdings cannot be resolved. `doc`/`book` + single-item reads bypass the shared meta cache (per-caller gate vs shared ETag). +- **spec**: new pure helpers powering that gate — `audienceAllows`, + `resolveDocAudiences`, `docAudienceAllows`, `resolveBookClaimedDocs` + (+ `AudienceCaller`/`AudienceBook` types). BREAKING (launch window): + `METADATA_FORM_REGISTRY` keys `role`/`profile` are gone — `position` is the + registered form (the `position` type had LOST its form layout in the P1 + rename); `EnvironmentArtifactMetadataSchema` declares `positions` instead of + retired `roles`/`profiles`. +- **plugin-security**: the `security` service exposes + `resolvePermissionSetNames(ctx)` — the same resolution as data-plane + enforcement, for the docs gate. +- **metadata**: artifact ingestion maps `positions → 'position'` (the stale + `roles → 'role'` mapping matched nothing since the P1 rename, silently + dropping compiled positions from metadata registration). +- **lint**: books join the D3 role-word scan (their `audience` is a + permission-model reference now), and a new advisory rule + `security-book-audience-unknown-set` flags a `{ permissionSet }` audience + naming a set the stack does not declare (runtime fails closed — the typo + cost is "nobody can read the book", so say it at author time). +- **platform-objects**: metadata-form translations regain `position` (all four + locales) and drop the retired `role`/`profile` groups, with a vocabulary + regression test. diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 008f1d8c7f..8236ef37f1 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -58,6 +58,13 @@ jobs: - name: Doc/skill authoring guard run: pnpm check:doc-authoring + # ADR-0090 D3 vocabulary ratchet: "role" is reserved-forbidden in docs + # and skills. Existing occurrences are frozen in the baseline (better-auth + # boundary, ARIA samples, educational mentions); NEW occurrences fail. + # Improvements ratchet the baseline down via --update. + - name: Reserved-word ("role") docs ratchet + run: pnpm check:role-word + # Authorization resolution must stay single-sourced (resolveAuthzContext, # @objectstack/core). Guards against a duplicate resolver copy drifting on a # security path (the REST-vs-dispatcher sys_user_role drift) and against an diff --git a/content/docs/automation/approvals.mdx b/content/docs/automation/approvals.mdx index 3eac087d89..6866a7dc39 100644 --- a/content/docs/automation/approvals.mdx +++ b/content/docs/automation/approvals.mdx @@ -15,7 +15,7 @@ An approval is a **flow** with an **approval node**. Two access decisions matter ### 1. Who can configure it -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)). +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)). ### 2. As whom does it run — the safety decision diff --git a/content/docs/permissions/access-recipes.mdx b/content/docs/permissions/access-recipes.mdx index 2d90ee3e87..8f6828e125 100644 --- a/content/docs/permissions/access-recipes.mdx +++ b/content/docs/permissions/access-recipes.mdx @@ -11,7 +11,7 @@ description: Map a concrete access requirement onto the platform's layers — ob ## Recommended solution -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*. +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*. Pick the layer that matches the requirement: @@ -45,7 +45,7 @@ definePermissionSet({ Two separate questions: -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). +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). 2. **As whom does it run?** A flow's `runAs` (ADR-0049): - `runAs: 'user'` (default) — runs as the triggering user; CRUD nodes respect that user's RLS. **Safer default.** - `runAs: 'system'` — elevated, bypasses RLS. Make elevation *explicit*, and surface it in the UI as "runs as system". diff --git a/content/docs/permissions/authorization.mdx b/content/docs/permissions/authorization.mdx index 038d51130d..4f5e63c033 100644 --- a/content/docs/permissions/authorization.mdx +++ b/content/docs/permissions/authorization.mdx @@ -105,13 +105,13 @@ package (metadata); subject bindings and env-specific values stay as config.** seeds `stack.permissions` at boot — idempotent, re-seeded on upgrade, and it **never clobbers** env-authored (`platform`/`user`/legacy) rows. A package never writes into a foreign record. -- The environment admin **assigns** sets to roles/users; the runtime unions +- The environment admin **assigns** sets to positions/users; the runtime unions them. One shared set with hand-picked cross-package grants remains an env-admin-only construct. - This is what makes package **uninstall** well-defined (drop the package's own sets) and the objectui Access matrix scopable to `{ packageId }`. -- Declared roles and sharing rules seed the same way - (`bootstrapDeclaredRoles`, ADR-0057 D6) — a declarable-but-never-seeded +- Declared positions and sharing rules seed the same way + (`bootstrapDeclaredPositions`, ADR-0057 D6) — a declarable-but-never-seeded array is exactly the inert-metadata smell ADR-0078 prohibits. ### Two doors, one metadata (ADR-0086 D6/D7) @@ -147,7 +147,7 @@ one of two doors, each writing only what it owns: | Stage | What holds today | Owned by | |---|---|---| -| 1 · Package development | Zod-validated authoring; roles / sharingRules / permissions seeded at boot with provenance | ADR-0057 D6, ADR-0086 D5, ADR-0049/0078 gates | +| 1 · Package development | Zod-validated authoring; positions / sharingRules / permissions seeded at boot with provenance | ADR-0057 D6, ADR-0086 D5, ADR-0049/0078 gates | | 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 | | 3 · Environment composition / assignment | Platform-owned assignment records (`sys_user_position` etc.); anti-escalation; union semantics | ADR-0057 D4 | | 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 | [0056](/adr/0056-permission-model-landing-verification) | Permission-model landing: OWD, anonymous deny default, D10 matrix | | [0057](/adr/0057-erp-authorization-core-business-units-and-scope-depth) | Business units, scope depth, declarative RBAC seeding, platform-owned assignment | | [0066](/adr/0066-unified-authorization-model) | Unified model: capability registry, posture, precedence, future refinements | -| [0068](/adr/0068-identity-roles) | Built-in identity roles, `EvalUser` | +| [0068](/adr/0068-identity-roles) | Built-in identity positions (formerly "identity roles"), `EvalUser` | | [0069](/adr/0069-enterprise-authentication-hardening) | Enterprise authentication hardening (phased) | | [0078](/adr/0078-no-inert-declarable-metadata) | No inert declarable metadata | | [0086](/adr/0086-authz-metadata-config-boundary-and-cross-package-composition) | Metadata↔config boundary, package provenance, cross-package composition | diff --git a/content/docs/references/system/environment-artifact.mdx b/content/docs/references/system/environment-artifact.mdx index 5297955ba0..43c130b9b5 100644 --- a/content/docs/references/system/environment-artifact.mdx +++ b/content/docs/references/system/environment-artifact.mdx @@ -173,8 +173,7 @@ Compiled environment metadata grouped by category | **skills** | `any[]` | optional | | | **permissions** | `any[]` | optional | | | **permissionSets** | `any[]` | optional | | -| **roles** | `any[]` | optional | | -| **profiles** | `any[]` | optional | | +| **positions** | `any[]` | optional | | | **translations** | `any[]` | optional | | | **datasources** | `any[]` | optional | | | **datasets** | `any[]` | optional | | diff --git a/content/docs/ui/role-based-interfaces.mdx b/content/docs/ui/audience-based-interfaces.mdx similarity index 100% rename from content/docs/ui/role-based-interfaces.mdx rename to content/docs/ui/audience-based-interfaces.mdx diff --git a/content/docs/ui/field-grouping-and-order.mdx b/content/docs/ui/field-grouping-and-order.mdx index 20569a6df7..e496ea7e4e 100644 --- a/content/docs/ui/field-grouping-and-order.mdx +++ b/content/docs/ui/field-grouping-and-order.mdx @@ -64,7 +64,7 @@ Conflating these two is the single most common source of exactly this confusion. ## Why -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. +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. ## Runnable example diff --git a/content/docs/ui/index.mdx b/content/docs/ui/index.mdx index 7a44f48b6e..12adc5fbc0 100644 --- a/content/docs/ui/index.mdx +++ b/content/docs/ui/index.mdx @@ -52,7 +52,7 @@ export const CrmApp = App.create({ - + diff --git a/content/docs/ui/meta.json b/content/docs/ui/meta.json index 5a47c4b7ed..0a56e5bca3 100644 --- a/content/docs/ui/meta.json +++ b/content/docs/ui/meta.json @@ -13,7 +13,7 @@ "---Recipes---", "create-vs-edit-form", "field-grouping-and-order", - "role-based-interfaces", + "audience-based-interfaces", "public-data-collection" ] } diff --git a/content/docs/ui/setup-app.mdx b/content/docs/ui/setup-app.mdx index f99c9dc311..27b7b1bd43 100644 --- a/content/docs/ui/setup-app.mdx +++ b/content/docs/ui/setup-app.mdx @@ -26,7 +26,7 @@ ADR-0048 (one app per package), `/apps/setup` — resolvable as ## Where it lives -| File | Role | +| File | Purpose | |:---|:---| | `packages/platform-objects/src/apps/setup.app.ts` | The `App` definition — the navigation **shell** (group anchors), branding, required permissions | | `packages/platform-objects/src/apps/setup-nav.contributions.ts` | `SETUP_NAV_CONTRIBUTIONS` — the nav entries owned by `@objectstack/platform-objects`, merged into the shell at runtime | @@ -51,7 +51,7 @@ anchors are: | **Overview** (`group_overview`) | System Overview dashboard — `platform-objects` | | **Apps** (`group_apps`) | Capability plugins only (e.g. marketplace via cloud-connection); empty otherwise | | **People & Organization** (`group_people_org`) | Users · Departments · Teams · Organizations · Invitations — `platform-objects` | -| **Access Control** (`group_access_control`) | Roles / Permission Sets — `plugin-security`; Sharing Rules / Record Shares — `plugin-sharing`; API Keys — `platform-objects` | +| **Access Control** (`group_access_control`) | Positions / Permission Sets — `plugin-security`; Sharing Rules / Record Shares — `plugin-sharing`; API Keys — `platform-objects` | | **Approvals** (`group_approvals`) | `plugin-approvals` | | **Configuration** (`group_configuration`) | All Settings · Branding · Authentication · Email · File Storage · AI & Embedder · Knowledge · Feature Flags — `platform-objects` | | **Diagnostics** (`group_diagnostics`) | Sessions · Notification Events — `platform-objects`; Audit Logs — `plugin-audit` | diff --git a/eslint.config.mjs b/eslint.config.mjs index 5413303546..4f3f2694e8 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -37,7 +37,7 @@ const SUBPATH_RULE_MESSAGE = // `.parse()` time and is a *value* import that fails loudly on a broken import // instead of silently degrading to `any` (the #2023 failure mode). const DOMAIN_TYPES = [ - 'Datasource', 'Connector', 'Policy', 'SharingRule', 'Role', 'PermissionSet', + 'Datasource', 'Connector', 'Policy', 'SharingRule', 'Position', 'PermissionSet', 'EmailTemplateDefinition', 'Report', 'Webhook', 'ObjectExtension', 'Cube', 'Mapping', 'Theme', 'TranslationBundle', 'Page', 'Action', ].flatMap((t) => [t, t + 'Input']).join('|'); diff --git a/package.json b/package.json index 98e47361cf..dac1db4f06 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "objectui:clean": "rm -rf packages/console/dist .cache/objectui-*", "lint": "eslint . --no-inline-config", "check:doc-authoring": "node scripts/check-doc-authoring.mjs", + "check:role-word": "node scripts/check-role-word.mjs", "check:authz-resolver": "node scripts/check-single-authz-resolver.mjs", "check:console-sha": "node scripts/check-console-sha.mjs" }, diff --git a/packages/lint/src/index.ts b/packages/lint/src/index.ts index f94d16a391..b0d1cbd0f9 100644 --- a/packages/lint/src/index.ts +++ b/packages/lint/src/index.ts @@ -91,6 +91,7 @@ export { SECURITY_WILDCARD_VAMA, SECURITY_ANCHOR_HIGH_PRIVILEGE, SECURITY_ROLE_WORD, + SECURITY_BOOK_AUDIENCE_UNKNOWN_SET, SECURITY_PRIVATE_NO_READSCOPE, } from './validate-security-posture.js'; export type { SecurityFinding, SecuritySeverity } from './validate-security-posture.js'; diff --git a/packages/lint/src/lazy-deps.test.ts b/packages/lint/src/lazy-deps.test.ts index 4f51bf47de..ee553fbc15 100644 --- a/packages/lint/src/lazy-deps.test.ts +++ b/packages/lint/src/lazy-deps.test.ts @@ -129,5 +129,8 @@ describe('lazy dependency loading (kernel boot-path contract)', () => { }); expect(depLoaded(req.cache, 'typescript')).toBe(true); expect(props.some((f) => f.rule === 'react-prop-missing-required' && /objectName/.test(f.message))).toBe(true); - }); + // Cold-loading sucrase + typescript in-process takes >5s on a loaded CI + // runner (dozens of parallel turbo tasks) — the default 5s timeout flakes + // there while the assertion set is pure contract, not latency. + }, 30_000); }); diff --git a/packages/lint/src/validate-security-posture.test.ts b/packages/lint/src/validate-security-posture.test.ts index 042c77d65d..e4148ca855 100644 --- a/packages/lint/src/validate-security-posture.test.ts +++ b/packages/lint/src/validate-security-posture.test.ts @@ -15,6 +15,7 @@ import { SECURITY_WILDCARD_VAMA, SECURITY_ANCHOR_HIGH_PRIVILEGE, SECURITY_ROLE_WORD, + SECURITY_BOOK_AUDIENCE_UNKNOWN_SET, SECURITY_PRIVATE_NO_READSCOPE, SECURITY_MASTER_DETAIL_UNGRANTED, } from './validate-security-posture.js'; @@ -315,3 +316,45 @@ describe('validateSecurityPosture · master-detail detail ungranted (framework#2 expect(md[0].where).toBe('object "work_order_item"'); }); }); + +describe('validateSecurityPosture · book audience (ADR-0046 §6.7 / ADR-0090)', () => { + it('flags the reserved word in book names and labels', () => { + const findings = validateSecurityPosture({ + books: [ + { name: 'crm_role_guide', label: 'CRM Guide', groups: [] }, + { name: 'crm_admin_guide', label: 'Admin Roles Handbook', groups: [] }, + ], + }); + const bookRole = findings.filter((f) => f.rule === SECURITY_ROLE_WORD); + expect(bookRole).toHaveLength(2); + expect(bookRole.map((f) => f.where)).toEqual(['book "crm_role_guide"', 'book "crm_admin_guide"']); + }); + + it('warns when a { permissionSet } audience references a set the stack does not declare', () => { + const findings = validateSecurityPosture({ + permissions: [{ name: 'crm_admin' }], + books: [ + { name: 'crm_admin_guide', audience: { permissionSet: 'crm_admn' }, groups: [] }, // typo + ], + }); + const dangling = findings.filter((f) => f.rule === SECURITY_BOOK_AUDIENCE_UNKNOWN_SET); + expect(dangling).toHaveLength(1); + expect(dangling[0].severity).toBe('warning'); + expect(dangling[0].path).toBe('books[0].audience.permissionSet'); + expect(dangling[0].message).toContain('crm_admn'); + }); + + it('accepts a gated book whose set the stack declares — and scalar audiences', () => { + expect( + rulesOf({ + permissions: [{ name: 'crm_admin' }], + books: [ + { name: 'crm_admin_guide', audience: { permissionSet: 'crm_admin' }, groups: [] }, + { name: 'crm_guide', audience: 'public', groups: [] }, + { name: 'crm_internal', audience: 'org', groups: [] }, + { name: 'crm_default', groups: [] }, + ], + }), + ).toEqual([]); + }); +}); diff --git a/packages/lint/src/validate-security-posture.ts b/packages/lint/src/validate-security-posture.ts index 9c997dd299..8922c2926b 100644 --- a/packages/lint/src/validate-security-posture.ts +++ b/packages/lint/src/validate-security-posture.ts @@ -14,6 +14,7 @@ * | security-wildcard-vama (error) | ADR-0066 superuser wildcard | * | security-anchor-high-privilege(error) | ADR-0090 D5/D9 anchors | * | security-role-word (error) | ADR-0090 D3 vocabulary freeze | + * | security-book-audience-unknown-set(warn)| ADR-0046 §6.7 { permissionSet } | * | security-private-no-readscope (info) | admin-intent mismatch class | * | security-master-detail-ungranted(warn) | framework#2700 os-tianshun-mtc#43| * @@ -38,6 +39,7 @@ export const SECURITY_EXTERNAL_WIDER = 'security-external-wider-than-internal'; export const SECURITY_WILDCARD_VAMA = 'security-wildcard-vama'; export const SECURITY_ANCHOR_HIGH_PRIVILEGE = 'security-anchor-high-privilege'; export const SECURITY_ROLE_WORD = 'security-role-word'; +export const SECURITY_BOOK_AUDIENCE_UNKNOWN_SET = 'security-book-audience-unknown-set'; export const SECURITY_PRIVATE_NO_READSCOPE = 'security-private-no-readscope'; export const SECURITY_MASTER_DETAIL_UNGRANTED = 'security-master-detail-ungranted'; @@ -335,6 +337,46 @@ export function validateSecurityPosture(stack: AnyRec): SecurityFinding[] { for (const [i, app] of asArray(stack.apps).entries()) { flagRole('app', app.name, app.label, `app "${String(app.name ?? i)}"`, `apps[${i}].name`); } + for (const [i, book] of asArray(stack.books).entries()) { + // Books entered the security-relevant set when `book.audience` became a + // permission-model reference (ADR-0046 §6.7 / ADR-0090): their names and + // labels are access-adjacent UI copy. + flagRole('book', book.name, book.label, `book "${String(book.name ?? i)}"`, `books[${i}].name`); + } + + // ── Book audience → permission-set reference must resolve ──────────── + // A `{ permissionSet }` book audience names a set the reader must hold + // (ADR-0046 §6.7). The runtime fails CLOSED on an unknown name (nobody + // holds it → nobody reads the book), so a typo is not a leak — but it IS + // the "why can nobody see the Admin Guide" support class, and packages + // should gate their books on their own sets (ADR-0090 D9 / ADR-0086 + // provenance). Advisory: an environment-authored book may legitimately + // reference an installed package's set that is not in THIS stack. + const stackSetNames = new Set( + permissionSets + .map((ps) => (typeof ps.name === 'string' ? ps.name : undefined)) + .filter((n): n is string => !!n), + ); + for (const [i, book] of asArray(stack.books).entries()) { + const audience = (book as AnyRec).audience; + if (!audience || typeof audience !== 'object') continue; + const setName = (audience as AnyRec).permissionSet; + if (typeof setName !== 'string' || setName.length === 0) continue; + if (!stackSetNames.has(setName)) { + findings.push({ + severity: 'warning', + rule: SECURITY_BOOK_AUDIENCE_UNKNOWN_SET, + where: `book "${String(book.name ?? i)}"`, + path: `books[${i}].audience.permissionSet`, + message: + `book audience references permission set "${setName}", which this stack does not declare. ` + + `The runtime fails closed — no holder means NO reader can open the book.`, + hint: + `Gate the book on one of this package's own permission sets (ADR-0090 D9, e.g. its admin set), ` + + `or fix the typo. Ignore if the set is intentionally provided by another installed package.`, + }); + } + } // ── Admin-intent mismatch: private object, plain read, no depth ────── // An object whose baseline is private (explicit or D1-defaulted) where a set diff --git a/packages/metadata/src/plugin.ts b/packages/metadata/src/plugin.ts index 11a89a953c..91b1ed7dc5 100644 --- a/packages/metadata/src/plugin.ts +++ b/packages/metadata/src/plugin.ts @@ -69,7 +69,10 @@ const ARTIFACT_FIELD_TO_TYPE: Record = { themes: 'theme', workflows: 'workflow', flows: 'flow', - roles: 'role', + // ADR-0090 D3: stacks declare `positions` (stack.zod.ts); the retired + // `roles: 'role'` mapping matched nothing and silently dropped compiled + // positions from artifact ingestion. + positions: 'position', permissions: 'permission', sharingRules: 'sharing_rule', policies: 'policy', diff --git a/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts index b5d251d7b9..6971c2ecae 100644 --- a/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts @@ -1338,67 +1338,12 @@ export const enMetadataForms: NonNullable = { } } }, - profile: { - label: "Profile", + position: { + label: "Position", sections: { - identity: { - label: "Identity", - description: "Permission Sets stack on top of a Profile to grant additional access. Profiles are the base set assigned 1:1 to each user." - }, - system_permissions: { - label: "System Permissions", - description: "High-level capabilities not tied to a specific object — e.g. manage_users, view_audit_logs." - }, - object_and_field_permissions: { - label: "Object & Field Permissions", - description: "Per-object CRUD + per-field FLS. Edit via the matrix editor or paste JSON here." - }, - tab_and_row_level_security: { - label: "Tab & Row-Level Security", - description: "Tab visibility, RLS policies, and custom context variables for predicate evaluation." - } - }, - fields: { - name: { - label: "Name", - helpText: "Machine name (snake_case)" - }, - label: { - label: "Label", - helpText: "Display label for admins" - }, - systemPermissions: { - label: "System Permissions", - helpText: "List of system capability keys" - }, - objects: { - label: "Objects", - helpText: "{ \"account\": { allowRead: true, allowEdit: true, ... } }" - }, - fields: { - label: "Fields", - helpText: "{ \"account.amount\": { readable: true, editable: false } }" - }, - tabPermissions: { - label: "Tab Permissions", - helpText: "{ \"app_crm\": \"visible\", \"app_admin\": \"hidden\" }" - }, - rowLevelSecurity: { - label: "Row Level Security", - helpText: "Array of RLS policies (see rls.zod.ts)" - }, - contextVariables: { - label: "Context Variables", - helpText: "Custom variables referenced in RLS predicates" - } - } - }, - role: { - label: "Role", - sections: { - role: { - label: "Role", - description: "Roles compose a hierarchy used for record sharing (sales VP → sales mgr → sales rep). Permissions themselves live on Permission Sets and Profiles." + position: { + label: "Position", + description: "A position is a flat, assignable bundle of permission sets (e.g. sales_rep, sales_manager). Capability lives on permission sets; visibility depth lives on the business-unit tree." } }, fields: { @@ -1409,10 +1354,6 @@ export const enMetadataForms: NonNullable = { label: { label: "Label" }, - parent: { - label: "Parent", - helpText: "Parent role machine name (Reports To)" - }, description: { label: "Description" } diff --git a/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts index 3b2de070a7..05f3d1bf2a 100644 --- a/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts @@ -1338,67 +1338,12 @@ export const esESMetadataForms: NonNullable = } } }, - profile: { - label: "Perfil", + position: { + label: "Posición", sections: { - identity: { - label: "Identidad", - description: "Los conjuntos de permisos se apilan sobre un perfil para conceder acceso adicional. Los perfiles son el conjunto base asignado 1:1 a cada usuario." - }, - system_permissions: { - label: "Permisos del sistema", - description: "Capacidades de alto nivel no vinculadas a un objeto específico — p. ej. manage_users, view_audit_logs." - }, - object_and_field_permissions: { - label: "Permisos de objeto y campo", - description: "CRUD por objeto + FLS por campo. Edita mediante el editor matricial o pega JSON aquí." - }, - tab_and_row_level_security: { - label: "Pestaña y seguridad a nivel de fila", - description: "Visibilidad de pestañas, políticas RLS y variables de contexto personalizadas para evaluar predicados." - } - }, - fields: { - name: { - label: "Nombre", - helpText: "Nombre de máquina (snake_case)" - }, - label: { - label: "Etiqueta", - helpText: "Etiqueta mostrada para administradores" - }, - systemPermissions: { - label: "Permisos del sistema", - helpText: "Lista de claves de capacidades del sistema" - }, - objects: { - label: "Permisos de objeto", - helpText: "Ejemplo: { \"account\": { allowRead: true, allowEdit: true, ... } }" - }, - fields: { - label: "Campos", - helpText: "Ejemplo: { \"account.amount\": { readable: true, editable: false } }" - }, - tabPermissions: { - label: "Permisos de pestaña", - helpText: "Ejemplo: { \"app_crm\": \"visible\", \"app_admin\": \"hidden\" }" - }, - rowLevelSecurity: { - label: "Seguridad a nivel de fila", - helpText: "Array de políticas RLS (ver rls.zod.ts)" - }, - contextVariables: { - label: "Variables de contexto", - helpText: "Variables personalizadas referenciadas en predicados RLS" - } - } - }, - role: { - label: "Rol", - sections: { - role: { - label: "Rol", - description: "Los roles componen una jerarquía usada para compartir registros (sales VP → sales mgr → sales rep). Los permisos en sí residen en Permission Sets y Profiles." + position: { + label: "Posición", + description: "Una posición es un conjunto plano y asignable de permission sets (p. ej. sales_rep, sales_manager). La capacidad reside en los permission sets; la profundidad de visibilidad en el árbol de unidades de negocio." } }, fields: { @@ -1409,10 +1354,6 @@ export const esESMetadataForms: NonNullable = label: { label: "Etiqueta" }, - parent: { - label: "Padre", - helpText: "Nombre de máquina del rol padre (Reports To)" - }, description: { label: "Descripción" } diff --git a/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts index 5b83f552cd..baa0a40f53 100644 --- a/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts @@ -1338,67 +1338,12 @@ export const jaJPMetadataForms: NonNullable = } } }, - profile: { - label: "プロファイル", + position: { + label: "ポジション", sections: { - identity: { - label: "ID", - description: "権限セットはプロファイルに追加アクセス権を重ねる。プロファイルは各ユーザーに 1:1 で割り当てる基本セット。" - }, - system_permissions: { - label: "システム権限", - description: "特定オブジェクトに紐づかない高レベル機能 — 例: manage_users, view_audit_logs。" - }, - object_and_field_permissions: { - label: "オブジェクトとフィールド権限", - description: "オブジェクト単位の CRUD + フィールド単位の FLS。マトリックスエディターで編集、またはここに JSON を貼り付け。" - }, - tab_and_row_level_security: { - label: "タブと行レベルセキュリティ", - description: "タブ表示、RLS ポリシー、述語評価用カスタムコンテキスト変数。" - } - }, - fields: { - name: { - label: "名前", - helpText: "マシン名(snake_case)" - }, - label: { - label: "表示名", - helpText: "管理者向け表示ラベル" - }, - systemPermissions: { - label: "システム権限", - helpText: "システム機能キーのリスト" - }, - objects: { - label: "オブジェクト権限", - helpText: "例: { \"account\": { allowRead: true, allowEdit: true, ... } }" - }, - fields: { - label: "フィールド", - helpText: "例: { \"account.amount\": { readable: true, editable: false } }" - }, - tabPermissions: { - label: "タブ権限", - helpText: "例: { \"app_crm\": \"visible\", \"app_admin\": \"hidden\" }" - }, - rowLevelSecurity: { - label: "行レベルセキュリティ", - helpText: "RLS ポリシーの配列(rls.zod.ts 参照)" - }, - contextVariables: { - label: "コンテキスト変数", - helpText: "RLS 述語で参照するカスタム変数" - } - } - }, - role: { - label: "ロール", - sections: { - role: { - label: "ロール", - description: "ロールはレコード共有に使う階層を構成(sales VP → sales mgr → sales rep)。権限自体は Permission Sets と Profiles に存在。" + position: { + label: "ポジション", + description: "ポジションは権限セットをまとめて割り当てるフラットな単位(例: sales_rep、sales_manager)。能力は権限セットに、可視範囲の深さはビジネスユニットツリーにあります。" } }, fields: { @@ -1409,10 +1354,6 @@ export const jaJPMetadataForms: NonNullable = label: { label: "表示名" }, - parent: { - label: "親", - helpText: "親ロールのマシン名(Reports To)" - }, description: { label: "説明" } diff --git a/packages/platform-objects/src/apps/translations/metadata-forms-vocabulary.test.ts b/packages/platform-objects/src/apps/translations/metadata-forms-vocabulary.test.ts new file mode 100644 index 0000000000..5ab6b928a3 --- /dev/null +++ b/packages/platform-objects/src/apps/translations/metadata-forms-vocabulary.test.ts @@ -0,0 +1,43 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// Regression guard for ADR-0090 D2/D3 in the generated metadata-form +// translations. The concepts were retired in the P1 wave, but the FORM +// registry (and therefore these extractor-generated bundles) lagged: the +// bundles kept `role`/`profile` groups (with pre-D3 copy like "Roles compose +// a hierarchy…") while the `position` type had no form translations at all. +// This asserts the retired keys stay gone and the replacement stays present, +// across every locale. + +import { describe, it, expect } from 'vitest'; +import { METADATA_FORM_REGISTRY } from '@objectstack/spec/system'; +import { enMetadataForms } from './en.metadata-forms.generated.js'; +import { zhCNMetadataForms } from './zh-CN.metadata-forms.generated.js'; +import { jaJPMetadataForms } from './ja-JP.metadata-forms.generated.js'; +import { esESMetadataForms } from './es-ES.metadata-forms.generated.js'; + +const LOCALES = [ + { name: 'en', forms: enMetadataForms as Record }, + { name: 'zh-CN', forms: zhCNMetadataForms as Record }, + { name: 'ja-JP', forms: jaJPMetadataForms as Record }, + { name: 'es-ES', forms: esESMetadataForms as Record }, +]; + +describe('ADR-0090 D2/D3 — metadata-form translations use the v2 vocabulary', () => { + it('the form registry itself carries no retired kind', () => { + expect(Object.keys(METADATA_FORM_REGISTRY)).not.toContain('role'); + expect(Object.keys(METADATA_FORM_REGISTRY)).not.toContain('profile'); + expect(Object.keys(METADATA_FORM_REGISTRY)).toContain('position'); + }); + + for (const { name, forms } of LOCALES) { + it(`${name}: retired role/profile groups are gone, position group exists`, () => { + expect(forms.role, `${name} still carries a 'role' form group`).toBeUndefined(); + expect(forms.profile, `${name} still carries a 'profile' form group`).toBeUndefined(); + expect(forms.position?.label, `${name} lacks the 'position' form group`).toBeTruthy(); + expect(forms.position?.sections?.position?.label).toBeTruthy(); + for (const field of ['name', 'label', 'description']) { + expect(forms.position?.fields?.[field]?.label, `${name} position.fields.${field}`).toBeTruthy(); + } + }); + } +}); diff --git a/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts index 3c934db4cb..f815af5e65 100644 --- a/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts @@ -1338,67 +1338,12 @@ export const zhCNMetadataForms: NonNullable = } } }, - profile: { - label: "配置文件", + position: { + label: "岗位", sections: { - identity: { - label: "标识", - description: "权限集叠加在配置文件之上以授予额外的访问权限。配置文件是按 1:1 分配给每个用户的基础集合。" - }, - system_permissions: { - label: "系统权限", - description: "与特定对象无关的高级能力——例如 manage_users、view_audit_logs。" - }, - object_and_field_permissions: { - label: "对象与字段权限", - description: "按对象的增删改查 + 按字段的字段级安全(FLS)。可通过矩阵编辑器编辑,或在此处粘贴 JSON。" - }, - tab_and_row_level_security: { - label: "标签页与行级安全", - description: "标签页可见性、RLS 策略,以及用于谓词求值的自定义上下文变量。" - } - }, - fields: { - name: { - label: "名称", - helpText: "机器名(snake_case)" - }, - label: { - label: "显示名称", - helpText: "面向管理员显示的标签" - }, - systemPermissions: { - label: "系统权限", - helpText: "系统能力键列表" - }, - objects: { - label: "对象权限", - helpText: "示例:{ \"account\": { allowRead: true, allowEdit: true, ... } }" - }, - fields: { - label: "字段", - helpText: "示例:{ \"account.amount\": { readable: true, editable: false } }" - }, - tabPermissions: { - label: "标签页权限", - helpText: "示例:{ \"app_crm\": \"visible\", \"app_admin\": \"hidden\" }" - }, - rowLevelSecurity: { - label: "行级安全", - helpText: "RLS 策略数组(参见 rls.zod.ts)" - }, - contextVariables: { - label: "上下文变量", - helpText: "RLS 谓词中引用的自定义变量" - } - } - }, - role: { - label: "角色", - sections: { - role: { - label: "角色定义", - description: "角色名称与继承关系" + position: { + label: "岗位", + description: "岗位是扁平的、可分配的权限集组合(如 sales_rep、sales_manager)。能力在权限集上;可见范围深度在业务单元树上。" } }, fields: { @@ -1409,10 +1354,6 @@ export const zhCNMetadataForms: NonNullable = label: { label: "显示名称" }, - parent: { - label: "父级", - helpText: "继承父角色的权限" - }, description: { label: "描述" } diff --git a/packages/plugins/plugin-security/src/bootstrap-declared-positions.ts b/packages/plugins/plugin-security/src/bootstrap-declared-positions.ts index d04c4e0c61..759eed96f5 100644 --- a/packages/plugins/plugin-security/src/bootstrap-declared-positions.ts +++ b/packages/plugins/plugin-security/src/bootstrap-declared-positions.ts @@ -1,18 +1,18 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. /** - * bootstrapDeclaredPositions — seed stack-declared `roles` into `sys_position` + * bootstrapDeclaredPositions — seed stack-declared `positions` into `sys_position` * (ADR-0057 D6, closes #2077). * - * Reads the validated `role` metadata (registered from the stack's `positions: []` + * Reads the validated `position` metadata (registered from the stack's `positions: []` * via `metadataService.list('position')`) and idempotently upserts each into - * `sys_position` by `name`, so the runtime role→permission-set resolution + * `sys_position` by `name`, so the runtime position→permission-set resolution * (`resolveExecutionContext` → `sys_position` → `sys_position_permission_set`) and - * sharing-rule role recipients stop being decorative. Runs on `kernel:ready` + * sharing-rule position recipients stop being decorative. Runs on `kernel:ready` * alongside the platform-admin bootstrap. * - * Pre-launch posture (ADR-0057): upsert only — no prune. Role visibility - * HIERARCHY is NOT seeded here: per ADR-0057 D5 the role is a capability + * Pre-launch posture (ADR-0057): upsert only — no prune. Position visibility + * HIERARCHY is NOT seeded here: per ADR-0057 D5 the position is a capability * bundle, and "manager sees subordinates" lives on the `sys_business_unit` * tree, not `sys_position.parent`. */ @@ -44,7 +44,7 @@ interface SeedOptions { /** * Read declared metadata items of a type. The engine's SchemaRegistry - * (populated by `manifest.register` from the stack's `roles`/`sharingRules` + * (populated by `manifest.register` from the stack's `positions`/`sharingRules` * arrays) is the reliable source in every boot path; the metadata-service * facade only surfaces these once the compiled-artifact loader runs (serve.ts). */ diff --git a/packages/plugins/plugin-security/src/security-plugin.ts b/packages/plugins/plugin-security/src/security-plugin.ts index 35e68929f2..ff2459c9ce 100644 --- a/packages/plugins/plugin-security/src/security-plugin.ts +++ b/packages/plugins/plugin-security/src/security-plugin.ts @@ -107,7 +107,7 @@ export interface SecurityPluginOptions { defaultPermissionSets?: PermissionSet[]; /** * Permission set name applied as an implicit baseline whenever an - * authenticated request has no resolved permission sets (and no roles + * authenticated request has no resolved permission sets (and no positions * that map to one). This guarantees baseline tenant/owner RLS for * every logged-in user even before an admin assigns explicit * profiles. Set to `null` to disable. @@ -406,6 +406,16 @@ export class SecurityPlugin implements Plugin { }; ctx.registerService('security', { getReadFilter: (object: string, context?: any) => this.getReadFilter(object, context), + // [ADR-0046 §6.7] Effective permission-set NAMES for a caller — the + // primitive the REST read layer needs to evaluate a permission-set- + // gated book/doc audience ({ permissionSet: '…' }). Same resolution + // as the middleware (positions expanded, additive baseline), so the + // docs gate can never drift from data-plane enforcement. Throws on + // resolution failure — callers must fail CLOSED (ADR-0049). + resolvePermissionSetNames: async (context?: any): Promise => { + const sets = await this.resolvePermissionSetsForContext(context); + return sets.map((s) => s.name); + }, // [ADR-0090 D6] First-class access explanation. Same code paths as // the middleware (resolution/evaluator/RLS compiler) — explained by // construction. Explaining ANOTHER user requires `manage_users`. @@ -490,14 +500,14 @@ export class SecurityPlugin implements Plugin { await this.delegatedAdminGate.assert(opCtx); } - const roles = opCtx.context?.positions ?? []; + const positions = opCtx.context?.positions ?? []; const explicitPermissionSets = opCtx.context?.permissions ?? []; - // Skip security checks if no roles AND no explicit permission sets + // Skip security checks if no positions AND no explicit permission sets // AND no userId (anonymous/unauthenticated). The auth middleware // should handle authentication separately. if ( - roles.length === 0 && + positions.length === 0 && explicitPermissionSets.length === 0 && !opCtx.context?.userId ) { @@ -556,7 +566,7 @@ export class SecurityPlugin implements Plugin { { operation: opCtx.operation, object: opCtx.object, - roles, + positions, permissionSets: explicitPermissionSets, requiredPermissions: required, missingPermissions: missing, @@ -578,8 +588,8 @@ export class SecurityPlugin implements Plugin { if (!allowed) { throw new PermissionDeniedError( `[Security] Access denied: operation '${opCtx.operation}' on object '${opCtx.object}' ` + - `is not permitted for roles [${roles.join(', ')}]`, - { operation: opCtx.operation, object: opCtx.object, roles, permissionSets: explicitPermissionSets }, + `is not permitted for positions [${positions.join(', ')}]`, + { operation: opCtx.operation, object: opCtx.object, positions, permissionSets: explicitPermissionSets }, ); } } @@ -659,7 +669,7 @@ export class SecurityPlugin implements Plugin { { operation: opCtx.operation, object: opCtx.object, - roles, + positions, permissionSets: explicitPermissionSets, recordId: targetId, }, @@ -729,7 +739,7 @@ export class SecurityPlugin implements Plugin { { operation: opCtx.operation, object: opCtx.object, - roles, + positions, permissionSets: explicitPermissionSets, forbiddenFields: forbidden, }, @@ -821,7 +831,7 @@ export class SecurityPlugin implements Plugin { ); throw new PermissionDeniedError( `[Security] Access denied: the ${opCtx.operation} would violate a row-level CHECK on '${opCtx.object}'`, - { operation: opCtx.operation, object: opCtx.object, roles, permissionSets: explicitPermissionSets }, + { operation: opCtx.operation, object: opCtx.object, positions, permissionSets: explicitPermissionSets }, ); } } @@ -905,12 +915,12 @@ export class SecurityPlugin implements Plugin { const report = await bootstrapPlatformAdmin(ql, this.bootstrapPermissionSets, { logger: ctx.logger, }); - // [ADR-0057 D6 / #2077] Seed stack-declared roles into sys_position so they - // stop being decorative (role→permission-set resolution + recipients). + // [ADR-0057 D6 / #2077] Seed stack-declared positions into sys_position so they + // stop being decorative (position→permission-set resolution + recipients). try { await bootstrapDeclaredPositions(ql, this.metadata, { logger: ctx.logger }); } catch (e) { - ctx.logger.warn('[security] declared-role seeding failed', { error: (e as Error).message }); + ctx.logger.warn('[security] declared-position seeding failed', { error: (e as Error).message }); } // [ADR-0086 D5] Seed stack-declared permission sets into // sys_permission_set with package provenance (managed_by:'package' + @@ -1024,7 +1034,7 @@ export class SecurityPlugin implements Plugin { } catch (e) { ctx.logger.warn('[security] permission publish-materializer registration failed', { error: (e as Error).message }); } - // [ADR-0068 D2] Seed the framework's reserved built-in identity roles + // [ADR-0068 D2] Seed the framework's reserved built-in identity positions // (platform_admin / org_*) so the role catalog is self-describing. try { await bootstrapBuiltinRoles(ql, { logger: ctx.logger }); @@ -1166,7 +1176,7 @@ export class SecurityPlugin implements Plugin { * * Returns: * - `undefined` → no scope applies (system context, or an unauthenticated - * request with no userId/roles/permissions — authn is gated elsewhere). + * request with no userId/positions/permissions — authn is gated elsewhere). * - a `FilterCondition` → AND it into the object's scan (the join's `ON`/ * `WHERE` for analytics; the where clause for a plain find). * - the `RLS_DENY_FILTER` sentinel → policies applied but none compiled, or @@ -1246,12 +1256,12 @@ export class SecurityPlugin implements Plugin { ): Promise | null | undefined> { // System operations bypass scoping (mirrors the middleware's isSystem skip). if (context?.isSystem) return undefined; - const roles = context?.positions ?? []; + const positions = context?.positions ?? []; const explicit = context?.permissions ?? []; - // Unauthenticated + role-less + permission-less → no scope (the auth layer, - // not RLS, gates anonymous access; the analytics REST endpoint already 401s - // without a token). Mirrors the middleware's early `return next()`. - if (roles.length === 0 && explicit.length === 0 && !context?.userId) { + // Unauthenticated + position-less + permission-less → no scope (the auth + // layer, not RLS, gates anonymous access; the analytics REST endpoint + // already 401s without a token). Mirrors the middleware's early `return next()`. + if (positions.length === 0 && explicit.length === 0 && !context?.userId) { return undefined; } try { @@ -1271,7 +1281,7 @@ export class SecurityPlugin implements Plugin { } /** - * Resolve the effective permission sets for an execution context — roles + + * Resolve the effective permission sets for an execution context — positions + * explicit permission sets, with the configured baseline applied both as an * implicit request (when none were named) and as a post-resolution fallback * (when named ones resolved to nothing). Shared by the engine middleware and @@ -1302,7 +1312,7 @@ export class SecurityPlugin implements Plugin { { logger: this.logger }, ); // Post-resolution fallback — closes the fail-open hole where a populated - // `roles` array maps to no permission set yet (no sys_position binding), which + // `positions` array maps to no permission set yet (no sys_position binding), which // would otherwise skip RLS entirely and expose every tenant's data. if ( permissionSets.length === 0 && diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index 5034b68a72..ea40ab241b 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -1294,6 +1294,63 @@ export class RestServer { return pending; } + /** + * [ADR-0046 §6.7] The audience-evaluation view of the caller for book/doc + * gating. `permissionSets` resolves through the security service's + * `resolvePermissionSetNames` — the SAME resolution as data-plane + * enforcement (positions expanded, additive baseline), so the docs gate + * can never drift from it. `permissionSets` stays undefined when the + * service is absent or resolution fails; `audienceAllows` then DENIES + * permission-set-gated audiences (fail closed, ADR-0049). Resolution is + * skipped unless `needPermissionSets` — callers pass true only when a + * `{ permissionSet }` audience is actually in play. + */ + private async resolveAudienceCaller( + environmentId: string | undefined, + req: any, + opts: { needPermissionSets: boolean }, + ): Promise<{ authenticated: boolean; permissionSets?: string[] }> { + const ctx = await this.resolveExecCtx(environmentId, req).catch(() => undefined); + const authenticated = !!ctx?.userId; + if (!authenticated || !opts.needPermissionSets || !this.securityServiceProvider) { + return { authenticated }; + } + try { + const svc = await this.securityServiceProvider(environmentId); + if (!svc || typeof svc.resolvePermissionSetNames !== 'function') return { authenticated }; + const names = await svc.resolvePermissionSetNames(ctx); + return { authenticated, permissionSets: Array.isArray(names) ? names : [] }; + } catch { + return { authenticated }; // unresolved holdings → gated audiences deny + } + } + + /** Whether any of these books carries a `{ permissionSet }` audience. */ + private static anyPermissionSetAudience(books: readonly any[]): boolean { + return books.some( + (b) => b && typeof b === 'object' && b.audience && typeof b.audience === 'object' + && typeof b.audience.permissionSet === 'string', + ); + } + + /** Coerce a getMetaItems result (array | {items}) into an array. */ + private static metaItemsArray(raw: unknown): any[] { + if (Array.isArray(raw)) return raw; + if (raw && typeof raw === 'object' && Array.isArray((raw as any).items)) return (raw as any).items; + return []; + } + + /** Fetch every book of the environment, shaped for the audience resolver. */ + private async fetchAudienceBooks(p: any, environmentId: string | undefined): Promise { + const raw = await p.getMetaItems({ + type: 'book', + ...(environmentId ? { environmentId } : {}), + } as any).catch(() => []); + return RestServer.metaItemsArray(raw).map((b: any) => + b && typeof b === 'object' ? { ...b, packageId: b._packageId } : b, + ); + } + /** Heavy path behind `resolveExecCtx` — resolve identity + RBAC/RLS + localization. */ private async computeExecCtx(environmentId: string | undefined, req: any): Promise { try { @@ -2425,6 +2482,67 @@ export class RestServer { } } + // ADR-0046 §6.7 — book list is audience-filtered: anonymous + // callers see only `public` books; `{ permissionSet }`-gated + // books require the caller to hold the named set (resolved + // through the security service; unresolvable → fail closed). + if (req.params.type === 'book') { + const raw = visible as unknown; + const list = RestServer.metaItemsArray(raw); + if (list.length > 0) { + const { audienceAllows } = await import('@objectstack/spec/system'); + const caller = await this.resolveAudienceCaller(environmentId, req, { + needPermissionSets: RestServer.anyPermissionSetAudience(list), + }); + const filtered = list.filter((b: any) => + b && typeof b === 'object' && audienceAllows((b as any).audience, caller)); + visible = Array.isArray(raw) ? filtered : { ...(raw as any), items: filtered }; + } + } + + // ADR-0046 §6.7 — doc list is audience-filtered by each + // doc's EFFECTIVE audience (union over the books that + // claim it; unclaimed docs default to `org`). Runs on the + // raw items (before locale collapse) so `_packageId` + // provenance is still present for membership scoping. + if (req.params.type === 'doc') { + const raw = visible as unknown; + const list = RestServer.metaItemsArray(raw); + if (list.length > 0) { + const { audienceAllows, docAudienceAllows, resolveDocAudiences } = + await import('@objectstack/spec/system'); + const books = await this.fetchAudienceBooks(p, environmentId); + const caller = await this.resolveAudienceCaller(environmentId, req, { + needPermissionSets: RestServer.anyPermissionSetAudience(books), + }); + let filtered: any[]; + if (caller.authenticated && !RestServer.anyPermissionSetAudience(books)) { + // Fast path: with no gated book anywhere, every + // effective audience admits an authenticated caller. + filtered = list; + } else { + const corpus = list + .filter((d: any) => d && typeof d === 'object') + .map((d: any) => ({ + name: d.name, + group: d.group, + tags: d.tags, + order: d.order, + packageId: d._packageId, + })); + const audiences = resolveDocAudiences(books as any, corpus); + filtered = list.filter((d: any) => { + if (!d || typeof d !== 'object') return false; + const eff = audiences.get(d.name); + return eff + ? docAudienceAllows(eff, caller) + : audienceAllows('org', caller); + }); + } + visible = Array.isArray(raw) ? filtered : { ...(raw as any), items: filtered }; + } + } + // ADR-0046 i18n: collapse each doc to the request // locale (localized label/description, `translations` // map dropped) before the content-strip step below. @@ -2530,7 +2648,7 @@ export class RestServer { const prot = await this.resolveProtocol(environmentId, req); const locale = this.extractLocale(req); const packageId = req.query?.package || undefined; - const { resolveBookTree, deriveImplicitPackageBook, isPublicAudience, resolveDocLocale } = + const { resolveBookTree, deriveImplicitPackageBook, audienceAllows, resolveDocAudiences, docAudienceAllows, resolveDocLocale } = await import('@objectstack/spec/system'); const norm = (raw: any): any[] => @@ -2547,10 +2665,21 @@ export class RestServer { book = deriveImplicitPackageBook(req.params.name, req.params.name); } - // §6.7 — anonymous callers only get `public` books. - const ctx = await this.resolveExecCtx(environmentId, req).catch(() => undefined); - if (!ctx?.userId && !isPublicAudience((book as any).audience)) { - sendError(res, { code: 'unauthorized', message: 'This documentation requires sign-in', status: 401 }); + // §6.7 — the book's audience gates the whole tree: + // anonymous → `public` only; `{ permissionSet }` → + // the caller must hold the named set (fail closed + // when holdings cannot be resolved, ADR-0049). + const audienceBooks = books.map((b: any) => + b && typeof b === 'object' ? { ...b, packageId: b._packageId } : b); + const caller = await this.resolveAudienceCaller(environmentId, req, { + needPermissionSets: RestServer.anyPermissionSetAudience([book, ...audienceBooks]), + }); + if (!audienceAllows((book as any).audience, caller)) { + if (!caller.authenticated) { + sendError(res, { code: 'unauthorized', message: 'This documentation requires sign-in', status: 401 }); + } else { + sendError(res, { code: 'PERMISSION_DENIED', message: 'This documentation is limited to holders of a permission set you do not have', status: 403 }); + } return; } @@ -2571,6 +2700,26 @@ export class RestServer { })); const tree = resolveBookTree(book as any, docs, (book as any)._packageId); + + // §6.7 — the tree's ENTRIES are additionally filtered by + // each doc's effective audience (union over claiming + // books, unclaimed → org), so an anonymous reader of a + // public book never sees nav entries that would 401 on + // fetch, and gated-only docs stay out of non-holders' + // trees. The book gate above passed, so this only ever + // narrows further for anonymous / non-holder callers. + const gatedTreePossible = !caller.authenticated + || RestServer.anyPermissionSetAudience(audienceBooks); + if (gatedTreePossible) { + const audiences = resolveDocAudiences(audienceBooks as any, docs); + tree.groups = tree.groups + .map((g: any) => ({ + ...g, + entries: g.entries.filter((e: any) => + !e.doc || docAudienceAllows(audiences.get(e.doc), caller)), + })) + .filter((g: any) => g.entries.some((e: any) => e.doc || e.href)); + } res.json(tree); } catch (error: any) { logError("[REST] Unhandled error:", error); @@ -2638,7 +2787,10 @@ export class RestServer { // `getMetaItem(type, name, packageId)` path runs. const packageScoped = typeof req.query?.package === 'string' && req.query.package.length > 0; - if (metadata.enableCache && p.getMetaItemCached && !isAppType && !isDraftRead && !previewDrafts && !packageScoped && req.params.type !== 'doc') { + // `doc` and `book` bypass the shared cache: their §6.7 + // audience gate is per-caller, and a shared ETag would + // leak gated content across viewers. + if (metadata.enableCache && p.getMetaItemCached && !isAppType && !isDraftRead && !previewDrafts && !packageScoped && req.params.type !== 'doc' && req.params.type !== 'book') { const cacheRequest = { ifNoneMatch: req.headers['if-none-match'] as string, ifModifiedSince: req.headers['if-modified-since'] as string, @@ -2739,6 +2891,57 @@ export class RestServer { if (serviceGate) visible = this.filterDashboardForUser(visible, serviceGate); } + // ADR-0046 §6.7 — audience gate on single-item reads. + // A `book` is gated by its own audience; a `doc` by its + // EFFECTIVE audience (union over the books that claim + // it, unclaimed → org). 401 for anonymous, 403 for an + // authenticated non-holder; fail closed when holdings + // cannot be resolved (ADR-0049). + if ((req.params.type === 'book' || req.params.type === 'doc') && visible) { + const { audienceAllows, docAudienceAllows, resolveDocAudiences } = + await import('@objectstack/spec/system'); + const target = isMetaEnvelope(visible) ? (visible as any).item : visible; + let caller: { authenticated: boolean; permissionSets?: string[] }; + let allowed: boolean; + if (req.params.type === 'book') { + caller = await this.resolveAudienceCaller(environmentId, req, { + needPermissionSets: RestServer.anyPermissionSetAudience([target]), + }); + allowed = audienceAllows(target?.audience, caller); + } else { + const books = await this.fetchAudienceBooks(p, environmentId); + caller = await this.resolveAudienceCaller(environmentId, req, { + needPermissionSets: RestServer.anyPermissionSetAudience(books), + }); + if (caller.authenticated && !RestServer.anyPermissionSetAudience(books)) { + allowed = true; // no gated book anywhere → org suffices + } else { + const corpus = RestServer.metaItemsArray(await p.getMetaItems({ + type: 'doc', + ...(environmentId ? { environmentId } : {}), + } as any).catch(() => [])) + .filter((d: any) => d && typeof d === 'object') + .map((d: any) => ({ + name: d.name, + group: d.group, + tags: d.tags, + order: d.order, + packageId: d._packageId, + })); + const audiences = resolveDocAudiences(books as any, corpus); + allowed = docAudienceAllows(audiences.get(target?.name), caller); + } + } + if (!allowed) { + if (!caller.authenticated) { + sendError(res, { code: 'unauthorized', message: 'This documentation requires sign-in', status: 401 }); + } else { + sendError(res, { code: 'PERMISSION_DENIED', message: 'This documentation is limited to holders of a permission set you do not have', status: 403 }); + } + return; + } + } + // ADR-0046 i18n: collapse the doc to the request // locale (label/description/content) and drop the // `translations` map so consumers get one body. diff --git a/packages/rest/src/rest.test.ts b/packages/rest/src/rest.test.ts index 6716c703bc..cc50af992b 100644 --- a/packages/rest/src/rest.test.ts +++ b/packages/rest/src/rest.test.ts @@ -664,6 +664,154 @@ describe('RestServer', () => { }); }); + describe('meta book/doc audience gating (ADR-0046 §6.7 / ADR-0090)', () => { + function getMetaRoute(rest: any, method: string, path: string) { + return rest.getRoutes().find((r: any) => r.method === method && r.path === path); + } + const mockRes = () => ({ + json: vi.fn(), + status: vi.fn().mockReturnThis(), + header: vi.fn(), + send: vi.fn(), + }); + const BOOKS = [ + { name: 'pub_book', audience: 'public', groups: [{ key: 'g', label: 'G', include: 'pub_*' }], _packageId: 'pkg' }, + { name: 'org_book', groups: [{ key: 'g', label: 'G', include: 'org_*' }], _packageId: 'pkg' }, + { name: 'gated_book', audience: { permissionSet: 'crm_admin' }, groups: [{ key: 'g', label: 'G', include: 'gated_*' }], _packageId: 'pkg' }, + ]; + const DOCS = [ + { name: 'pub_intro', _packageId: 'pkg' }, + { name: 'org_intro', _packageId: 'pkg' }, + { name: 'gated_setup', _packageId: 'pkg' }, + ]; + const metaByType = (books: any[] = BOOKS, docs: any[] = DOCS) => + vi.fn().mockImplementation(async ({ type }: any) => + type === 'book' ? books : type === 'doc' ? docs : []); + + it('tree: anonymous caller is 401ed off a non-public book', async () => { + protocol.getMetaItems = metaByType(); + const rest = new RestServer(server as any, protocol as any, ANON_API as any); + rest.registerRoutes(); + const route = getMetaRoute(rest, 'GET', '/api/v1/meta/book/:name/tree'); + const res = mockRes(); + await route!.handler({ params: { name: 'org_book' }, query: {}, headers: {} }, res); + expect(res.status).toHaveBeenCalledWith(401); + }); + + it('tree: anonymous caller gets a public book, with non-public docs filtered OUT of the tree', async () => { + protocol.getMetaItems = metaByType(); + const rest = new RestServer(server as any, protocol as any, ANON_API as any); + rest.registerRoutes(); + const route = getMetaRoute(rest, 'GET', '/api/v1/meta/book/:name/tree'); + const res = mockRes(); + await route!.handler({ params: { name: 'pub_book' }, query: {}, headers: {} }, res); + expect(res.status).not.toHaveBeenCalledWith(401); + const tree = res.json.mock.calls.at(-1)![0]; + const docsInTree = tree.groups.flatMap((g: any) => g.entries.map((e: any) => e.doc)); + expect(docsInTree).toContain('pub_intro'); + // org_intro / gated_setup are claimed by other books; the orphan pass + // would render them, but the audience filter must drop them for anon. + expect(docsInTree).not.toContain('org_intro'); + expect(docsInTree).not.toContain('gated_setup'); + }); + + it('tree: authenticated non-holder is 403ed off a permission-set-gated book (fail closed without security service)', async () => { + protocol.getMetaItems = metaByType(); + const rest = new RestServer(server as any, protocol as any, ANON_API as any); + (rest as any).resolveExecCtx = vi.fn().mockResolvedValue({ userId: 'u1' }); + rest.registerRoutes(); + const route = getMetaRoute(rest, 'GET', '/api/v1/meta/book/:name/tree'); + const res = mockRes(); + await route!.handler({ params: { name: 'gated_book' }, query: {}, headers: {} }, res); + expect(res.status).toHaveBeenCalledWith(403); + }); + + it('tree: a holder resolved through the security service opens the gated book', async () => { + protocol.getMetaItems = metaByType(); + const rest = new RestServer(server as any, protocol as any, ANON_API as any); + (rest as any).resolveExecCtx = vi.fn().mockResolvedValue({ userId: 'u1' }); + (rest as any).securityServiceProvider = async () => ({ + resolvePermissionSetNames: async () => ['member_default', 'crm_admin'], + }); + rest.registerRoutes(); + const route = getMetaRoute(rest, 'GET', '/api/v1/meta/book/:name/tree'); + const res = mockRes(); + await route!.handler({ params: { name: 'gated_book' }, query: {}, headers: {} }, res); + expect(res.status).not.toHaveBeenCalledWith(401); + expect(res.status).not.toHaveBeenCalledWith(403); + const tree = res.json.mock.calls.at(-1)![0]; + const docsInTree = tree.groups.flatMap((g: any) => g.entries.map((e: any) => e.doc)); + expect(docsInTree).toContain('gated_setup'); + }); + + it('list: anonymous /meta/book returns only public books', async () => { + protocol.getMetaItems = metaByType(); + const rest = new RestServer(server as any, protocol as any, ANON_API as any); + rest.registerRoutes(); + const route = getMetaRoute(rest, 'GET', '/api/v1/meta/:type'); + const res = mockRes(); + await route!.handler({ params: { type: 'book' }, query: {}, headers: {} }, res); + const body = res.json.mock.calls.at(-1)![0]; + expect(body.map((b: any) => b.name)).toEqual(['pub_book']); + }); + + it('list: anonymous /meta/doc returns only docs whose effective audience is public', async () => { + protocol.getMetaItems = metaByType(); + const rest = new RestServer(server as any, protocol as any, ANON_API as any); + rest.registerRoutes(); + const route = getMetaRoute(rest, 'GET', '/api/v1/meta/:type'); + const res = mockRes(); + await route!.handler({ params: { type: 'doc' }, query: {}, headers: {} }, res); + const body = res.json.mock.calls.at(-1)![0]; + expect(body.map((d: any) => d.name)).toEqual(['pub_intro']); + }); + + it('list: authenticated member sees org + public docs but not gated-only docs', async () => { + protocol.getMetaItems = metaByType(); + const rest = new RestServer(server as any, protocol as any, ANON_API as any); + (rest as any).resolveExecCtx = vi.fn().mockResolvedValue({ userId: 'u1' }); + (rest as any).securityServiceProvider = async () => ({ + resolvePermissionSetNames: async () => ['member_default'], + }); + rest.registerRoutes(); + const route = getMetaRoute(rest, 'GET', '/api/v1/meta/:type'); + const res = mockRes(); + await route!.handler({ params: { type: 'doc' }, query: {}, headers: {} }, res); + const body = res.json.mock.calls.at(-1)![0]; + expect(body.map((d: any) => d.name).sort()).toEqual(['org_intro', 'pub_intro']); + }); + + it('item: anonymous GET of an org-only doc is 401; of a public-claimed doc succeeds', async () => { + protocol.getMetaItems = metaByType(); + protocol.getMetaItem = vi.fn().mockImplementation(async ({ name }: any) => + DOCS.find((d) => d.name === name)); + const rest = new RestServer(server as any, protocol as any, ANON_API as any); + rest.registerRoutes(); + const route = getMetaRoute(rest, 'GET', '/api/v1/meta/:type/:name'); + const resDenied = mockRes(); + await route!.handler({ params: { type: 'doc', name: 'org_intro' }, query: {}, headers: {} }, resDenied); + expect(resDenied.status).toHaveBeenCalledWith(401); + const resOk = mockRes(); + await route!.handler({ params: { type: 'doc', name: 'pub_intro' }, query: {}, headers: {} }, resOk); + expect(resOk.status).not.toHaveBeenCalledWith(401); + expect(resOk.json.mock.calls.at(-1)![0]).toMatchObject({ name: 'pub_intro' }); + }); + + it('item: gated book GET bypasses the shared cache and 403s a non-holder', async () => { + protocol.getMetaItemCached = vi.fn(); + protocol.getMetaItems = metaByType(); + protocol.getMetaItem = vi.fn().mockResolvedValue(BOOKS[2]); + const rest = new RestServer(server as any, protocol as any, ANON_API as any); + (rest as any).resolveExecCtx = vi.fn().mockResolvedValue({ userId: 'u1' }); + rest.registerRoutes(); + const route = getMetaRoute(rest, 'GET', '/api/v1/meta/:type/:name'); + const res = mockRes(); + await route!.handler({ params: { type: 'book', name: 'gated_book' }, query: {}, headers: {} }, res); + expect(protocol.getMetaItemCached).not.toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(403); + }); + }); + describe('meta single-item Cache-Control header (cached path)', () => { function getMetaItemRoute(rest: any) { return rest diff --git a/packages/spec/api-surface.json b/packages/spec/api-surface.json index 95678f765b..7ae83ed875 100644 --- a/packages/spec/api-surface.json +++ b/packages/spec/api-surface.json @@ -517,6 +517,8 @@ "AppManifestSchema (const)", "AppTranslationBundle (type)", "AppTranslationBundleSchema (const)", + "AudienceBook (interface)", + "AudienceCaller (interface)", "AuditConfig (type)", "AuditConfigSchema (const)", "AuditEvent (type)", @@ -1180,12 +1182,14 @@ "WorkerConfigSchema (const)", "WorkerStats (type)", "WorkerStatsSchema (const)", + "audienceAllows (function)", "azureBlobStorageExample (const)", "defineBook (function)", "defineEmailTemplateDefinition (function)", "defineJob (function)", "defineTranslationBundle (function)", "deriveImplicitPackageBook (function)", + "docAudienceAllows (function)", "emailTemplateForm (const)", "gcsStorageExample (const)", "isPublicAudience (function)", @@ -1193,7 +1197,9 @@ "resolveActionConfirm (function)", "resolveActionLabel (function)", "resolveActionSuccess (function)", + "resolveBookClaimedDocs (function)", "resolveBookTree (function)", + "resolveDocAudiences (function)", "resolveDocLocale (function)", "resolveMetadataFormLabels (function)", "resolveMetadataTypeDescription (function)", diff --git a/packages/spec/src/identity/position.test.ts b/packages/spec/src/identity/position.test.ts index 2884b6cd3c..a10150f5c7 100644 --- a/packages/spec/src/identity/position.test.ts +++ b/packages/spec/src/identity/position.test.ts @@ -3,62 +3,62 @@ import { PositionSchema, type Position } from './position.zod'; describe('PositionSchema', () => { describe('Basic Properties', () => { - it('should accept minimal role', () => { - const role: Position = { + it('should accept minimal position', () => { + const position: Position = { name: 'ceo', label: 'CEO', }; - expect(() => PositionSchema.parse(role)).not.toThrow(); + expect(() => PositionSchema.parse(position)).not.toThrow(); }); - it('should enforce snake_case for role name', () => { + it('should enforce snake_case for position name', () => { const validNames = ['ceo', 'vp_sales', 'sales_manager', 'account_exec']; validNames.forEach(name => { expect(() => PositionSchema.parse({ name, label: 'Test' })).not.toThrow(); }); - const invalidNames = ['CEO', 'VP-Sales', 'salesManager', '123role', '_internal']; + const invalidNames = ['CEO', 'VP-Sales', 'salesManager', '123position', '_internal']; invalidNames.forEach(name => { expect(() => PositionSchema.parse({ name, label: 'Test' })).toThrow(); }); }); - it('should accept role with description', () => { - const role: Position = { + it('should accept position with description', () => { + const position: Position = { name: 'sales_director', label: 'Sales Director', description: 'Oversees all sales operations', }; - expect(() => PositionSchema.parse(role)).not.toThrow(); + expect(() => PositionSchema.parse(position)).not.toThrow(); }); }); describe('Hierarchy', () => { - it('should accept role without parent (top level)', () => { - const role: Position = { + it('should accept position without parent (top level)', () => { + const position: Position = { name: 'ceo', label: 'Chief Executive Officer', }; - expect(() => PositionSchema.parse(role)).not.toThrow(); + expect(() => PositionSchema.parse(position)).not.toThrow(); }); - it('should accept role with parent', () => { - const role: Position = { + it('should accept position with parent', () => { + const position: Position = { name: 'vp_sales', label: 'VP of Sales', parent: 'ceo', }; - expect(() => PositionSchema.parse(role)).not.toThrow(); + expect(() => PositionSchema.parse(position)).not.toThrow(); }); }); - describe('Real-World Role Hierarchy Examples', () => { + describe('Real-World Position Examples', () => { it('should accept complete sales organization hierarchy', () => { - const roles: Position[] = [ + const positions: Position[] = [ { name: 'ceo', label: 'Chief Executive Officer', @@ -96,13 +96,13 @@ describe('PositionSchema', () => { }, ]; - roles.forEach(role => { - expect(() => PositionSchema.parse(role)).not.toThrow(); + positions.forEach(position => { + expect(() => PositionSchema.parse(position)).not.toThrow(); }); }); it('should accept service organization hierarchy', () => { - const roles: Position[] = [ + const positions: Position[] = [ { name: 'vp_customer_success', label: 'VP of Customer Success', @@ -124,13 +124,13 @@ describe('PositionSchema', () => { }, ]; - roles.forEach(role => { - expect(() => PositionSchema.parse(role)).not.toThrow(); + positions.forEach(position => { + expect(() => PositionSchema.parse(position)).not.toThrow(); }); }); it('should accept product organization hierarchy', () => { - const roles: Position[] = [ + const positions: Position[] = [ { name: 'cto', label: 'Chief Technology Officer', @@ -163,13 +163,13 @@ describe('PositionSchema', () => { }, ]; - roles.forEach(role => { - expect(() => PositionSchema.parse(role)).not.toThrow(); + positions.forEach(position => { + expect(() => PositionSchema.parse(position)).not.toThrow(); }); }); it('should accept matrix organization with multiple reporting lines', () => { - const roles: Position[] = [ + const positions: Position[] = [ { name: 'ceo', label: 'CEO', @@ -201,13 +201,13 @@ describe('PositionSchema', () => { }, ]; - roles.forEach(role => { - expect(() => PositionSchema.parse(role)).not.toThrow(); + positions.forEach(position => { + expect(() => PositionSchema.parse(position)).not.toThrow(); }); }); it('should accept flat organization', () => { - const roles: Position[] = [ + const positions: Position[] = [ { name: 'founder', label: 'Founder', @@ -219,8 +219,8 @@ describe('PositionSchema', () => { }, ]; - roles.forEach(role => { - expect(() => PositionSchema.parse(role)).not.toThrow(); + positions.forEach(position => { + expect(() => PositionSchema.parse(position)).not.toThrow(); }); }); }); diff --git a/packages/spec/src/system/audit.zod.ts b/packages/spec/src/system/audit.zod.ts index 6fa9f85452..647a31ff73 100644 --- a/packages/spec/src/system/audit.zod.ts +++ b/packages/spec/src/system/audit.zod.ts @@ -139,7 +139,7 @@ export type AuditEventActor = z.infer; */ export const AuditEventTargetSchema = lazySchema(() => z.object({ /** - * Target type (e.g., 'object', 'record', 'user', 'role', 'config') + * Target type (e.g., 'object', 'record', 'user', 'position', 'config') */ type: z.string().describe('Target type'), diff --git a/packages/spec/src/system/book.test.ts b/packages/spec/src/system/book.test.ts index 60d47faced..1eb7a13b55 100644 --- a/packages/spec/src/system/book.test.ts +++ b/packages/spec/src/system/book.test.ts @@ -6,6 +6,10 @@ import { resolveBookTree, deriveImplicitPackageBook, isPublicAudience, + audienceAllows, + resolveBookClaimedDocs, + resolveDocAudiences, + docAudienceAllows, type Book, type ResolverDoc, } from './book.zod'; @@ -158,3 +162,88 @@ describe('deriveImplicitPackageBook + audience', () => { expect(isPublicAudience(undefined)).toBe(false); }); }); + +describe('audienceAllows (ADR-0046 §6.7, ADR-0090 vocabulary)', () => { + const anon = { authenticated: false }; + const member = { authenticated: true, permissionSets: ['member_default'] }; + const admin = { authenticated: true, permissionSets: ['member_default', 'crm_admin'] }; + const unresolved = { authenticated: true }; // security resolution unavailable + + it('public allows everyone, including anonymous', () => { + for (const caller of [anon, member, admin, unresolved]) { + expect(audienceAllows('public', caller)).toBe(true); + } + }); + it('org (and unset) allows any authenticated principal, never anonymous', () => { + for (const audience of ['org', undefined] as const) { + expect(audienceAllows(audience, anon)).toBe(false); + expect(audienceAllows(audience, member)).toBe(true); + expect(audienceAllows(audience, unresolved)).toBe(true); + } + }); + it('permission-set gate requires holding the named set', () => { + const gated = { permissionSet: 'crm_admin' }; + expect(audienceAllows(gated, admin)).toBe(true); + expect(audienceAllows(gated, member)).toBe(false); + expect(audienceAllows(gated, anon)).toBe(false); + }); + it('permission-set gate FAILS CLOSED when holdings are unknown (ADR-0049)', () => { + expect(audienceAllows({ permissionSet: 'crm_admin' }, unresolved)).toBe(false); + }); +}); + +describe('resolveDocAudiences — union over claiming books (§6.7)', () => { + const corpus: ResolverDoc[] = [ + { name: 'crm_guide_intro', packageId: 'crm' }, + { name: 'crm_guide_flows', packageId: 'crm' }, + { name: 'crm_admin_setup', packageId: 'crm' }, + { name: 'crm_unclaimed', packageId: 'crm' }, + ]; + const publicBook: Book & { packageId?: string } = { + name: 'crm_guide', + audience: 'public', + groups: [{ key: 'g', label: 'Guide', include: 'crm_guide_*' }], + packageId: 'crm', + }; + const adminBook: Book & { packageId?: string } = { + name: 'crm_admin_guide', + audience: { permissionSet: 'crm_admin' }, + groups: [{ key: 'a', label: 'Admin', include: 'crm_admin_*' }], + packageId: 'crm', + }; + + it('claimed docs union the audiences of every claiming book', () => { + const audiences = resolveDocAudiences([publicBook, adminBook], corpus); + expect(audiences.get('crm_guide_intro')).toEqual(['public']); + expect(audiences.get('crm_admin_setup')).toEqual([{ permissionSet: 'crm_admin' }]); + }); + it('a doc claimed by no book defaults to org — orphans do NOT ride along with public books', () => { + const audiences = resolveDocAudiences([publicBook, adminBook], corpus); + expect(audiences.get('crm_unclaimed')).toEqual(['org']); + // The tree view still renders the orphan (nothing is dropped)… + const tree = resolveBookTree(publicBook, corpus, 'crm'); + expect(tree.groups.at(-1)!.entries.some((e) => e.doc === 'crm_unclaimed')).toBe(true); + // …but the CLAIM set excludes it, so it never inherits `public`. + expect(resolveBookClaimedDocs(publicBook, corpus, 'crm').has('crm_unclaimed')).toBe(false); + }); + it('docAudienceAllows applies the union: any allowing book grants access', () => { + const both: Book & { packageId?: string } = { + ...adminBook, + name: 'crm_all', + audience: 'org', + groups: [{ key: 'all', label: 'All', include: 'crm_admin_*' }], + }; + const audiences = resolveDocAudiences([adminBook, both], corpus); + const member = { authenticated: true, permissionSets: ['member_default'] }; + // Reachable via the org book even without crm_admin. + expect(docAudienceAllows(audiences.get('crm_admin_setup'), member)).toBe(true); + // Gated-only doc stays gated. + const gatedOnly = resolveDocAudiences([adminBook], corpus); + expect(docAudienceAllows(gatedOnly.get('crm_admin_setup'), member)).toBe(false); + expect(docAudienceAllows(gatedOnly.get('crm_admin_setup'), { authenticated: false })).toBe(false); + }); + it('an unknown doc (absent from the corpus map) gets the org default', () => { + expect(docAudienceAllows(undefined, { authenticated: true })).toBe(true); + expect(docAudienceAllows(undefined, { authenticated: false })).toBe(false); + }); +}); diff --git a/packages/spec/src/system/book.zod.ts b/packages/spec/src/system/book.zod.ts index 8125a5fb85..87ad2cdbc4 100644 --- a/packages/spec/src/system/book.zod.ts +++ b/packages/spec/src/system/book.zod.ts @@ -321,3 +321,98 @@ export function deriveImplicitPackageBook(packageId: string, label?: string): Bo export function isPublicAudience(audience?: BookAudience): boolean { return audience === 'public'; } + +// --------------------------------------------------------------------------- +// Audience evaluation (ADR-0046 §6.7, vocabulary per ADR-0090) — pure helpers +// shared by the REST read layer so `/meta/book` and `/meta/doc` gate on ONE +// canonical semantics and can never drift from each other. +// --------------------------------------------------------------------------- + +/** What the read layer knows about the caller when evaluating an audience. */ +export interface AudienceCaller { + /** True when the request carries an authenticated principal. */ + authenticated: boolean; + /** + * Names of the permission sets the caller effectively holds (positions + * expanded, baseline included — the security plugin's resolution). + * Absent/undefined means "unknown" and permission-set-gated audiences + * DENY (fail closed, ADR-0049) — pass an empty array only when resolution + * genuinely returned nothing. + */ + permissionSets?: readonly string[]; +} + +/** + * Whether a caller may read content published under `audience`: + * `'public'` → always; `'org'` (or unset — §3.6 default) → any authenticated + * principal; `{ permissionSet }` → an authenticated principal that holds the + * named set. Unknown/unresolvable set holdings deny (fail closed). + */ +export function audienceAllows(audience: BookAudience | undefined, caller: AudienceCaller): boolean { + if (isPublicAudience(audience)) return true; + if (!caller.authenticated) return false; + if (audience === undefined || audience === 'org') return true; + if (typeof audience === 'object' && typeof audience.permissionSet === 'string') { + return Array.isArray(caller.permissionSets) && caller.permissionSets.includes(audience.permissionSet); + } + return false; // unknown future shape → fail closed +} + +/** + * Doc names a book CLAIMS — members placed by a group rule (`include`), + * an explicit `doc.group`, or a pinned `pages` override. Deliberately + * EXCLUDES the synthetic *Uncategorized* orphan group: orphans are a + * rendering convenience ("nothing is ever dropped" in the tree view), not + * an authored membership claim, and letting them ride along would leak + * every unclaimed doc of a package through any `public` book it ships. + */ +export function resolveBookClaimedDocs(book: Book, docs: ResolverDoc[], bookPackage?: string): Set { + const claimed = new Set(); + for (const group of resolveBookTree(book, docs, bookPackage).groups) { + if (group.key === UNCATEGORIZED_KEY) continue; + for (const entry of group.entries) { + if (entry.doc) claimed.add(entry.doc); + } + } + return claimed; +} + +/** A book plus the provenance the audience resolver needs. */ +export interface AudienceBook extends Book { + /** Owning package id (stamped `_packageId` by the metadata layer). */ + packageId?: string; +} + +/** + * Effective audience of every doc = the union over the books that claim it + * (ADR-0046 §6.7). A doc claimed by no authored book falls back to `'org'` — + * the §3.6 package-grant default (equivalently: the implicit per-package + * book, §6.4, whose audience is `'org'`). Exposure is therefore always an + * authored decision: nothing becomes `public` or permission-set-gated except + * through a book that explicitly claims it. + */ +export function resolveDocAudiences( + books: readonly AudienceBook[], + docs: ResolverDoc[], +): Map { + const audiences = new Map(); + for (const doc of docs) audiences.set(doc.name, []); + for (const book of books) { + const audience: BookAudience = book.audience ?? 'org'; + for (const name of resolveBookClaimedDocs(book, docs, book.packageId)) { + audiences.get(name)?.push(audience); + } + } + for (const [name, list] of audiences) { + if (list.length === 0) audiences.set(name, ['org']); + } + return audiences; +} + +/** Whether a caller may read a doc given its effective audiences (§6.7 union). */ +export function docAudienceAllows(audiences: readonly BookAudience[] | undefined, caller: AudienceCaller): boolean { + // A doc with no computed entry (not in the corpus handed to the resolver) + // gets the same 'org' default as an unclaimed doc. + if (!audiences || audiences.length === 0) return audienceAllows('org', caller); + return audiences.some((a) => audienceAllows(a, caller)); +} diff --git a/packages/spec/src/system/environment-artifact.zod.ts b/packages/spec/src/system/environment-artifact.zod.ts index 0fd69cd3d9..b237e77fa5 100644 --- a/packages/spec/src/system/environment-artifact.zod.ts +++ b/packages/spec/src/system/environment-artifact.zod.ts @@ -200,8 +200,11 @@ export const EnvironmentArtifactMetadataSchema = z skills: z.array(z.unknown()).optional(), permissions: z.array(z.unknown()).optional(), permissionSets: z.array(z.unknown()).optional(), - roles: z.array(z.unknown()).optional(), - profiles: z.array(z.unknown()).optional(), + // ADR-0090 D3/D2: `roles`/`profiles` categories retired with the concepts + // (stacks declare `positions`; the profile concept is gone). Old artifacts + // carrying them still parse via `passthrough()` — the keys are simply no + // longer part of the declared envelope. + positions: z.array(z.unknown()).optional(), translations: z.array(z.unknown()).optional(), datasources: z.array(z.unknown()).optional(), datasets: z.array(z.unknown()).optional(), diff --git a/packages/spec/src/system/metadata-form-registry.ts b/packages/spec/src/system/metadata-form-registry.ts index dcdc2784ea..17843b2ddc 100644 --- a/packages/spec/src/system/metadata-form-registry.ts +++ b/packages/spec/src/system/metadata-form-registry.ts @@ -72,7 +72,12 @@ export const METADATA_FORM_REGISTRY: Readonly> = Object view: viewForm, app: appForm, dashboard: dashboardForm, - role: positionForm, + // ADR-0090 D3/D2: the `role` and `profile` metadata kinds were retired + // in the P1 wave (positions replace roles; the profile concept is gone). + // The registry keys are the /meta type machine names, so the entries + // moved with them — a `role:`/`profile:` key here would be dead weight + // the extractor still emits translations for. + position: positionForm, action: actionForm, page: pageForm, agent: agentForm, @@ -80,7 +85,6 @@ export const METADATA_FORM_REGISTRY: Readonly> = Object skill: skillForm, flow: flowForm, permission: permissionForm, - profile: permissionForm, email_template: emailTemplateForm, }); diff --git a/scripts/check-doc-authoring.mjs b/scripts/check-doc-authoring.mjs index ba51eea827..8d122cfb2b 100644 --- a/scripts/check-doc-authoring.mjs +++ b/scripts/check-doc-authoring.mjs @@ -22,7 +22,7 @@ const SKIP_DIRS = new Set(['node_modules', '.git', 'dist', 'references']); const SKIP_FILES = new Set(['content/docs/ai/skills-reference.mdx']); const DOMAINS = [ - 'Datasource', 'Connector', 'Policy', 'SharingRule', 'Role', 'PermissionSet', + 'Datasource', 'Connector', 'Policy', 'SharingRule', 'Position', 'PermissionSet', 'EmailTemplateDefinition', 'Report', 'Webhook', 'ObjectExtension', 'Cube', 'Mapping', 'Theme', 'TranslationBundle', 'Page', 'Action', ].join('|'); diff --git a/scripts/check-role-word.mjs b/scripts/check-role-word.mjs new file mode 100644 index 0000000000..78277d6860 --- /dev/null +++ b/scripts/check-role-word.mjs @@ -0,0 +1,100 @@ +#!/usr/bin/env node +// check-role-word — ADR-0090 D3 vocabulary ratchet for hand-written docs. +// +// D3 makes "role" a reserved-forbidden word (capability = permission_set, +// distribution = position, hierarchy = business_unit). The publish-time lint +// (`security-role-word`, packages/lint) enforces this for AUTHORED METADATA; +// nothing enforced it for the repo's own documentation — which is how the +// pre-D3 copy in book.zod.ts ("role-gated") and content/docs survived the +// P1 rename wave (#2697 was identifier-driven). +// +// This is a RATCHET, not a ban-with-exceptions: existing occurrences are +// frozen in scripts/role-word-baseline.json (many are legitimate — the +// better-auth boundary, ARIA `role=` in samples, educational "formerly +// roles" mentions — and untangling them file-by-file is incremental work). +// The check fails when: +// • a file NOT in the baseline contains the word, or +// • a baselined file's count INCREASES, or +// • a baselined file's count DECREASED / file vanished (improvement!) — +// run with --update to ratchet the baseline down and commit it. +// +// node scripts/check-role-word.mjs [--update] +// +// Scope: content/docs (hand-written; references/ is generated from spec and +// excluded — the spec source is the fix site there) and skills/. File and +// directory NAMES count too (they become URLs). +import { readdirSync, readFileSync, writeFileSync, statSync, existsSync } from 'node:fs'; +import { join, relative } from 'node:path'; + +const ROOTS = ['content/docs', 'skills']; +const SKIP_DIRS = new Set(['node_modules', '.git', 'dist', 'references']); +const EXTENSIONS = new Set(['.mdx', '.md']); +const BASELINE_PATH = 'scripts/role-word-baseline.json'; +const WORD = /\brole(?:s)?\b/gi; + +const update = process.argv.includes('--update'); + +function walk(dir, out) { + for (const e of readdirSync(dir)) { + if (SKIP_DIRS.has(e)) continue; + const p = join(dir, e); + const s = statSync(p); + if (s.isDirectory()) walk(p, out); + else if ([...EXTENSIONS].some((x) => e.endsWith(x))) out.push(p); + } +} + +function countMatches(text) { + const m = text.match(WORD); + return m ? m.length : 0; +} + +const files = []; +for (const root of ROOTS) if (existsSync(root)) walk(root, files); + +const current = {}; +for (const f of files.sort()) { + const rel = relative('.', f).replace(/\\/g, '/'); + // File/dir names are URLs — a `role-*` slug is UI copy (counts once). + const nameHits = countMatches(rel); + const bodyHits = countMatches(readFileSync(f, 'utf8')); + const total = nameHits + bodyHits; + if (total > 0) current[rel] = total; +} + +if (update) { + writeFileSync(BASELINE_PATH, JSON.stringify(current, null, 2) + '\n'); + console.log(`role-word baseline updated: ${Object.keys(current).length} file(s).`); + process.exit(0); +} + +const baseline = existsSync(BASELINE_PATH) + ? JSON.parse(readFileSync(BASELINE_PATH, 'utf8')) + : {}; + +const errors = []; +for (const [file, count] of Object.entries(current)) { + const allowed = baseline[file]; + if (allowed === undefined) { + errors.push(`${file}: NEW use of the reserved word "role" (${count} occurrence(s)). ` + + `ADR-0090 D3: use permission_set / position / business_unit — or, for a genuine ` + + `boundary (better-auth, ARIA, quoted history), add it to ${BASELINE_PATH} with --update.`); + } else if (count > allowed) { + errors.push(`${file}: role-word count grew ${allowed} → ${count}. New occurrences are banned (ADR-0090 D3).`); + } +} +for (const [file, allowed] of Object.entries(baseline)) { + const now = current[file]; + if (now === undefined) { + errors.push(`${file}: baselined file is clean/gone (was ${allowed}) — ratchet DOWN: run \`node scripts/check-role-word.mjs --update\` and commit the baseline.`); + } else if (now < allowed) { + errors.push(`${file}: role-word count improved ${allowed} → ${now} — ratchet DOWN: run \`node scripts/check-role-word.mjs --update\` and commit the baseline.`); + } +} + +if (errors.length) { + console.error(`check-role-word: ${errors.length} problem(s)\n`); + for (const e of errors) console.error(' • ' + e); + process.exit(1); +} +console.log(`check-role-word: OK (${Object.keys(current).length} baselined file(s), no new occurrences).`); diff --git a/scripts/role-word-baseline.json b/scripts/role-word-baseline.json new file mode 100644 index 0000000000..f62b0dc576 --- /dev/null +++ b/scripts/role-word-baseline.json @@ -0,0 +1,47 @@ +{ + "content/docs/ai/agents.mdx": 5, + "content/docs/ai/index.mdx": 2, + "content/docs/api/error-catalog.mdx": 1, + "content/docs/api/wire-format.mdx": 1, + "content/docs/automation/approvals.mdx": 5, + "content/docs/automation/hooks.mdx": 1, + "content/docs/concepts/architecture.mdx": 4, + "content/docs/concepts/metadata-lifecycle.mdx": 1, + "content/docs/data-modeling/seed-data.mdx": 2, + "content/docs/deployment/migration-from-objectql.mdx": 2, + "content/docs/getting-started/cli.mdx": 1, + "content/docs/getting-started/common-patterns.mdx": 1, + "content/docs/getting-started/index.mdx": 1, + "content/docs/getting-started/quick-start.mdx": 1, + "content/docs/kernel/contracts/auth-service.mdx": 4, + "content/docs/kernel/contracts/data-engine.mdx": 1, + "content/docs/kernel/contracts/metadata-service.mdx": 1, + "content/docs/kernel/events.mdx": 1, + "content/docs/kernel/runtime-services/examples.mdx": 2, + "content/docs/kernel/runtime-services/sharing-service.mdx": 2, + "content/docs/kernel/services-checklist.mdx": 5, + "content/docs/permissions/authorization.mdx": 3, + "content/docs/permissions/index.mdx": 1, + "content/docs/permissions/permission-metadata.mdx": 2, + "content/docs/permissions/permission-sets.mdx": 1, + "content/docs/permissions/positions.mdx": 6, + "content/docs/plugins/anatomy.mdx": 7, + "content/docs/plugins/packages.mdx": 2, + "content/docs/protocol/diagram.mdx": 4, + "content/docs/protocol/objectos/error-handling.mdx": 1, + "content/docs/protocol/objectos/index.mdx": 1, + "content/docs/protocol/objectos/runtime-capabilities.mdx": 4, + "content/docs/protocol/objectql/security.mdx": 1, + "content/docs/protocol/objectui/record-alert.mdx": 1, + "content/docs/protocol/objectui/widget-contract.mdx": 3, + "content/docs/releases/implementation-status.mdx": 1, + "content/docs/ui/forms.mdx": 3, + "skills/objectstack-ai/SKILL.md": 8, + "skills/objectstack-api/SKILL.md": 1, + "skills/objectstack-automation/SKILL.md": 3, + "skills/objectstack-data/SKILL.md": 8, + "skills/objectstack-data/rules/relationships.md": 1, + "skills/objectstack-platform/SKILL.md": 3, + "skills/objectstack-query/rules/filters.md": 9, + "skills/objectstack-ui/SKILL.md": 2 +}