Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
7a5ef33
wip(plugin-security): retire the declared-permission seeder, drift, r…
claude Oct 11, 2026
77ad5f2
wip(plugin-security,cli): the platform-admin bootstrap writes no cata…
claude Oct 11, 2026
7f18550
wip(examples,plugin-security): delete the example binders; drift_stat…
claude Oct 11, 2026
8d2baa4
wip: zero-row boot pin, everyone re-derivation pin, changeset (#15204…
claude Oct 11, 2026
facb9fd
chore(plugin-security): regenerate the translation bundles without dr…
claude Oct 11, 2026
47f0b55
test(plugin-security): fixtures modelling an existing database write …
claude Oct 11, 2026
206cdd1
docs(changeset): state the migration in prose under the runtime-inter…
claude Oct 11, 2026
173f4c1
docs(changeset): no-migration-prescription disposition (#15204 stage …
claude Oct 11, 2026
6e67a32
chore: liveness verdicts and the tenant-audit census follow the retir…
claude Oct 11, 2026
c89e15d
chore: the engine-double ledgers drop the retired suites and pin the …
claude Oct 11, 2026
274040f
docs(changeset): os meta resync and the example binders carry their o…
claude Oct 11, 2026
7d870d1
test(dogfood): fixtures read the security catalog, not a seeded sys_p…
claude Oct 11, 2026
6c849ce
test(dogfood): catalogPermissionSet answers any, as the findOne it re…
claude Oct 11, 2026
8b9fb77
chore(docs-audit): the CLI command-anchor specimen is os meta registe…
claude Oct 11, 2026
a574a24
refactor(plugin-security)!: delete the organization-scope helpers the…
claude Oct 11, 2026
fcaedfd
test(plugin-security): tighten the stage's new test and comment text …
claude Oct 11, 2026
5adf113
Merge remote-tracking branch 'origin/main' into claude/issue-15204-s6…
claude Oct 12, 2026
d15a156
chore(census): regenerate the tenant-audit census after merging main
claude Oct 12, 2026
2a88285
fix(security): an uninstall revokes the grants naming the package's p…
claude Oct 12, 2026
cfffec8
test(runtime,cli): read a package's permission set from the catalog, …
claude Oct 12, 2026
8e40fb0
test(plugin-auth): the grant-reader suites write the legacy platform-…
claude Oct 12, 2026
bf1eb8d
test(cloud-connection): resolve @objectstack/metadata-protocol from s…
claude Oct 12, 2026
c60e088
Merge remote-tracking branch 'origin/main' into claude/issue-15204-s6…
claude Oct 12, 2026
ecfb54f
chore(census): restamp the tenant-audit census after merging main
claude Oct 12, 2026
5cc2ef8
docs(permissions): describe the catalog-served package sets, not the …
claude Oct 12, 2026
70a42bf
docs(permissions): say what the ADR-0094 projector still writes on a …
claude Oct 12, 2026
320ce20
docs(permissions): the environment-admin bullet points at the transit…
claude Oct 12, 2026
4da5948
Merge remote-tracking branch 'origin/main' into claude/issue-15204-s6…
claude Oct 12, 2026
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
34 changes: 34 additions & 0 deletions .changeset/15204-s6b2-declared-permission-seeders.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
---
'@objectstack/plugin-security': minor
'@objectstack/metadata-protocol': minor
'@objectstack/objectql': minor
'@objectstack/cloud-connection': patch
---

feat(plugin-security)!: no seeder or materializer writes a `sys_permission_set` row — the declared-permission seeder, the platform-default materialization and the `permission` publish materializer are deleted

Clause-②: yes (narrowing)

<!-- adr-0087: not-required (no-migration-prescription) A runtime change on a platform object's rows and on TypeScript surfaces, not on any metadata schema: no key of any spec schema is removed, renamed or re-shaped, so there is nothing for `objectstack migrate meta` to rewrite and no tombstone. The rows the retired seeders wrote stay in the table until a later ADR-0131 stage; nothing reads them for authorization. The package publishes (not unpublished); no ADR-0087 id covers this rule and this diff adds none (not registered / already-registered). -->

**BREAKING**, shipped as `minor` under the repo's launch-window convention for breaking changes.

`SecurityPlugin` no longer writes a `sys_permission_set` row for any permission set: not for the platform defaults (`admin_full_access`, `member_default`, …), not for a set a package declares, not for an organization's copy, and not on a metadata reload or an organization's creation. Permission sets are served by the security catalog (ADR-0131 D3), and since the grant names its set (`sys_user_permission_set.permission_set`), no grant path needs a row. A fresh database still promotes its first platform administrator, grants organization administrators and self-registered members, and grants every principal what it was granted before. On a database that was already seeded, the existing rows stay as they are; they are no longer refreshed at boot. Until a later stage retires the ADR-0094 projector, publishing a permission set (a package-door publish, or a writable base's direct publish) still has it write a projection row, `managed_by: 'admin'` with no `package_id` (a package-door publish wrote `managed_by: 'package'` before this release); no authorization path reads it, and uninstalling the package removes it.

- **Accepted package defaults follow every publish.** The `everyone` anchor is derived again after every landed `permission` mutation (publish, direct save or delete, through any door), so an accepted set whose new body no longer qualifies stops granting at once. Before this release the re-derivation ran only inside the publish materializer, and only when the published body carried an `isDefault` key.
- **`drift_status` and `drift_detail` are gone from `sys_permission_set`**, with the **Needs Attention** list view that listed them. Nothing computes them any more: the declared-versus-enforced diagnostic retired with the rows it compared. A Studio overlay that shadows a packaged set is still named at boot.
- **Uninstalling a package still revokes every grant of its permission sets** (ADR-0090 D5, "no ghost grants"). With no row standing for a package's set, both uninstall doors (`deletePackage` and `DELETE /api/v1/marketplace/install-local/:id`) capture the set names the package held just before they withdraw it from the registry, and the `security.package-permissions` cleanup revokes every `sys_user_permission_set` grant naming them. The withdrawal still runs before the cleanups. A grant naming such a set would otherwise survive the uninstall and be live again on reinstall. Grants written before the name column, and position bindings, are still reached through a legacy `sys_permission_set` row where an already-seeded database holds one.
- `bootstrapPlatformAdmin` no longer takes the `resync` option and no longer reports `seeded`, `resynced` or `resyncSkipped`. A refused read of the legacy `admin_full_access` row (the restrict-only reach to a grant written before the name column existed) withholds the promotion and says so once.

Added (additive, nothing an author writes changes):

- `@objectstack/metadata-protocol`: the optional `declared?: UninstallDeclared` argument of `UninstallCleanup` (and of `runUninstallCleanups`' request), the `UninstallDeclared` type, and `captureUninstallDeclared(registry, packageId, stored?)`, the one capture both doors call. A cleanup that ignores `declared` behaves as before.
- `@objectstack/objectql`: `SchemaRegistry.securityCatalogNamesHeldBy(packageId, type)`.
- `@objectstack/plugin-security`: `cleanupPackagePermissions` takes an optional fourth argument, the captured names.

Removed from `@objectstack/plugin-security`'s public exports, with no replacement:

- `bootstrapDeclaredPermissions`
- `normalizeManagedByVocab`
- `computePermissionSetDriftDiagnostics`, `persistPermissionSetDriftDiagnostics`, `runPermissionSetDriftDiagnostics`, and the types `PermissionSetDriftStatus`, `PermissionSetDriftDiagnostic`, `DriftDiagnosticsOptions`
- `CAPABILITY_DECLARATION_UNOWNED`, `CAPABILITY_PLATFORM_NAME_REFUSED`, `PERMISSION_SET_DECLARATION_UNOWNED`, `capabilityDeclarationUnownedDiagnostic`, `capabilityPlatformNameRefusedDiagnostic`, `permissionSetDeclarationUnownedDiagnostic`, `reportCapabilityDeclarationUnowned`, `reportCapabilityPlatformNameRefused`, `reportPermissionSetDeclarationUnowned`, and the types `CapabilityDeclarationUnownedDiagnostic`, `CapabilityPlatformNameRefusedDiagnostic`, `PermissionSetDeclarationUnownedDiagnostic`
39 changes: 22 additions & 17 deletions content/docs/permissions/authorization.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -152,13 +152,11 @@ them, and treat "sensitive field on a public object" as a review smell.
The metadata↔config boundary follows one line: **definitions travel with the
package (metadata); subject bindings and env-specific values stay as config.**

- A package ships **its own permission sets** (Shape B), recorded with
`managedBy: 'package'` + owning `packageId` on `sys_permission_set`
(`package_id` / `managed_by` columns). `bootstrapDeclaredPermissions`
(`packages/plugins/plugin-security/src/bootstrap-declared-permissions.ts`)
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.
- A package ships **its own permission sets** (Shape B): its
`stack.permissions` are registry items stamped with the owning package
(`_packageId`), served by name from the security catalog (ADR-0131 D3). The
boot writes no `sys_permission_set` row for them, and a package never
writes into a foreign definition.
- 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.
Expand Down Expand Up @@ -226,12 +224,17 @@ one of two doors, each writing only what it owns:
- **Package door** (studio `/studio/:packageId/access`) — a package's own set is
**metadata**, so edits are saved as a **draft** stamped with the `packageId`
(`saveMetaItem` `mode:'draft'`) and go live with the package's atomic
**Publish**, exactly like Data and Interfaces. On publish, a registered
materializer (`registerPublishMaterializer`) projects the published body into
`sys_permission_set` as `managedBy:'package'` + `packageId`, reusing the same
upsert as the boot seeder (`upsertPackagePermissionSet`). Enforcement is
unaffected while a set is still a draft (drafts never enter the active
resolve).
**Publish**, exactly like Data and Interfaces. On publish, the security
catalog serves the published body from the registry, owned by the package
(ADR-0131 D3). The landed publish fires `onMetadataMutation`, which runs the
audience-binding suggestion reconcile and re-derives the `everyone` anchor's
accepted package defaults. Until stage 7 retires the ADR-0094 projector, the
publish (a package-door draft publish, or a writable base's direct publish)
also has the projector write a `sys_permission_set` projection row,
`managedBy:'admin'` with no `packageId`, and the boot reconcile re-creates it
if it is missing. No authorization path reads that row, and uninstalling the
package removes it. Enforcement is unaffected while a set is still a draft
(drafts never enter the active resolve).
- **Environment-admin door** (`metadata-admin`) — the cross-package all-objects
matrix plus subject **assignment** (`sys_user_permission_set`), edited
**live** (config); the sets a position distributes are its definition's
Expand All @@ -242,7 +245,9 @@ one of two doors, each writing only what it owns:
CRUD) is transparently redirected into an env-scope metadata save
(`saveMetaItem`), and the data record is a **pure projection** the platform
re-derives on every metadata mutation (awaited — no staleness window) and at
boot. Under a `group` or `isolated` tenancy posture that Setup write is the
boot. A package's published set gets such a row too, until stage 7 — the
transitional `managedBy:'admin'` row described in the Package-door bullet
above. Under a `group` or `isolated` tenancy posture that Setup write is the
platform operator's: anyone else is refused `403 PERMISSION_DENIED` before the
redirect (ADR-0131 D3). Deleting an artifact-backed set through this door **resets** it to its
declared body rather than removing it; renaming through the data door is
Expand All @@ -267,16 +272,16 @@ one of two doors, each writing only what it owns:
`allowRuntimeCreate` tier and stays editable. A "delete" of a packaged set
through this door still degrades to a **reset** to the shipped declaration —
the admin door can never remove a packaged definition. System /
boot writes carry `isSystem` and bypass it, so the seeder and materializer
are never self-blocked. That bypass is not local to this gate — the full set
boot writes carry `isSystem` and bypass it (the ADR-0094 projector's among
them), so the platform's own writes are never self-blocked. That bypass is not local to this gate — the full set
of behaviours the flag changes is catalogued in
[System Context (`isSystem`)](/docs/permissions/system-context).

## Lifecycle coverage (five stages)

| Stage | What holds today | Owned by |
|---|---|---|
| 1 · Package development | Zod-validated authoring; sharingRules / permissions seeded at boot with provenance; positions served from the registry, with no row (ADR-0131 D2) | ADR-0057 D6, ADR-0086 D5, ADR-0131 D2, ADR-0049/0078 gates |
| 1 · Package development | Zod-validated authoring; sharingRules seeded at boot with provenance; permission sets and positions served from the registry, with no seeded row (ADR-0131 D2/D3) | ADR-0057 D6, ADR-0086 D5, ADR-0131 D2, 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; each enforced primitive holds a row naming its enforcement site — the matrix file is the authority on the count and on which rows CI can check | ADR-0056 D10 matrix |
Expand Down
62 changes: 19 additions & 43 deletions content/docs/permissions/permission-sets.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -161,8 +161,9 @@ the anchor decides *which* unit `unit*` means.

## Built-in permission sets

Auto-seeded on boot by `plugin-security`
(`objects/default-permission-sets.ts`):
Declared by `plugin-security` on its own manifest
(`objects/default-permission-sets.ts`) and served from the security catalog —
the boot writes no row for them:

| Name | Scope | What it unlocks | Notes |
|:--|:--|:--|:--|
Expand Down Expand Up @@ -337,9 +338,10 @@ tenant-level.

## Provenance — package vs environment sets (ADR-0086)

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 forging package
A package ships its own sets as registry items stamped with the owning package
(`packageId`), served from the security catalog — the boot writes no row for
them, and an upgrade is served as soon as the package registers its new
version; environment-authored sets are never clobbered. The data layer refuses forging package
provenance through the admin door (two-doors separation, evolved by ADR-0094 —
an edit of a set shipped in code is refused; clone it instead, see below), which
is what makes package uninstall well-defined — and enforced: uninstalling a
Expand Down Expand Up @@ -430,47 +432,21 @@ it was copied from, so changes to the source never reach the copy.

### Declared ≠ enforced — diagnosing a frozen package set

A package-declared set's *enforced* grants (what `sys_permission_set` stores
and what the evaluator resolves) can diverge from its shipped *artifact*
(what the package currently declares) through two independent mechanisms —
either can be live on one row, and they need different remedies:
A package-declared set's *enforced* grants (what the security catalog serves
and the evaluator resolves) can diverge from its shipped *artifact* (what the
package currently declares) in one way:

- **Overlay shadow.** An active `sys_metadata` overlay for the set's name
(from a Studio save made while overlaying was still permitted, or through
the `OS_METADATA_WRITABLE` escape hatch) is re-projected onto the record on
every boot, unconditionally — the overlay wins forever, regardless of what
the package ships next. No door creates such an overlay any more: the
metadata door refuses a save over a packaged set, and so does the data door
(clone the set instead). **Remedy:** delete the overlay row. From the
release that adds ADR-0048's cold-boot check, a deployment that still holds
one does not boot: `os migrate security-catalog-overlays` lists every such
row with no server running, and `--apply` deletes them, after which the
package's definition is served again.
- **Provenance skip.** The record's `managed_by` column predates package
provenance tracking (a legacy insert without `managed_by: 'package'` —
typically a database first initialized on an older release line), so boot
seeding treats it as environment-authored and never reconciles it with the
package — permanently. There is currently no automated adoption path for
this case (boot-time auto-adoption and a bulk `os meta
adopt-permission-sets` command were both deliberately deferred as migration
machinery for a customer population that does not yet exist — see the
discussion on the tracking issue for this section). The manual recovery is
the same one-time SQL a field remediation used, run against the
environment's database with a backup first:

```sql
UPDATE sys_permission_set
SET managed_by = 'package', package_id = '<owning package id>'
WHERE name = '<permission set name>';
```

Restart the environment afterward so boot seeding re-adopts the row as
package-owned and reconciles its grants to the shipped artifact.

Both mechanisms are surfaced on the record itself: `drift_status`
(`'overlay_shadow'` / `'provenance_skip'`) and `drift_detail` name the cause,
recomputed every boot, and the **Needs Attention** list view collects every
currently drifted set — an in-sync set is never listed there.
the `OS_METADATA_WRITABLE` escape hatch) answers ahead of the package's
definition — the overlay wins forever, regardless of what the package ships
next. No door creates such an overlay any more: the metadata door refuses a
save over a packaged set, and so does the data door (clone the set instead).
The boot names every such set in its log. **Remedy:** delete the overlay
row. From the release that adds ADR-0048's cold-boot check, a deployment
that still holds one does not boot: `os migrate security-catalog-overlays`
lists every such row with no server running, and `--apply` deletes them,
after which the package's definition is served again.

---

Expand Down
Loading
Loading