Skip to content

Commit c8a9783

Browse files
feat(plugin-security)!: no seeder or materializer writes a sys_permission_set row; the boot seeds no row for a declared set (#15204 stage 6b-2) (#22900)
Part of #15204 Clause-②: yes (narrowing) Stage **6b-2**: no seeder or materializer writes a `sys_permission_set` row, and the boot seeds no row for a declared set. The declared-permission seeder, the platform-default materialization, the `permission` publish materializer, the `metadata:reloaded` re-seed and the organization-creation hook are deleted, with drift, the seed refusal diagnostics and the `managed_by` normalizer ("only delete, never fix", batch #310 item 4). An uninstall still revokes every grant of the package's sets, now by name (option 1, the maintainer's ruling 6123311533; mechanism B, the seat's choice under option 1). Until stage 7 (7a, #22975) retires the ADR-0094 projector, a permission-set publish still has it write a transitional `admin` projection row (see "The ADR-0094 projector until stage 7" below). This PR leaves #15204 open: stages 7a, 7b, 8, 8-post, C7b and 6c remain. ## Landing — draft until the seat marks it ready - **Prerequisites are on `main`**, last #22939 (grant-name pickers) and #22967 (the console pin), with CD (#22881) and 6b-2a (#22899) before them. `main` (`df7a137923`) is merged into this branch. - **Then land back to back: T1 (#22897) → T2 (#22898) → this PR.** T1 and T2 stay drafts until then, so the seeders are never live on `main` with their pins gone. If `main` moves first, it is merged into all three again just before landing. - **Size: Tier H.** Measured on its own once T1 and T2 are on `main` (simulated: `main` `df7a137923` + T1 `bb7b4cef36` + T2 `a38b54f6f2`, then this head): **3,601 changed lines** (48 files, +945 / −2,656). The uninstall revoke and its pins (mechanism B) took it over 3,000. This diff touches no governed path. Until T1 and T2 land, GitHub shows the whole branch. - **Merge notes, for when `main` is merged in after T1 and T2:** - `scripts/engine-double-contract.pinned.json`: regenerate with `node scripts/check-engine-double-contract.mjs --write` (measured: the regeneration produces no change against this branch). - The two `*.baseline.json` ledgers: T1 and T2 conflict with each other there; drop every entry for a deleted suite. ## What changes | Surface | Before | Now | |:--|:--|:--| | `bootstrap-declared-permissions.ts` (522 lines) | seeded every declared set as a `managed_by:'package'` row, once per organization under a wall | deleted | | `bootstrap-platform-admin.ts` | seeded the platform defaults insert-once; `resync` reconciled them | writes no catalog row. It reads the legacy `admin_full_access` row only for leg C (a grant written before the name column; restrict-only). A refused read withholds the promotion and says so once. No `resync` option, no `seeded` / `resynced` / `resyncSkipped` | | `permission` publish materializer (SP) | upserted a package-door publish into a row; ran the suggestion reconcile and `everyone`'s re-derivation only when it materialized and the body carried `isDefault` | deleted. The reconcile and the re-derivation hang off `onMetadataMutation`: every landed `permission` publish, direct save or delete, unconditionally. Drafts and other types do nothing (closes U2 contract flag A, 6109964380). The ADR-0094 projector still writes an `admin` projection row on the publish until stage 7 (next section) | | `metadata:reloaded` re-seed, organization-creation hook (SP) | re-seeded rows after a hot install and per new organization | deleted | | `permission-set-drift.ts`, `normalize-managed-by.ts`, `seed-refusal-diagnostics.ts` | boot diagnostics over the seeded rows | deleted; their public exports go (listed in the changeset) | | `warnOrganizationLessRows`, `catalogIsPerOrganization`, `listSeedOrganizationIds` (`organization-scope.ts`, internal) | the per-organization sweep and the organization-less row guard; the seeder was their one caller | deleted (re-measured: no live caller, no test) | | `sys_permission_set.drift_status` / `drift_detail` and the **Needs Attention** view | written by drift | removed with their writer (translations regenerated). The packaged-overlay report still names a shadowed set at boot | | `readDeclared` | exported by the seeder | re-homed into `declared-capability-context.ts` (its two readers: the capability context and the managed-object write denies). It is a read of the registry, the catalog's one home, never of a row | | **Uninstall** (`security.package-permissions`, both doors) | found a package's grants through its `managed_by:'package'` rows | see the next section | ## The ADR-0094 projector until stage 7 (measured) With the `permission` materializer gone, the awaited ADR-0094 projector (`projectPermissionMutation`, retired in stage 7 / 7a #22975) is the one writer left on a publish. Measured on the CRM stack, fresh database, this head against `main`: | | this PR | `main` | |:--|:--|:--| | package-door publish (draft, then `publishPackageDrafts`) | 1 row: `managed_by:'admin'`, no `package_id` | 1 row: `managed_by:'package'`, `package_id` | | writable base's direct `saveMetaItem` publish | 1 row: `admin`, no `package_id` | the same (pre-existing) | | admin data-door `PATCH` | accepted; written through into the base's own `sys_metadata` row (no environment overlay); the catalog serves the edit | the same | | admin data-door `DELETE` | deletes the row, the base's `sys_metadata` row and the served set | answers `success:false`; the row stays, the `sys_metadata` row goes | | `deletePackage` | the row is removed (the projector's delete leg) | the row is removed | | boot with a base-published set and no row (file-backed DB, re-boot) | the boot reconcile re-creates one `admin` row | not measured | No authorization path reads the row (the resolver reads set bodies from the catalog). The one change from `main` is the provenance of a package-door publish's row (`package` → `admin`) and what a data-door delete of it does. Stage-plan rule: `permission-set-projection.ts` retires in 7a, so it is not fixed here ("only delete, never fix"). The seat carries "after 7a, a package-door publish writes no row" on 7a's card. The alternative, a projector guard that creates no row for a package-held name, is offered to the maintainer as (A). ## Uninstall: the grants naming the package's sets (ADR-0090 D5) With no row standing for a package's set, the cleanup found no grant to revoke: a grant naming the set survived the uninstall, inert while nothing served the name, and **live again on reinstall**. Option 1 (the maintainer's ruling on #15204, 6123311533): revoke by the package's catalog set names, read before the package is withdrawn. Mechanism B (the seat's choice under option 1): capture before the withdrawal and hand the names to the cleanups; the lifecycle order is untouched. - **One capture, both doors, the same point.** `captureUninstallDeclared(registry, packageId, stored?)` (`@objectstack/metadata-protocol`) runs just before `registry.uninstallPackage`, in `deletePackage` and in install-local's `DELETE /api/v1/marketplace/install-local/:id`. The withdrawal still precedes the cleanups (#21576 / #21490 race guard unchanged); the capture is synchronous, so install-local still has no `await` between the ledger removal and the withdrawal. - **Two sources, unioned:** - the registry: `SchemaRegistry.securityCatalogNamesHeldBy(packageId, 'permission')` (new; the package's claims and `<packageId>:<name>` item slots, unfiltered by disable, since a disabled package still holds its names). This names an installed (artifact / install-local) package's sets. - on `deletePackage`, the `permission` rows of the package's `sys_metadata` that the door already read before any delete. **A base-authored item is held in the registry's bare slot with no `_packageId` (provenance `org`)** (measured on the CRM stack: `securityCatalogNamesHeldBy(base)` answers `[]`, while `sys_metadata` holds `{ type: 'permission', name }`), so the registry alone cannot attribute it to the base. - **The hand-off is additive and optional:** `UninstallCleanup`'s args gain `declared?: { permission?: string[] }`, passed by `runUninstallCleanups` only when the capture named a set. A package that declared none hands its cleanups exactly the request they had before. `PACKAGE_JOBS_UNINSTALL_CLEANUP` is unchanged. - **The cleanup** revokes every `sys_user_permission_set` grant naming a captured set. A name has one holder (the registry refuses a second at install), so each such grant was a grant of this package's set. - **The legacy row path stays:** an already-seeded database keeps its `managed_by:'package'` rows until C7b (this PR's changeset says so). Grants written before the name column and `sys_position_permission_set` bindings (which carry only `permission_set_id`, no name column) are still reached through such a row. - **Not touched:** a position declared in the catalog that lists the uninstalled package's set is metadata an author wrote, not a grant row; it stays as authored. ## Pins - `builtin-positions.boot.test.ts` (real plugin boot, real SQLite, every scenario): **zero `sys_permission_set` rows** after boot, with a positive census control. The first platform administrator (`single`) and the organization administrator are still granted. The grant golden is unchanged, with no row at all. - `grant-permission-set-name.writers.test.ts`: the promotion leaves zero rows and the holder reads by name. The 6b-2-pre pins stay green. - `everyone-rederivation-on-permission-mutation.test.ts`: one listener even across a bootstrap re-run. Publish and delete of a `permission` re-derive; a draft or another type does not. - `showcase-permission-seeding.dogfood.test.ts` (the `declarative-permission-seeding` authz row, rewritten): the showcase's sets are served by the catalog with their package, and the boot writes no row. - `bootstrap-platform-admin-refused-existence-read.test.ts`: a refused legacy read withholds the promotion and writes nothing. - **Uninstall, each source with its own ablation:** - **registry-held source (install-local door).** `marketplace-install-local-uninstall-withdrawal.test.ts`: the order is capture → withdraw → cleanups, and the cleanups get the names; control: a registry that cannot say hands nothing. End to end, `package-install-local-uninstall-cleanups.integration.test.ts` (CLI, four orders of events, rewritten to the catalog): grant by name → DELETE → the grant is gone → after a restart, another package's hot install, and a **reinstall of the same package**, the grant is not live again ("the grant revoked by the DELETE does not come back with the set"). Ablation: the capture answering nothing turned the door pin red (1 of 8); the controls stayed green. - **base-authored source (`deletePackage` door).** `package-uninstall-revokes-named-grants.dogfood.test.ts` (CRM stack): a writable base publishes a set, a user is granted it by name, `deletePackage` → the grant is gone; the base publishes the set again → it is served, and the grant does not come back. Ablation: dropping the capture in `deletePackage` turned 2 of 3 red. Before the stored-row source existed, this pin was red with the registry-only capture. - `protocol.uninstall-declared-capture.test.ts`: the helper (both sources, the union, nothing when nothing names a set), the runner's hand-off, and `deletePackage`'s order. `protocol.uninstall-cleanups-runner.test.ts`'s control (a request with no capture is passed through unchanged) stays green. - `registry-security-catalog-names-held-by.test.ts`: the names a package holds, a disabled package included, none after uninstall. - `cleanup-package-permissions.test.ts`: the named grants go, with a no-names control in which they survive. ## Tests this branch rewrites (they pinned the rows it removes) - `standalone-stack-seeder-declaration-copy.test.ts` (runtime): asserts no row for a declared set, as it already did for a capability. - `package-install-local-boot-steps.integration.test.ts` (CLI, #21322): the hot-installed set is read from `GET /meta/permission/…`. The flow cases still pin #21322's defect; the permission-set case now pins the current contract (the catalog served the set before #21322 too). ## Also in this PR - **Liveness ledger.** `permission.packageId` re-points to its surviving reader (the CLI's compile-time collision door). `permission.managedBy` is corrected to `dead` (see Acceptance notes). - **Tenant-audit census** regenerated after merging `main`: `main` has 227 write sites, and this branch's removal of the normalizer's and the platform-admin bootstrap's `update` sites takes it to **225**. The hand-written figures on `tenant-audit-census.mdx` follow. - **Docs**: `permission-sets.mdx` (built-in sets, provenance, the drift section reduced to overlay shadow) and `authorization.mdx`. - **Gate ledgers**: the double-contract ledgers drop the retired suites and pin the new one. ## Changeset One file, `.changeset/15204-s6b2-declared-permission-seeders.md`: - `@objectstack/plugin-security` **minor**, BREAKING, `Clause-②: yes (narrowing)`, ADR-0087 `not-required (no-migration-prescription)`. The level is unchanged by the uninstall work: nothing an author writes moves. - `@objectstack/metadata-protocol` **minor** and `@objectstack/objectql` **minor**, additive: the optional `declared` argument of the exported `UninstallCleanup` type and of `runUninstallCleanups`' request, the `UninstallDeclared` type, `captureUninstallDeclared`, and `SchemaRegistry.securityCatalogNamesHeldBy`. `@objectstack/cloud-connection` **patch** (its door hands the names on). `cleanupPackagePermissions` takes an optional fourth argument. - `check:changeset-no-major` and `check:adr-0087-registration` exit 0. ## Verification (local, at this head) At `ecfb54f8b8`, `main` `df7a137923` merged in (`c60e08810`). The merge had two conflicts: install-local's imports, where both sides' imports were kept, and the declaration-copy test's persisted-row case, which #22994 had rewritten and which keeps this branch's "no row" side. - Build: `turbo run build`, every task green. Typecheck green for the 21 packages below and dogfood. - Suites (`turbo run test`, 21 packages: plugin-security, platform-objects, runtime, metadata-protocol, cli (unit and integration), objectql, cloud-connection, plugin-auth, organizations, plugin-approvals, plugin-sharing, mcp, rest, hono, client, http-conformance, core, example-crm, example-showcase, example-todo, example-multi-package): **82 of 82 tasks green**. plugin-auth: 136 files, 2,780 passed. cloud-connection: 43 files, 519 passed (from source; see below). - Dogfood, **in full**: 258 files passed, 1 skipped; 2,117 tests passed, 9 skipped (the `deletePackage` door pin, the grant-name pickers and `main`'s walled-catalog suites included). - The rewritten suites (before this merge, on a fresh full build): (a) runtime 13/13; (b) cli boot-steps 7/7; (c) cli uninstall-cleanups 17/17. All three are inside the suite run above. - `check-i18n-bundles`: green after the full build. - `dispatch-gates --commands`: 117 derived families, all run and reconciled by `--ran`, 0 not measured. - `check:test-source-alias` went red on this diff earlier (cloud-connection's new runtime import of `@objectstack/metadata-protocol`). It is now aliased to source and green. - `check-engine-split-ratio` needs history covering its 90-day window; the clone was deepened to it, exit 0. - The one non-zero: `check:platform-checklist`, the same 8 absent-symbol anchors on T1 (which touches no checklist file). That check is the scheduled watchdog's (`checklist-status.yml`), not a per-PR gate, and `main`'s checklist cites none of T1's deleted files. - `check:changeset-no-major` and `check:adr-0087-registration`: exit 0. `check-tenant-audit-census`: OK, 225 sites. After `ecfb54f8b8`, three text rounds from the contract reviews: `5cc2ef8629`, `70a42bf2f5` and `320ce20b74`. They change docs prose, code comments, the changeset's wording, two `sys_permission_set` field descriptions (with the English translation bundle regenerated) and two install-local warn strings; no code path. At `320ce20b74`: - plugin-security: 191 files, 3,973 passed; - cloud-connection: 44 files, 538 passed; - the protocol capture pins: 10/10; - `check-i18n-bundles`, the docs checks, `check:changeset-no-major` and `check:adr-0087-registration`: all 0; - `dispatch-gates`: 119/119 run, the only non-zero being the checklist watchdog. ## Acceptance notes - **Data-door reach on a package-door-published set** (a writable base, package installed; measured on the CRM stack, this PR vs `main`): - a data-door `PATCH` is 200 on both and lands in the base's own `sys_metadata` row, with no environment overlay; - a data-door `DELETE` deletes end to end here (the row, the `sys_metadata` row and the catalog entry), while `main` half-applies it (`success:false`; `sys_metadata` gone, the row and the served body kept); - a grant naming the deleted set stays and goes dead, and is live again if the base republishes that name, as it would be on `main` after a reload; - under `single` the wall is inert, so the door is open to any caller with CRUD on `sys_permission_set` (the platform operator in practice); under `group` / `isolated` the wall refuses everyone but the operator (ADR-0131 D3); - the provenance key: the row's `managed_by:'package'` is what the projector's delete leg, the data-layer gate's `transfer` / `restore` / `purge` refusals and the uninstall cleanup's legacy row path key on, and a publish no longer stamps it here. The packaged-set lock (`packaged-permission-set-lock.ts`) keys on the artifact, not the row, and answers the same on both trees (delta review 6130075012 ①8); - carrier: 7a retires the projector and both write-throughs (#22975, seat note 6129253247). - **Stale text left to its carriers:** `packages/spec/src/security/permission.zod.ts:845` (`managedBy`'s JSDoc, "seeded by `bootstrapDeclaredPermissions`, re-seeded on upgrade"): the spec seat, with the `managedBy` enforce-or-remove item. `seed-name-lookup.ts:9` and `packages/objectql/src/engine-capability-provenance.test.ts:164–165` (comments): C7b. - **`permission.managedBy` is an authored key with no reader** (pre-existing; the ledger now says `dead`). Retiring it is an enforce-or-remove item for the spec seat. It is not done here. - **The name-fold census instrument** (`scripts/measure-position-name-fold-census.mjs`, not a CI gate) refuses to report since 6b-2a deleted the binders. Carrier: whoever next runs it re-aims its junction scan at `PositionSchema.permissionSets`. - **Two dogfood suites** (`two-doors-permission`, `showcase-permission-projection`) keep their data-door cases on an existing database (the fixture writes the legacy row), because the gate still guards such rows until 7a / stage 8. - **plugin-auth's two grant-reader suites** read the legacy platform-bucket rows; their fixture is T2's item (d) (#22898, `a38b54f6f`), carried here byte-identical (`8e40fb06b`) as this branch carries T2's other bytes, so it cancels out of the measured size. ## 维护者速读(草稿) **改了什么**:不再有任何播种器或物化器向 `sys_permission_set` 表写行,启动时也不再为声明的权限集播种行(在阶段 7 删除 ADR-0094 投影器之前,发布权限集时投影器仍会写一条 `admin` 投影行,鉴权不读它,卸载包时会被删掉)。删除了:包声明权限集的播种器、平台默认权限集的物化、包发布时的物化器、热安装后的重播种、新建组织时的播种,以及基于这些行的漂移诊断、`managed_by` 归一化和播种拒绝诊断。`drift_status`/`drift_detail` 两个字段及「需要关注」视图随写入者一并删除。卸载包时仍会撤销所有指向该包权限集的授权:两个卸载入口在把包从注册表撤下之前,先记下该包持有的权限集名称(注册表中的名称,加上 `deletePackage` 已读到的该包已存储的权限集行),交给清理步骤按名称撤销授权(方案 1 是你的裁定 6123311533;机制 B 是席位在方案 1 之下的选择)。 **为什么改**:ADR-0131 D3 规定权限集只有一个家——注册表。鉴权解析器早已从注册表读定义,授权按名称写入,这些行已不再决定任何权限。卸载按名称撤销,是为了守住 ADR-0090 D5「不留幽灵授权」:否则授权会在卸载后残留,并在重新安装后重新生效。 **风险与代价(含回滚)**:已有数据库保留旧行(直到 C7b),旧行路径的撤销照旧。删除了若干公开导出和 `bootstrapPlatformAdmin` 的 `resync` 选项,changeset 已逐项列出;新增的卸载交接字段是可选的,不读它的清理步骤行为不变。本 PR 单独计 3,601 行(超过 3,000),因此为 Tier H。回滚:直接 revert,无数据迁移。 **席位意见**: **你要做的**:T1、T2、本 PR 依次合并;本 PR 因体量为 Tier H,需要你批准。 --- 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_013XKtmoR5QCUSTMjiQ3os49 --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent dfb3ba2 commit c8a9783

52 files changed

Lines changed: 1001 additions & 2696 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
---
2+
'@objectstack/plugin-security': minor
3+
'@objectstack/metadata-protocol': minor
4+
'@objectstack/objectql': minor
5+
'@objectstack/cloud-connection': patch
6+
---
7+
8+
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
9+
10+
Clause-②: yes (narrowing)
11+
12+
<!-- 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). -->
13+
14+
**BREAKING**, shipped as `minor` under the repo's launch-window convention for breaking changes.
15+
16+
`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.
17+
18+
- **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.
19+
- **`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.
20+
- **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.
21+
- `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.
22+
23+
Added (additive, nothing an author writes changes):
24+
25+
- `@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.
26+
- `@objectstack/objectql`: `SchemaRegistry.securityCatalogNamesHeldBy(packageId, type)`.
27+
- `@objectstack/plugin-security`: `cleanupPackagePermissions` takes an optional fourth argument, the captured names.
28+
29+
Removed from `@objectstack/plugin-security`'s public exports, with no replacement:
30+
31+
- `bootstrapDeclaredPermissions`
32+
- `normalizeManagedByVocab`
33+
- `computePermissionSetDriftDiagnostics`, `persistPermissionSetDriftDiagnostics`, `runPermissionSetDriftDiagnostics`, and the types `PermissionSetDriftStatus`, `PermissionSetDriftDiagnostic`, `DriftDiagnosticsOptions`
34+
- `CAPABILITY_DECLARATION_UNOWNED`, `CAPABILITY_PLATFORM_NAME_REFUSED`, `PERMISSION_SET_DECLARATION_UNOWNED`, `capabilityDeclarationUnownedDiagnostic`, `capabilityPlatformNameRefusedDiagnostic`, `permissionSetDeclarationUnownedDiagnostic`, `reportCapabilityDeclarationUnowned`, `reportCapabilityPlatformNameRefused`, `reportPermissionSetDeclarationUnowned`, and the types `CapabilityDeclarationUnownedDiagnostic`, `CapabilityPlatformNameRefusedDiagnostic`, `PermissionSetDeclarationUnownedDiagnostic`

