Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions .changeset/adr-0090-docs-audience-enforcement.md
Original file line number Diff line number Diff line change
@@ -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.
7 changes: 7 additions & 0 deletions .github/workflows/lint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion content/docs/automation/approvals.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
4 changes: 2 additions & 2 deletions content/docs/permissions/access-recipes.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down Expand Up @@ -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".
Expand Down
10 changes: 5 additions & 5 deletions content/docs/permissions/authorization.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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 |
Expand Down Expand Up @@ -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 |
Expand Down
3 changes: 1 addition & 2 deletions content/docs/references/system/environment-artifact.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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 | |
Expand Down
2 changes: 1 addition & 1 deletion content/docs/ui/field-grouping-and-order.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion content/docs/ui/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ export const CrmApp = App.create({
<Cards>
<Card href="/docs/ui/create-vs-edit-form" title="Create form ≠ edit form" description="Different layouts per form mode" />
<Card href="/docs/ui/field-grouping-and-order" title="Field grouping & order" description="Control form structure" />
<Card href="/docs/ui/role-based-interfaces" title="Audience-based interfaces" description="Different UIs per audience" />
<Card href="/docs/ui/audience-based-interfaces" title="Audience-based interfaces" description="Different UIs per audience" />
<Card href="/docs/ui/public-data-collection" title="Collect data from the public" description="Anonymous portal routes end to end" />
</Cards>

Expand Down
2 changes: 1 addition & 1 deletion content/docs/ui/meta.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
"---Recipes---",
"create-vs-edit-form",
"field-grouping-and-order",
"role-based-interfaces",
"audience-based-interfaces",
"public-data-collection"
]
}
4 changes: 2 additions & 2 deletions content/docs/ui/setup-app.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand All @@ -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` |
Expand Down
2 changes: 1 addition & 1 deletion eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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('|');
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
1 change: 1 addition & 0 deletions packages/lint/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
5 changes: 4 additions & 1 deletion packages/lint/src/lazy-deps.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
43 changes: 43 additions & 0 deletions packages/lint/src/validate-security-posture.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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([]);
});
});
Loading