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
37 changes: 37 additions & 0 deletions .changeset/23546-trail-doors-setup-access.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
---
'@objectstack/rest': minor
'@objectstack/runtime': minor
---

fix(rest,runtime)!: the authoring-trail doors (`/meta` history, audit and diff, and package commits) stop admitting `setup.access`, so an organization admin is refused the environment's authoring trail

Clause-②: yes (narrowing)

<!-- adr-0087: not-required (no-migration-prescription) A runtime admission narrowing at four read doors, not a metadata change. No spec key, option, config field, response field, error code or stored shape is removed, renamed or re-shaped, and no export is added or retired: the new predicate and gate are module-private. A caller these doors still admit receives the same answer as before, so there is no tombstone and nothing for `objectstack migrate meta` to rewrite. What narrows is which callers these doors serve: a caller holding `setup.access` and neither `studio.access` nor `manage_metadata` is now refused with 403 at all four doors, and at the commits door so is a caller holding `setup.access` and `manage_metadata` without `studio.access`. The other categories are closed on facts: both packages publish (not unpublished); no ADR-0087 id covers these doors and this diff adds none (not registered, not already-registered); nothing published is retired or re-typed (not runtime-interface-only, type-surface-only or runtime-export-retired). -->

**BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes.

Organization admins hold `setup.access`. Under a wall (the `isolated` and `group` postures), these four doors handed every organization's admin the environment-wide authoring trail of items no organization authors (ADR-0131 C5): who saved each item and when, its superseded bodies, and every package's commit timeline. The data door on the same three tables (`sys_metadata_history`, `sys_metadata_audit`, `sys_metadata_commit`) already refused them (`requiredPermissions: ['studio.access']`, ADR-0131 D7). The doors now follow the same rule. The narrowing applies in every posture, `single` included.

Each door, and the callers it now refuses:

- **BREAKING** `GET /meta/:type/:name/history` (`@objectstack/rest`), for a caller holding `setup.access` and neither `studio.access` nor `manage_metadata`: was 200 with the item's version history; now `403 FORBIDDEN`.
- **BREAKING** `GET /meta/:type/:name/audit` (`@objectstack/rest`), for the same caller: was 200 with the item's audit rows; now `403 FORBIDDEN`.
- **BREAKING** `GET /meta/:type/:name/diff` (`@objectstack/rest`), for the same caller: was 200 with a diff of the item's stored versions; now `403 FORBIDDEN`.
- **BREAKING** `GET /packages/:id/commits` (`@objectstack/runtime`), for a caller holding `setup.access` without `studio.access`, **with or without `manage_metadata`**: was 200 with the package's commit timeline; now `403 PERMISSION_DENIED`.
- `manage_metadata` never admitted this door. A caller holding it alone was refused before and still is; a caller holding it beside `setup.access` was admitted only through `setup.access`.
- One such caller is a walled organization owner granted `manage_metadata` inside its own organization. It keeps history, audit and diff, and loses commits.

Who is still admitted:

- History, audit and diff admit a system caller, `studio.access` or `manage_metadata`, through their own predicate.
- The commits door admits a system caller or `studio.access`.
- Both capabilities are platform-scoped. Platform operators are not affected, and neither are metadata authors who hold `studio.access` (all four doors) or `manage_metadata` (history, audit and diff; commits never admitted it).

Unchanged:

- The draft switches keep their admission, `setup.access` included: `GET /meta/_drafts`, `?state=draft` and `?preview=draft`.
- `GET /packages`, `GET /packages/:id` and `GET /packages/:id/export` keep their read cohort (`studio.access` or `setup.access`).
- The refusal keeps its envelope and code at each door. Only the message changes: it now names the capabilities that admit the door.

What changes for you. If an organization admin needs a metadata item's history, audit trail or package commits, grant `studio.access`. A `manage_metadata` holder without `studio.access` who read package commits through `setup.access` needs `studio.access` for them too. A client that offers History or Audit to a caller holding only `setup.access` should hide them for that caller. The Setup app's item editors do this in objectstack-ai/objectui#12475.
4 changes: 2 additions & 2 deletions content/docs/api/client-sdk.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -227,13 +227,13 @@ const view = await client.meta.getItem('view', 'crm_lead.pipeline');
// Per-item draft lifecycle (ADR-0033)
await client.meta.publishItem('object', 'account', { message: 'go live' });
await client.meta.rollbackItem('object', 'account', 3);
// Authoring-only, like getHistory and listDrafts: without studio.access, setup.access or manage_metadata → 403 FORBIDDEN
// The authoring trail, like getHistory and getAudit: without studio.access or manage_metadata → 403 FORBIDDEN (setup.access alone is refused)
const diff = await client.meta.diffItem('object', 'account', { from: 2, to: 5 });