‎content/docs/permissions/authorization.mdx‎

Lines changed: 22 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -152,13 +152,11 @@ them, and treat "sensitive field on a public object" as a review smell.
152152
The metadata↔config boundary follows one line: **definitions travel with the
153153
package (metadata); subject bindings and env-specific values stay as config.**
154154

155-
- A package ships **its own permission sets** (Shape B), recorded with
156-
`managedBy: 'package'` + owning `packageId` on `sys_permission_set`
157-
(`package_id` / `managed_by` columns). `bootstrapDeclaredPermissions`
158-
(`packages/plugins/plugin-security/src/bootstrap-declared-permissions.ts`)
159-
seeds `stack.permissions` at boot — idempotent, re-seeded on upgrade, and it
160-
**never clobbers** env-authored (`platform`/`user`/legacy) rows. A package
161-
never writes into a foreign record.
155+
- A package ships **its own permission sets** (Shape B): its
156+
`stack.permissions` are registry items stamped with the owning package
157+
(`_packageId`), served by name from the security catalog (ADR-0131 D3). The
158+
boot writes no `sys_permission_set` row for them, and a package never
159+
writes into a foreign definition.
162160
- The environment admin **assigns** sets to positions/users; the runtime unions
163161
them. One shared set with hand-picked cross-package grants remains an
164162
env-admin-only construct.
@@ -226,12 +224,17 @@ one of two doors, each writing only what it owns:
226224
- **Package door** (studio `/studio/:packageId/access`) — a package's own set is
227225
**metadata**, so edits are saved as a **draft** stamped with the `packageId`
228226
(`saveMetaItem` `mode:'draft'`) and go live with the package's atomic
229-
**Publish**, exactly like Data and Interfaces. On publish, a registered
230-
materializer (`registerPublishMaterializer`) projects the published body into
231-
`sys_permission_set` as `managedBy:'package'` + `packageId`, reusing the same
232-
upsert as the boot seeder (`upsertPackagePermissionSet`). Enforcement is
233-
unaffected while a set is still a draft (drafts never enter the active
234-
resolve).
227+
**Publish**, exactly like Data and Interfaces. On publish, the security
228+
catalog serves the published body from the registry, owned by the package
229+
(ADR-0131 D3). The landed publish fires `onMetadataMutation`, which runs the
230+
audience-binding suggestion reconcile and re-derives the `everyone` anchor's
231+
accepted package defaults. Until stage 7 retires the ADR-0094 projector, the
232+
publish (a package-door draft publish, or a writable base's direct publish)
233+
also has the projector write a `sys_permission_set` projection row,
234+
`managedBy:'admin'` with no `packageId`, and the boot reconcile re-creates it
235+
if it is missing. No authorization path reads that row, and uninstalling the
236+
package removes it. Enforcement is unaffected while a set is still a draft
237+
(drafts never enter the active resolve).
235238
- **Environment-admin door** (`metadata-admin`) — the cross-package all-objects
236239
matrix plus subject **assignment** (`sys_user_permission_set`), edited
237240
**live** (config); the sets a position distributes are its definition's
@@ -242,7 +245,9 @@ one of two doors, each writing only what it owns:
242245
CRUD) is transparently redirected into an env-scope metadata save
243246
(`saveMetaItem`), and the data record is a **pure projection** the platform
244247
re-derives on every metadata mutation (awaited — no staleness window) and at
245-
boot. Under a `group` or `isolated` tenancy posture that Setup write is the
248+
boot. A package's published set gets such a row too, until stage 7 — the
249+
transitional `managedBy:'admin'` row described in the Package-door bullet
250+
above. Under a `group` or `isolated` tenancy posture that Setup write is the
246251
platform operator's: anyone else is refused `403 PERMISSION_DENIED` before the
247252
redirect (ADR-0131 D3). Deleting an artifact-backed set through this door **resets** it to its
248253
declared body rather than removing it; renaming through the data door is
@@ -267,16 +272,16 @@ one of two doors, each writing only what it owns:
267272
`allowRuntimeCreate` tier and stays editable. A "delete" of a packaged set
268273
through this door still degrades to a **reset** to the shipped declaration —
269274
the admin door can never remove a packaged definition. System /
270-
boot writes carry `isSystem` and bypass it, so the seeder and materializer
271-
are never self-blocked. That bypass is not local to this gate — the full set
275+
boot writes carry `isSystem` and bypass it (the ADR-0094 projector's among
276+
them), so the platform's own writes are never self-blocked. That bypass is not local to this gate — the full set
272277
of behaviours the flag changes is catalogued in
273278
[System Context (`isSystem`)](/docs/permissions/system-context).
274279

