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
14 changes: 14 additions & 0 deletions .changeset/permission-set-overlay-customization.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
"@objectstack/plugin-security": minor
---

Package-owned permission sets are now customizable through the standard environment metadata overlay (ADR-0094 D5, revised — closes framework#2898 by making the overlay FIRST-CLASS instead of rejecting it).

- An env-scope `saveMetaItem('permission', …)` on a package-owned set is a real customization: the awaited projector applies the effective (overlay-wins) body to the `sys_permission_set` record while preserving its `managed_by:'package'` + `package_id` provenance, and the evaluator enforces it.
- A data-door edit of a packaged set (Setup PATCH) is translated into exactly that overlay — no more flat 403; a data-door "delete" removes the overlay and RESETS the record to the shipped declaration (the row survives).
- The ADR-0086 two-doors data gate narrows to what stays structurally true: forging package provenance through the admin door remains refused, as do the lifecycle ops with no overlay translation (`transfer`/`restore`/`purge`) on package rows; kernels without a metadata overlay layer keep the legacy full refusal.
- Cross-package roles compose via positions (bind several packages' sets); overlays narrow. Rationale: rejecting the overlay would make `permission` the one type whose declared `allowOrgOverride: true` is a lie, and clone-to-customize forks away from vendor baseline updates.

Note the standard overlay trade, now applicable to permission sets: while an overlay pins a set, later vendor baseline changes (including tightenings) don't take effect for that name until the overlay is reset or re-authored — surfaced by the Studio layered diff and covered by ADR-0091 recertification.

Also lands a dogfood proof (`showcase-permission-projection`) covering the full ADR-0094 invariant set — write-through, awaited projection, declared-set edit becomes an enforced overlay, package-set customize/reset lifecycle — registered in the liveness proof registry.
20 changes: 11 additions & 9 deletions content/docs/permissions/authorization.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -142,15 +142,17 @@ one of two doors, each writing only what it owns:
declared body rather than removing it; renaming through the data door is
rejected (the name is the metadata identity — clone instead). Subject
assignments remain plain config rows, unchanged.
- **Data-layer write gate** — the security middleware **refuses** any admin-door
write to a `managedBy:'package'` `sys_permission_set` row, and refuses a
payload that forges that provenance (insert or update, single or array). It
fails closed ahead of the CRUD check — even a `modifyAllRecords` super-user is
blocked — so the door separation is a real boundary, not a UI hint. System /
boot writes carry `isSystem` and bypass it, so the seeder and materializer are
never self-blocked. An environment adjusts a packaged set through the
ADR-0005 overlay / muting subtract layer (below), never by editing the base
record.
- **Data-layer gate (evolved by ADR-0094)** — the security middleware still
**refuses** any payload that forges package provenance (insert or update,
single or array) and the lifecycle ops with no overlay translation
(`transfer`/`restore`/`purge`) on package rows, failing closed ahead of the
CRUD check — even a `modifyAllRecords` super-user is blocked. Ordinary
admin-door **edits of a packaged set are no longer refused**: the ADR-0094
write-through translates them into the standard ADR-0005 env-scope
**overlay** — the record projects the effective body while the package keeps
owning the row, and "delete" resets to the shipped declaration. System /
boot writes carry `isSystem` and bypass it, so the seeder and materializer
are never self-blocked.

## Lifecycle coverage (five stages)

Expand Down
48 changes: 41 additions & 7 deletions content/docs/permissions/permission-sets.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -192,13 +192,47 @@ tenant-level.

A package ships its own sets (`managedBy: 'package'` + `packageId`), seeded
idempotently at boot and re-seeded on upgrade; environment-authored sets
(`platform`/`user`) are never clobbered. The data layer refuses admin-door
writes to package rows (two-doors separation), which is what makes package
uninstall well-defined — and enforced: uninstalling a package
(`DELETE /api/v1/packages/:id`) revokes its own sets, their position/user
bindings, and its pending audience-binding suggestions in the same request
(no ghost grants); the uninstall response reports the revocation under
`cleanups`. Environment-authored sets and other packages' rows survive.
(`platform`/`user`) are never clobbered. The data layer refuses forging package
provenance through the admin door (two-doors separation, evolved by ADR-0094 —
ordinary edits of a packaged set become environment overlays, see below), which
is what makes package uninstall well-defined — and enforced: uninstalling a
package (`DELETE /api/v1/packages/:id`) revokes its own sets, their
position/user bindings, and its pending audience-binding suggestions in the
same request (no ghost grants); the uninstall response reports the revocation
under `cleanups`. Environment-authored sets and other packages' rows survive.

## One authoritative store — the record is a projection (ADR-0094)

A permission-set **definition** has a single authoritative store: the metadata
layer (packaged declarations plus the `sys_metadata` overlay). The queryable
`sys_permission_set` record — what Setup lists and user assignment reads — is a
**pure projection** of that definition, never independently authoritative.
Every non-system write on the record (Setup CRUD, a bulk import, any API that
goes through the data engine) is redirected into a metadata write and the
record is re-derived by an awaited projector, so the two can never drift. This
has three author-visible consequences:

- **Editing any declared set through Setup — packaged sets included** becomes
an environment overlay of that definition (the standard metadata
customization): it genuinely takes effect, where a record-only edit
previously displayed but never enforced. The row keeps its package
provenance; the Studio layered view diffs the shipped baseline against your
customization, and removing the overlay resets to the baseline. Note the
trade every overlay makes: while an overlay pins a set, the vendor's later
baseline changes don't take effect for it until you reset or re-author.
- **The API name is immutable after creation.** It is the definition's metadata
identity, so the create form accepts it but the edit form locks it, and a
rename through the data door is rejected. Clone the set to a new name instead.
- **Deleting through the data door depends on where the definition lives.** A
set you created in this environment is removed. A set that ships with an
installed package/app cannot be removed from the environment — "delete"
**resets** it to the shipped definition (the customization overlay is
dropped), and the row remains. Uninstall the owning package to remove a
packaged set entirely.

A cross-package job function needs no hand-authored cross-package set: bind
each package's own sets to one **position** (the union model adds), and use an
overlay where a packaged set must be narrowed.

---

Expand Down
132 changes: 117 additions & 15 deletions docs/adr/0094-sys-permission-set-pure-projection.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,12 @@ not by a subscriber a new write path might forget to trigger:
re-derived from metadata (metadata wins), and legacy records that exist **only** in
the data plane are migrated into the metadata store once.

Package-owned records (`managed_by:'package'`) keep their ADR-0086 semantics: their
baseline is the shipped declaration, projected by boot seeding / publish
materialization; the environment door never touches them.
Package-owned records (`managed_by:'package'`) keep the shipped declaration as their
BASELINE (boot seeding / publish materialization), and — per the revised D5 — the
environment customizes them through the standard ADR-0005 overlay: the record projects
the effective (overlay-wins) body with its package provenance preserved, and removing
the overlay resets it to the declaration. Forging package provenance through the data
door stays impossible.

---

Expand Down Expand Up @@ -94,10 +97,10 @@ thrown, the metadata write itself succeeded and boot reconciliation heals on nex
`plugin-security` registers the `permission` projector. It re-reads the **fresh layered
effective body** and:

- upserts the env record (creates it if missing, `managed_by:'user'` — Studio-created
sets now appear in Setup);
- **refuses package-owned records** (`managed_by:'package'`) — the package door owns
them (ADR-0086 D4);
- upserts the record (creates it if missing, `managed_by:'user'` — Studio-created
sets now appear in Setup); a PACKAGE-OWNED record's facets follow the effective
body too, with its `managed_by:'package'` + `package_id` provenance preserved
(see D5 — an env overlay is the standard customization of a packaged set);
- syncs the **metadata manager's in-memory `permission` entry**
(`registerInMemory`) so the evaluator's registry-first `list('permission')`
resolution sees the same effective body it projects — closing the
Expand Down Expand Up @@ -155,14 +158,45 @@ convergence pass:

The pass is idempotent and re-runs harmlessly on every boot.

### D5 — Env-scope overlays of package-owned sets remain inert (and should be rejected at authoring)

For a name whose record is package-owned, the environment door is refused at
projection (existing #2867 rule, kept). The metadata type registry currently allows
authoring such an overlay (`allowOrgOverride: true`), which produces a layered overlay
that neither projects nor enforces — an ADR-0049 violation surfaced but not fixed here.
Rejecting it at `saveMetaItem` requires a per-type authoring gate in the protocol;
tracked as framework#2898 rather than silently expanding this change.
### D5 — Env-scope overlays of package-owned sets are FIRST-CLASS customizations (revised 2026-07-14)

**History.** As first landed, the projector refused env-scope bodies for
package-owned records (the #2867 rule), which left an authored overlay of a
packaged set inert — neither projecting nor enforcing. The initial follow-up
(#2898) proposed rejecting such overlays at authoring time. The maintainers
reversed that direction on 2026-07-14: rejection would have made permission
sets the one metadata type whose declared `allowOrgOverride: true` is a lie,
and "clone to customize" **forks** — a clone stops receiving the vendor's
subsequent baseline changes (including security tightenings) and loses the
layered code-vs-overlay diff.

**Decision.** An environment-scope overlay of a package-owned permission set
is the platform's **standard ADR-0005 customization**, fully supported:

- the projector projects the EFFECTIVE (overlay-wins) body onto the record
while **preserving** `managed_by:'package'` + `package_id` — the package
still owns the row; the overlay customizes it;
- a data-door edit of a package row is translated by the write-through into
exactly this overlay (no more flat 403); a data-door "delete" removes the
overlay — an ADR-0005 **reset** to the shipped declaration;
- the ADR-0086 two-doors gate narrows to what is still structurally true:
the admin door can never **forge** package provenance, and lifecycle ops
with no overlay translation (`transfer`/`restore`/`purge`) stay refused on
package rows. In a kernel without a metadata overlay layer the legacy full
refusal applies (there is nothing to carry a customization);
- cross-package composition remains a POSITION concern (bind several
packages' sets to one position — the union model adds; an overlay narrows),
and package-first authoring (ADR-0070) gives runtime-created sets a home
package, so the loose `managed_by:'user'` category can retire over time.

**The risk this accepts, deliberately.** ADR-0005 overlays are whole-document:
an env overlay can widen a vendor baseline, and a vendor's later baseline
*tightening* does not reach a name that is pinned by an overlay until the
overlay is reset or re-authored. Mitigations: the Studio layered view diffs
code-vs-overlay; upgrade flows should surface "customized packaged sets" for
review; ADR-0091 recertification covers overlays like any other grant source.
This is the same trade every overlayable type makes — permission sets no
longer get a bespoke, stricter rule that the rest of the platform contradicts.

## Consequences

Expand Down Expand Up @@ -215,3 +249,71 @@ tracked as framework#2898 rather than silently expanding this change.
- Implementation: `packages/plugins/plugin-security/src/permission-set-projection.ts`,
`packages/metadata-protocol/src/protocol.ts` (`registerMutationProjector`),
`packages/plugins/plugin-security/src/security-plugin.ts` (wiring).

---

## Addendum (2026-07-14): generalizing to the sibling declared-metadata ↔ queryable-record types

`sys_permission_set` is not the only object with **two stores** — a declared
definition in the metadata layer AND a queryable `sys_*` record, historically
synced only at boot and on publish. An audit found three siblings seeded the
same way: `sys_position` (`bootstrapDeclaredPositions`), `sys_sharing_rule`
(`bootstrapDeclaredSharingRules`), and `sys_capability`
(`bootstrapSystemCapabilities`). This addendum promotes the decision from a
permission-set-specific fix to a **classification rule** for all of them, so a
future maintainer neither leaves a split-brain unaddressed nor naively applies
the wrong cure.

### The general invariant

A declared definition and its queryable record must not be **two independently
writable authorities** reconciled only at boot/publish. Exactly one is
authoritative; the other is derived and documented as such — enforced
structurally (a choke point every write traverses), not by a subscriber a new
path can bypass.

### The classification criterion — *which store does enforcement read at request time?*

The cure follows the authority, and the authority is decided by one question:
**at request time, does the runtime read the metadata definition or the data
record?**

- **Metadata-authoritative** (enforcement resolves the definition from the
metadata layer) → the record is a **pure projection** (this ADR's machinery:
data-door write-through + awaited `registerMutationProjector` +
`registerAuthoringGate` + boot reconciliation). The record must never be an
independent authority, because it isn't the one enforcement trusts.
- **Record-authoritative** (enforcement reads the `sys_*` record live) → the
record is the authority and the declared metadata is a **boot SEED only**,
not a competing overlay. The cure is the mirror image: the seeder must not
clobber an environment-edited record, and no path may treat the declared
body as a live override that silently loses to (or fights) the record. **Do
not** apply the projection machinery here — projecting the record *from*
metadata would invert the real authority.

The generic protocol seams added by this ADR (`registerMutationProjector`,
`registerAuthoringGate`) serve the *metadata-authoritative* case and are
reusable by any such type; the record-authoritative case needs no new seam,
only a seed-not-clobber discipline.

### Per-type decisions

| Type | Enforcement reads | Class | Decision |
| :-- | :-- | :-- | :-- |
| `sys_permission_set` | metadata (`PermissionEvaluator.resolvePermissionSets` → `metadata.list('permission')`, DB row only as fallback) | metadata-authoritative | **Record is a projection — done** (this ADR). |
| `sys_sharing_rule` | the record, live (`sharing-plugin.ts` "rule evaluation reads `sys_sharing_rule` live"; `sharing-rule-service` `engine.find`) | **record-authoritative** | Declared rules are a **boot seed**; the record is the authority. Do **not** project. Audit that `bootstrapDeclaredSharingRules` preserves env-edited rows (seed-not-clobber) and that the metadata overlay is not read as a live override. |
| `sys_position` | mixed — position→permission-set resolution is metadata-first for the *sets*, but the `sys_position` **record** (incl. its `permissions` field, bindings, `delegatable`, `admin` gating) is read live by the anchor gate and `DelegatedAdminGate` | **needs the seed-vs-authority audit** against the criterion above; likely record-authoritative for bindings with metadata seeding identity | Classify precisely, then either project (if the position *definition* is enforced from metadata) or make declared positions seed-only. |
| `sys_capability` | the record (curated registry read for capability existence) | record-authoritative (registry) | Seed-only; low authoring surface. Audit seed-not-clobber. |

Only `sys_permission_set` was both metadata-authoritative **and** carried a
harmful, actively-drifting split-brain, which is why it was fixed first and in
full. The others are recorded here with their class so the follow-up work is
scoped, not rediscovered — tracked in framework#2909.

### Why an addendum, not a new ADR

The decision — *one authoritative store; the other derived; enforced
structurally* — is identical; only the per-type **direction** differs. A
separate ADR would duplicate the rationale and split the classification from
the decision that motivates it. This addendum keeps the rule and its
applications in one place.
Loading