// Introspection & governance
const bad = await client.meta.getDiagnostics({ severity: 'error' });
const refs = await client.meta.getReferences('object', 'account');
// Authoring-only too, like diffItem: without studio.access, setup.access or manage_metadata → 403 FORBIDDEN
// The authoring trail too, like diffItem: without studio.access or manage_metadata → 403 FORBIDDEN (setup.access alone is refused)
const trail = await client.meta.getAudit('object', 'account', { limit: 20 });
const tree = await client.meta.getBookTree('handbook');

Expand Down
1 change: 1 addition & 0 deletions content/docs/permissions/system-context.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@ that silently does not happen.
| 6b | WRITE admission granted unconditionally | plugin-security | Get: `canWriteObject` is `true`. The WRITE twin of the object-level read admission above, asked by the write PREVIEW (`ObjectQL.validate()`), which runs no middleware for its target object and so has no gate of its own. It exists because a validation rule that reads one hop through a reference field is evaluated against a related row fetched under system authority, and that elevation is bounded to the arms the question RUNS — named, ⛔ never a category of the write decision, and ⛔ not a promise the write would succeed. The arms: the ADR-0103 engine-owned affordance gate and the ADR-0090 D12 delegated-admin gate (both ahead of every resolution), the fail-closed postures (#3545's unresolvable posture and the D10 dangling delegator), the ADR-0066 D3 capability arm, the CRUD grant, the ADR-0090 D10 delegator arm that a bare CRUD-grant check misses, the field-level-security write gate over the caller's payload when one is supplied, and the ADR-0123 D2 no-active-organization wall | `packages/plugins/plugin-security/src/security-plugin.ts#canWriteObject` |
| 7 | Write bypass = `true`, effective write scope = `org` | plugin-security | Get: widest write scope without holding any capability | `packages/plugins/plugin-security/src/security-plugin.ts#start` |
| 8 | Metadata-plane schema masking exempt (ADR-0106 D4) | metadata-core | Get: unmasked object schema. Note: the exemption is a **caller** property — it short-circuits before the security service is consulted | `packages/metadata-core/src/object-schema-fls.ts#isObjectSchemaMaskExempt` |
| 8b | Authoring-trail reads admitted | rest, runtime | Get: a metadata item's version history, audit rows and stored-version diff (`GET /meta/:type/:name/history`, `/audit`, `/diff`) without `studio.access` or `manage_metadata`, and a package's commit timeline (`GET /packages/:id/commits`) without `studio.access`. Note: narrower than the schema-mask exemption the draft switches ask (`isObjectSchemaMaskExempt`): neither door admits `setup.access`, which organization admins hold | `packages/rest/src/rest-server.ts#mayReadAuthoringTrail`, `packages/runtime/src/domains/packages.ts#requireCommitTrailReadCapability` |
| 9 | `explain()` may target a principal other than the caller | plugin-security | Get: no `manage_users` / delegated-admin check | `packages/plugins/plugin-security/src/security-plugin.ts#explainAccessForCaller` |
| 9b | The master-detail write check the `security` service serves (`checkControlledByParentWrite`, ADR-0055) answers `allow` | plugin-security | Get: `allow` for any record, with no master judged — the write path's own first exit, so a gate composing it admits exactly what the record's own system-context update would. Lose: nothing a caller reaches today: its two consumers, the `sys_attachment` and `sys_comment` parent gates, return before they ask for a system session | `packages/plugins/plugin-security/src/security-plugin.ts#checkControlledByParentWrite` |
| 10 | Anonymous-deny treats the caller as authenticated | core | Get: passes the 401 seam with no `userId` | `packages/core/src/security/anonymous-deny.ts#shouldDenyAnonymous` |
Expand Down
17 changes: 9 additions & 8 deletions packages/metadata-core/src/objects/sys-metadata-audit.object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -226,14 +226,15 @@ export const SysMetadataAuditObject = ObjectSchema.create({
// `'*'` grant — so without an object-level gate the generic data door would
// serve every organization's rows of this table (the metadata-protection trail: actors, refused-write notes). D7 governs it by
// object permission: `studio.access`, platform-scoped (no tenant
// administrator holds it by role) and admitted by every `/meta` door that
// serves this table (`/meta/:type/:name/history` and `/audit` admit it among
// `studio.access` / `setup.access` / `manage_metadata`; `/packages/:id/commits`
// among `studio.access` / `setup.access`). `requiredPermissions` is an
// AND-gate, so it names one capability all three doors admit — and not
// `setup.access`, which organization admins DO hold (plugin-security's
// `PLATFORM_ADMIN_ONLY_CAPABILITIES` excludes it on purpose). Platform code
// reads under the system context, which the gate does not stop.
// administrator holds it by role) and admitted by every door that serves
// this table's trail (`/meta/:type/:name/history`, `/audit` and `/diff` admit
// it beside `manage_metadata`; `/packages/:id/commits` admits it alone).
// `requiredPermissions` is an AND-gate, so it names the one capability all
// of those doors admit — and not `setup.access`, which organization admins
// DO hold (plugin-security's `PLATFORM_ADMIN_ONLY_CAPABILITIES` excludes it
// on purpose). [#23546] None of those doors admits `setup.access` either, so
// the data door and the trail doors refuse an organization admin alike.
// Platform code reads under the system context, which the gate does not stop.
requiredPermissions: ['studio.access'],

indexes: [
Expand Down
17 changes: 9 additions & 8 deletions packages/metadata-core/src/objects/sys-metadata-commit.object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,14 +144,15 @@ export const SysMetadataCommitObject = ObjectSchema.create({
// `'*'` grant — so without an object-level gate the generic data door would
// serve every organization's rows of this table (package commit ranges and their items). D7 governs it by
// object permission: `studio.access`, platform-scoped (no tenant
// administrator holds it by role) and admitted by every `/meta` door that
// serves this table (`/meta/:type/:name/history` and `/audit` admit it among
// `studio.access` / `setup.access` / `manage_metadata`; `/packages/:id/commits`
// among `studio.access` / `setup.access`). `requiredPermissions` is an
// AND-gate, so it names one capability all three doors admit — and not
// `setup.access`, which organization admins DO hold (plugin-security's
// `PLATFORM_ADMIN_ONLY_CAPABILITIES` excludes it on purpose). Platform code
// reads under the system context, which the gate does not stop.
// administrator holds it by role) and admitted by every door that serves
// this table's trail (`/meta/:type/:name/history`, `/audit` and `/diff` admit
// it beside `manage_metadata`; `/packages/:id/commits` admits it alone).
// `requiredPermissions` is an AND-gate, so it names the one capability all
// of those doors admit — and not `setup.access`, which organization admins
// DO hold (plugin-security's `PLATFORM_ADMIN_ONLY_CAPABILITIES` excludes it
// on purpose). [#23546] None of those doors admits `setup.access` either, so
// the data door and the trail doors refuse an organization admin alike.
// Platform code reads under the system context, which the gate does not stop.
requiredPermissions: ['studio.access'],

indexes: [
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -182,14 +182,15 @@ export const SysMetadataHistoryObject = ObjectSchema.create({
// `'*'` grant — so without an object-level gate the generic data door would
// serve every organization's rows of this table (every item version body). D7 governs it by
// object permission: `studio.access`, platform-scoped (no tenant
// administrator holds it by role) and admitted by every `/meta` door that
// serves this table (`/meta/:type/:name/history` and `/audit` admit it among
// `studio.access` / `setup.access` / `manage_metadata`; `/packages/:id/commits`
// among `studio.access` / `setup.access`). `requiredPermissions` is an
// AND-gate, so it names one capability all three doors admit — and not
// `setup.access`, which organization admins DO hold (plugin-security's
// `PLATFORM_ADMIN_ONLY_CAPABILITIES` excludes it on purpose). Platform code
// reads under the system context, which the gate does not stop.
// administrator holds it by role) and admitted by every door that serves
// this table's trail (`/meta/:type/:name/history`, `/audit` and `/diff` admit
// it beside `manage_metadata`; `/packages/:id/commits` admits it alone).
// `requiredPermissions` is an AND-gate, so it names the one capability all
// of those doors admit — and not `setup.access`, which organization admins
// DO hold (plugin-security's `PLATFORM_ADMIN_ONLY_CAPABILITIES` excludes it
// on purpose). [#23546] None of those doors admits `setup.access` either, so
// the data door and the trail doors refuse an organization admin alike.
// Platform code reads under the system context, which the gate does not stop.
requiredPermissions: ['studio.access'],

indexes: [
Expand Down
Loading
Loading