275280
## Lifecycle coverage (five stages)
276281

277282
| Stage | What holds today | Owned by |
278283
|---|---|---|
279-
| 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 |
284+
| 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 |
280285
| 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 |
281286
| 3 · Environment composition / assignment | Platform-owned assignment records (`sys_user_position` etc.); anti-escalation; union semantics | ADR-0057 D4 |
282287
| 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 |

‎content/docs/permissions/permission-sets.mdx‎

Lines changed: 19 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -161,8 +161,9 @@ the anchor decides *which* unit `unit*` means.
161161

162162
## Built-in permission sets
163163

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

167168
| Name | Scope | What it unlocks | Notes |
168169
|:--|:--|:--|:--|
@@ -337,9 +338,10 @@ tenant-level.
337338

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

340-
A package ships its own sets (`managedBy: 'package'` + `packageId`), seeded
341-
idempotently at boot and re-seeded on upgrade; environment-authored sets
342-
(`platform`/`user`) are never clobbered. The data layer refuses forging package
341+
A package ships its own sets as registry items stamped with the owning package
342+
(`packageId`), served from the security catalog — the boot writes no row for
343+
them, and an upgrade is served as soon as the package registers its new
344+
version; environment-authored sets are never clobbered. The data layer refuses forging package
343345
provenance through the admin door (two-doors separation, evolved by ADR-0094 —
344346
an edit of a set shipped in code is refused; clone it instead, see below), which
345347
is what makes package uninstall well-defined — and enforced: uninstalling a
@@ -430,47 +432,21 @@ it was copied from, so changes to the source never reach the copy.
430432

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

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

438439
- **Overlay shadow.** An active `sys_metadata` overlay for the set's name
439440
(from a Studio save made while overlaying was still permitted, or through
440-
the `OS_METADATA_WRITABLE` escape hatch) is re-projected onto the record on
441-
every boot, unconditionally — the overlay wins forever, regardless of what
442-
the package ships next. No door creates such an overlay any more: the
443-
metadata door refuses a save over a packaged set, and so does the data door
444-
(clone the set instead). **Remedy:** delete the overlay row. From the
445-
release that adds ADR-0048's cold-boot check, a deployment that still holds
446-
one does not boot: `os migrate security-catalog-overlays` lists every such
447-
row with no server running, and `--apply` deletes them, after which the
448-
package's definition is served again.
449-
- **Provenance skip.** The record's `managed_by` column predates package
450-
provenance tracking (a legacy insert without `managed_by: 'package'` —
451-
typically a database first initialized on an older release line), so boot
452-
seeding treats it as environment-authored and never reconciles it with the
453-
package — permanently. There is currently no automated adoption path for
454-
this case (boot-time auto-adoption and a bulk `os meta
455-
adopt-permission-sets` command were both deliberately deferred as migration
456-
machinery for a customer population that does not yet exist — see the
457-
discussion on the tracking issue for this section). The manual recovery is
458-
the same one-time SQL a field remediation used, run against the
459-
environment's database with a backup first:
460-
461-
```sql
462-
UPDATE sys_permission_set
463-
SET managed_by = 'package', package_id = '<owning package id>'
464-
WHERE name = '<permission set name>';
465-
```
466-
467-
Restart the environment afterward so boot seeding re-adopts the row as
468-
package-owned and reconciles its grants to the shipped artifact.
469-
470-
Both mechanisms are surfaced on the record itself: `drift_status`
471-
(`'overlay_shadow'` / `'provenance_skip'`) and `drift_detail` name the cause,
472-
recomputed every boot, and the **Needs Attention** list view collects every
473-
currently drifted set — an in-sync set is never listed there.
441+
the `OS_METADATA_WRITABLE` escape hatch) answers ahead of the package's
442+
definition — the overlay wins forever, regardless of what the package ships
443+
next. No door creates such an overlay any more: the metadata door refuses a
444+
save over a packaged set, and so does the data door (clone the set instead).
445+
The boot names every such set in its log. **Remedy:** delete the overlay
446+
row. From the release that adds ADR-0048's cold-boot check, a deployment
447+
that still holds one does not boot: `os migrate security-catalog-overlays`
448+
lists every such row with no server running, and `--apply` deletes them,
449+
after which the package's definition is served again.
474450

475451
---
476452

0 commit comments

Comments
 (0)