diff --git a/.changeset/15204-s6b2-declared-permission-seeders.md b/.changeset/15204-s6b2-declared-permission-seeders.md new file mode 100644 index 00000000000..abf718276c7 --- /dev/null +++ b/.changeset/15204-s6b2-declared-permission-seeders.md @@ -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) + + + +**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` diff --git a/content/docs/permissions/authorization.mdx b/content/docs/permissions/authorization.mdx index 3241fc889a5..bccf3c94995 100644 --- a/content/docs/permissions/authorization.mdx +++ b/content/docs/permissions/authorization.mdx @@ -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. @@ -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 @@ -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 @@ -267,8 +272,8 @@ 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). @@ -276,7 +281,7 @@ one of two doors, each writing only what it owns: | 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 | diff --git a/content/docs/permissions/permission-sets.mdx b/content/docs/permissions/permission-sets.mdx index 091db906aea..8d5583c7f9d 100644 --- a/content/docs/permissions/permission-sets.mdx +++ b/content/docs/permissions/permission-sets.mdx @@ -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 | |:--|:--|:--|:--| @@ -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 @@ -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 = '' - WHERE 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. --- diff --git a/content/docs/permissions/tenant-audit-census.mdx b/content/docs/permissions/tenant-audit-census.mdx index f14126367c8..96542330024 100644 --- a/content/docs/permissions/tenant-audit-census.mdx +++ b/content/docs/permissions/tenant-audit-census.mdx @@ -83,7 +83,7 @@ what moved this page's population from 225 to 227; nothing about the two sites changed, only whether this instrument could see them. **The expensive failure direction is a keyword.** Sites whose receiver the author -typed `any` have no type to read, and there are 44 of them — just under a fifth +typed `any` have no type to read, and there are 42 of them — just under a fifth of the population, concentrated in exactly the seed and bootstrap paths this control exists for. Scoring an unreadable receiver as "not an engine" would have dropped every one of them silently, with a clean exit and a smaller number that @@ -122,7 +122,7 @@ are reported as `undecidable` rather than assumed either way. The same holds twice over for the context. An options argument spelled as a literal can be read; one spelled `options`, `{ ...opts }`, or handed through a -forwarding shim cannot, and **53 of the 227 sites are spelled that way**. A +forwarding shim cannot, and **53 of the 225 sites are spelled that way**. A context resolved from an inline literal or a local `const` can be tested for `isSystem`; one arriving from a helper call cannot. @@ -187,10 +187,10 @@ reproduce them. Where it disagrees, it disagrees on the page: | carried figure | where it survives | this census | | :--- | :--- | ---: | -| 175 write call sites | quoted in the merged changeset | **227** | +| 175 write call sites | quoted in the merged changeset | **225** | | 24 carrying no tenant context | quoted in the merged changeset | **2** provable and tenancy-enabled; **32** more whose options argument is unreadable | -| 127 of 175 statically decidable, 48 runtime-parameter-name sites | restated on the `isSystem`-scoping card | **155 of 227** decidable, **72** undecidable | -| 135 (77%) silenced by the `isSystem` guard before the posture gate | the lost issue body — **no surviving corroboration** | **not reproduced**: 122 decidably elevated, 0 decidably not, 97 undecidable | +| 127 of 175 statically decidable, 48 runtime-parameter-name sites | restated on the `isSystem`-scoping card | **155 of 225** decidable, **70** undecidable | +| 135 (77%) silenced by the `isSystem` guard before the posture gate | the lost issue body — **no surviving corroboration** | **not reproduced**: 120 decidably elevated, 0 decidably not, 97 undecidable | | 141 and 132, two independent re-derivations | the card that filed this work | — | **The differences are not reconciled, and deliberately so.** The old census's @@ -200,18 +200,18 @@ be stated is what this instrument counts, which is written above and re-runnable at any commit. Two structural facts do plausibly widen this reading against any hand or regex -one, and both are counted in the generated tables below: the 44 sites reached +one, and both are counted in the generated tables below: the 42 sites reached through an erased (`any`) receiver, and the 52 that name their object through a `const` rather than inline. An instrument that read either the way a person does would report a smaller number and would not say so. The fourth row is the one worth flagging to anyone citing it. **The 135 / 77% figure has no surviving corroboration anywhere in the tree.** This census reads -122 of 227 (54%) as decidably elevated, with 97 more whose elevation is a +120 of 225 (53%) as decidably elevated, with 97 more whose elevation is a run-time fact — so the claim is neither confirmed nor refuted, and the honest answer is that a static reading cannot settle it. -⇒ **Cite `2 / 227`, and say what it is**: the sites whose options argument was +⇒ **Cite `2 / 225`, and say what it is**: the sites whose options argument was READ and holds no tenant context, against a decidably tenancy-enabled object. That is the control's provable yield surface. ⛔ Do not cite it as "the sites without tenant context" — **32 further sites** have an options argument this @@ -223,17 +223,17 @@ cannot read, and they are neither in nor out. | what | count | | :--- | ---: | -| write call sites on the application surface | **227** | +| write call sites on the application surface | **225** | | …whose object name is statically decidable | 155 | -| …whose object name is chosen at run time | 72 | +| …whose object name is chosen at run time | 70 | | …against an object with tenancy ENABLED | 154 | | …against an object that declares tenancy off | 1 | -| threading a tenant context | 166 | +| threading a tenant context | 164 | | PROVABLY carrying none (options read, no context key) | **8** | | …of those, against a decidably tenancy-enabled object | **2** | | options argument UNREADABLE — may or may not carry one | 53 | | …of those, against a decidably tenancy-enabled object | 32 | -| threading a decidably ELEVATED (`isSystem`) context | 122 | +| threading a decidably ELEVATED (`isSystem`) context | 120 | | threading a context that is decidably NOT elevated | 0 | | threading a context whose elevation is a run-time fact | 97 | @@ -241,12 +241,12 @@ cannot read, and they are neither in nor out. | :--- | ---: | | receiver carried a readable engine type | 183 | | receiver erased, placed by the object NAME | 30 | -| receiver erased, placed by an `object: string` PARAMETER | 9 | +| receiver erased, placed by an `object: string` PARAMETER | 7 | | receiver erased, placed by an `UNTYPED_RECEIVERS` row | 5 | | object name spelled inline | 103 | | object name spelled through a `const` | 52 | -| object name is an `object: string` parameter | 11 | +| object name is an `object: string` parameter | 9 | | object name is some other run-time expression | 61 | ### Subtractions the census could NOT defend — enforced @@ -297,12 +297,12 @@ holds still. They are required to be HERE and to say WHEN they were true; their values are not compared. The reasoning, and the measurement behind it, are in `scripts/check-tenant-audit-census.mjs`. -Measured on 2026-10-11 at `be55432f6`. +Measured on 2026-10-12 at `c60e08810`. | corpus scale (not enforced) | count | | :--- | ---: | -| tracked non-test sources scanned | 632 | -| engine-shaped types recognised | 72 | +| tracked non-test sources scanned | 629 | +| engine-shaped types recognised | 71 | | declared objects in the registry | 117 | | same-named calls subtracted as non-engine | 161 | diff --git a/docs/audits/2026-08-tenant-audit-write-call-sites.counts.md b/docs/audits/2026-08-tenant-audit-write-call-sites.counts.md index 9ca4d164a81..6a61d0062ff 100644 --- a/docs/audits/2026-08-tenant-audit-write-call-sites.counts.md +++ b/docs/audits/2026-08-tenant-audit-write-call-sites.counts.md @@ -33,17 +33,17 @@ silent, and `node scripts/tenant-audit-census.mjs --write` is the resolution. | Measure | Value | |---|---:| -| Write call sites | 227 | +| Write call sites | 225 | | Object name statically decidable | 155 | -| Object name chosen at run time | 72 | +| Object name chosen at run time | 70 | | Against a tenancy-enabled object | 154 | | Against an object declaring tenancy off | 1 | -| Threading a tenant context | 166 | +| Threading a tenant context | 164 | | Provably carrying none | 8 | | …and decidably tenancy-enabled | 2 | | Options argument unreadable | 53 | | …and decidably tenancy-enabled | 32 | -| Threading a decidably elevated context | 122 | +| Threading a decidably elevated context | 120 | | Threading a decidably non-elevated context | 0 | | Threading a context of undecidable elevation | 97 | @@ -90,12 +90,12 @@ holds still. They are required to be HERE and to say WHEN they were true; their values are not compared. The reasoning, and the measurement behind it, are in `scripts/check-tenant-audit-census.mjs`. -Measured on 2026-10-11 at `be55432f6`. +Measured on 2026-10-12 at `c60e08810`. | corpus scale (not enforced) | count | | :--- | ---: | -| tracked non-test sources scanned | 632 | -| engine-shaped types recognised | 72 | +| tracked non-test sources scanned | 629 | +| engine-shaped types recognised | 71 | | declared objects in the registry | 117 | | same-named calls subtracted as non-engine | 161 | @@ -156,13 +156,11 @@ Measured on 2026-10-11 at `be55432f6`. | `packages/plugins/plugin-security/src/auto-org-admin-grant.ts` | `delete` | `object` | undecidable | elevated | 1 | | `packages/plugins/plugin-security/src/auto-org-admin-grant.ts` | `insert` | `object` | undecidable | elevated | 1 | | `packages/plugins/plugin-security/src/bootstrap-platform-admin.ts` | `insert` | `object` | undecidable | elevated | 1 | -| `packages/plugins/plugin-security/src/bootstrap-platform-admin.ts` | `update` | `object` | undecidable | elevated | 1 | | `packages/plugins/plugin-security/src/claim-seed-ownership.ts` | `update` | `schema.name` | undecidable | elevated | 1 | | `packages/plugins/plugin-security/src/cleanup-package-permissions.ts` | `delete` | `object` | undecidable | elevated | 1 | | `packages/plugins/plugin-security/src/grant-permission-set-name-backfill.ts` | `insert` | `DATA_MIGRATION_FLAG_OBJECT` | undecidable | elevated | 1 | | `packages/plugins/plugin-security/src/grant-permission-set-name-backfill.ts` | `update` | `GRANT_OBJECT` | undecidable | context, elevation undecidable | 1 | | `packages/plugins/plugin-security/src/invitation-placement.ts` | `insert` | `sys_user_position` | enabled | elevated | 1 | -| `packages/plugins/plugin-security/src/normalize-managed-by.ts` | `update` | `object` | undecidable | elevated | 1 | | `packages/plugins/plugin-security/src/permission-set-projection.ts` | `insert` | `object` | undecidable | context, elevation undecidable | 1 | | `packages/plugins/plugin-security/src/permission-set-projection.ts` | `update` | `object` | undecidable | context, elevation undecidable | 1 | | `packages/plugins/plugin-security/src/permission-set-projection.ts` | `delete` | `sys_permission_set` | enabled | elevated | 1 | diff --git a/docs/design/permission-model.md b/docs/design/permission-model.md index b0260fbda2b..6a921f751aa 100644 --- a/docs/design/permission-model.md +++ b/docs/design/permission-model.md @@ -162,9 +162,10 @@ Three developer disciplines: - **Restrict by not granting**: in a union model a `readable: false` cannot defeat another set's `true`. There are no subtraction sets — to withhold, don't grant. -On install, `bootstrapDeclaredPermissions` seeds these rows with `packageId` + -`managedBy: 'package'`; upgrades re-seed **only** those rows. Customer-authored sets and all -bindings are never touched — that provenance line *is* the package/platform isolation boundary. +On install, the security catalog serves these sets from the package's registered metadata, owned +by the package (ADR-0131 D3); no row is seeded, and an upgrade serves the definitions the new +version ships. Customer-authored sets and all bindings are never touched — that provenance line +*is* the package/platform isolation boundary. ### What the admin configures after install diff --git a/packages/cli/src/utils/permission-set-name-collisions.ts b/packages/cli/src/utils/permission-set-name-collisions.ts index dbf66ed7771..eab626f6252 100644 --- a/packages/cli/src/utils/permission-set-name-collisions.ts +++ b/packages/cli/src/utils/permission-set-name-collisions.ts @@ -7,9 +7,11 @@ * * ## The defect this closes, one layer up from #17516 * - * `bootstrapDeclaredPermissions` refuses to write into a `sys_permission_set` - * row another package owns (ADR-0086 D4). That refusal is CORRECT and nothing - * here changes it — ⛔ this module reports, it does not alter what is skipped. + * `bootstrapDeclaredPermissions` refused to write into a `sys_permission_set` + * row another package owned (ADR-0086 D4). That seeder retired with ADR-0131 + * C3 stage 6b-2; the registry now refuses, at install, a package declaring a + * set name another holder already holds. Nothing here changes either refusal — + * ⛔ this module reports, it does not alter what is refused. * #17516 gave that refusal a RUNTIME door. Until this module there was no * compile-time door at all: `os build` and `os validate` walked an artifact in * which two packages declared the same set name and said nothing, so the first @@ -62,8 +64,8 @@ * * ## The provenance rule, mirrored from the seeder rather than invented * - * `bootstrapDeclaredPermissions` owns a set under - * `ps._packageId ?? ps.packageId` — registry provenance (ADR-0010) first, the + * `bootstrapDeclaredPermissions` (retired with ADR-0131 C3 stage 6b-2) owned a + * set under `ps._packageId ?? ps.packageId` — registry provenance (ADR-0010) first, the * author-declared `packageId` (ADR-0086 D3) as fallback. At compile time the * artifact's package id IS that registry provenance: `artifactPackages`' id is * "the string the runtime registers a package under", so a set inside package diff --git a/packages/cli/test/package-install-local-boot-steps.integration.test.ts b/packages/cli/test/package-install-local-boot-steps.integration.test.ts index 57fa4461226..21561e5c173 100644 --- a/packages/cli/test/package-install-local-boot-steps.integration.test.ts +++ b/packages/cli/test/package-install-local-boot-steps.integration.test.ts @@ -3,8 +3,8 @@ /** * #21322 — a hot install (`os package install ` into a RUNNING * `os start`) leaves the runtime in the state a restart would: the installed - * package's record-change flow fires and its permission set is projected into - * `sys_permission_set`, both right after the install, with no restart. + * package's record-change flow fires and its permission set is served by the + * security catalog, both right after the install, with no restart. * * ## The defect, measured on the published train and again on `main` * @@ -23,6 +23,11 @@ * sweeps run once per boot; nothing re-ran them for a package that arrived * after it. * + * Since ADR-0131 C3 stage 6b-2 no door writes a `sys_permission_set` row: the + * set is served by the security catalog from the registry, and a grant names + * it. What an admin grants from is `GET /meta/permission/…`, so that is what + * the permission-set case reads now. + * * ## What each `it` reads * * One fixture, three phases — after the hot INSTALL, after a RESTART on the @@ -268,7 +273,7 @@ function rowsOf(answer: Answer): any[] { const recordOf = (a: Answer) => a.body?.data ?? a.body?.record ?? a.body; interface Phase { - /** `GET /data/sys_permission_set?name=…` — the projection the admin surface grants from. */ + /** `GET /meta/permission/…` — the set as the security catalog serves it, which the grant form lists (ADR-0131 D3). */ permissionSet: Answer; /** The task the flow is driven through: created, then updated to `done`. */ created: Answer; @@ -279,7 +284,7 @@ interface Phase { let seq = 0; async function probe(live: LiveStart, token: string): Promise { - const permissionSet = await http(live, 'GET', `/api/v1/data/sys_permission_set?name=${PERMISSION_SET}`, token); + const permissionSet = await http(live, 'GET', `/api/v1/meta/permission/${PERMISSION_SET}`, token); const taskName = `flow-probe-${++seq}`; const created = await http(live, 'POST', `/api/v1/data/${TASK}`, token, { name: taskName, status: 'open' }); @@ -352,10 +357,11 @@ describe('#21322: a hot install binds what the boot binds', () => { for (const name of ['install', 'restart', 'control'] as const) { describe(`after ${name}`, () => { - it('the package permission set is projected into sys_permission_set', () => { + it('the package permission set is served by the security catalog', () => { const p = phases[name]!; expect(p.permissionSet.status, JSON.stringify(p.permissionSet.body)).toBe(200); - expect(rowsOf(p.permissionSet).map((r) => r?.name)).toEqual([PERMISSION_SET]); + expect((p.permissionSet.body?.item ?? p.permissionSet.body?.data?.item)?.name, JSON.stringify(p.permissionSet.body)) + .toBe(PERMISSION_SET); }); it('the record-change flow fires: a task updated to done writes its note', () => { diff --git a/packages/cli/test/package-install-local-uninstall-cleanups.integration.test.ts b/packages/cli/test/package-install-local-uninstall-cleanups.integration.test.ts index ed75effa5e1..8c7c8a56d29 100644 --- a/packages/cli/test/package-install-local-uninstall-cleanups.integration.test.ts +++ b/packages/cli/test/package-install-local-uninstall-cleanups.integration.test.ts @@ -22,6 +22,17 @@ * `security.package-permissions` is the one that removes package-owned sets with * their position and user bindings. This door never ran that registry. * + * ## Since ADR-0131 C3 stage 6b-2 + * + * No door writes a `sys_permission_set` row any more: the package's set is + * served by the security catalog from the registry (`GET /meta/permission/…`), + * and the grant names it. So the cleanup can no longer find the grants through + * a row. The door captures the set names the package held just before it + * withdraws the package, and the cleanup revokes the grants naming them + * (ADR-0090 D5, maintainer ruling on #15204: option 1). A grant naming the + * set would otherwise survive the uninstall, inert, and be live again the + * moment the package is reinstalled; the reinstall order pins that it is not. + * * ## What each `it` reads * * One fixture, two orders of events, each probed through the data route a user @@ -241,17 +252,21 @@ function rowsOf(answer: Answer): any[] { const recordOf = (a: Answer) => a.body?.data ?? a.body?.record ?? a.body; -/** What the data route answers about the package's grants at one moment. */ +/** What the routes answer about the package's set and its grants at one moment. */ interface Grants { - /** `GET /data/sys_permission_set?name=…` — the projection the admin surface grants from. */ + /** `GET /meta/permission/…` — the set as the security catalog serves it (ADR-0131 D3). */ sets: Answer; /** `GET /data/sys_user_permission_set?permission_set=…` — the user grant made before the uninstall, by the set's name (ADR-0131 D4). */ bindings: Answer; } -async function readGrants(live: LiveStart, token: string, _setId: string): Promise { +/** The name the catalog serves for {@link PERMISSION_SET}, or `undefined` when it serves none. */ +const servedName = (a: Answer): string | undefined => + a.status === 200 ? (a.body?.item ?? a.body?.data?.item)?.name : undefined; + +async function readGrants(live: LiveStart, token: string): Promise { return { - sets: await http(live, 'GET', `/api/v1/data/sys_permission_set?name=${PERMISSION_SET}`, token), + sets: await http(live, 'GET', `/api/v1/meta/permission/${PERMISSION_SET}`, token), bindings: await http(live, 'GET', `/api/v1/data/sys_user_permission_set?permission_set=${PERMISSION_SET}`, token), }; } @@ -263,7 +278,6 @@ interface Run { before?: Grants; /** `POST /data/sys_user_permission_set` — the grant whose survival is the defect's second half. */ granted?: Answer; - setId?: string; uninstall?: Answer; /** Same process, right after the DELETE answered. */ after?: Grants; @@ -281,8 +295,8 @@ interface Run { afterOtherInstall?: Grants; /** Reinstall order only: the same package installed again, in the same process, after its DELETE. */ reinstall?: { exit: number | null; output: string }; - /** Reinstall order only: the object and the set right after that reinstall. */ - afterReinstall?: { object: Answer; sets: Answer }; + /** Reinstall order only: the object, the set and the grant right after that reinstall. */ + afterReinstall?: { object: Answer; sets: Answer; bindings: Answer }; } /** @@ -291,26 +305,24 @@ interface Run { * the same act on both orders of events. */ async function grantThenUninstall(live: LiveStart, session: Session, run: Run): Promise { - const sets = await http(live, 'GET', `/api/v1/data/sys_permission_set?name=${PERMISSION_SET}`, session.token); - const setId = rowsOf(sets)[0]?.id; - if (typeof setId !== 'string') { - throw new Error(`precondition: no ${PERMISSION_SET} row to grant — ${sets.status} ${JSON.stringify(sets.body)}\n--- output ---\n${live.output().slice(-3000)}`); + const sets = await http(live, 'GET', `/api/v1/meta/permission/${PERMISSION_SET}`, session.token); + if (servedName(sets) !== PERMISSION_SET) { + throw new Error(`precondition: the catalog serves no ${PERMISSION_SET} to grant — ${sets.status} ${JSON.stringify(sets.body)}\n--- output ---\n${live.output().slice(-3000)}`); } - run.setId = setId; run.granted = await http(live, 'POST', '/api/v1/data/sys_user_permission_set', session.token, { user_id: session.userId, permission_set: PERMISSION_SET, }); - run.before = await readGrants(live, session.token, setId); + run.before = await readGrants(live, session.token); run.objectBefore = await http(live, 'GET', `/api/v1/data/${TASK}`, session.token); run.uninstall = await http(live, 'DELETE', UNINSTALL, session.token); - run.after = await readGrants(live, session.token, setId); + run.after = await readGrants(live, session.token); run.objectAfter = await http(live, 'GET', `/api/v1/data/${TASK}`, session.token); } async function readAfterRestart(live: LiveStart, run: Run): Promise { const { token } = await authenticate(live); - run.restarted = await readGrants(live, token, run.setId!); + run.restarted = await readGrants(live, token); run.object = await http(live, 'GET', `/api/v1/data/${TASK}`, token); } @@ -369,7 +381,7 @@ beforeAll(async () => { runs.reseed.install = await packageInstall(appDir, f); await grantThenUninstall(f, fSession, runs.reseed); runs.reseed.otherInstall = await packageInstall(otherAppDir, f); - runs.reseed.afterOtherInstall = await readGrants(f, fSession.token, runs.reseed.setId!); + runs.reseed.afterOtherInstall = await readGrants(f, fSession.token); await stopGroup(f.child); const g = await bootStart(reseedDir, reseedHome, port); await readAfterRestart(g, runs.reseed); @@ -387,7 +399,7 @@ beforeAll(async () => { runs.reinstall.reinstall = await packageInstall(appDir, h); runs.reinstall.afterReinstall = { object: await http(h, 'GET', `/api/v1/data/${TASK}`, hSession.token), - sets: await http(h, 'GET', `/api/v1/data/sys_permission_set?name=${PERMISSION_SET}`, hSession.token), + ...await readGrants(h, hSession.token), }; await stopGroup(h.child); }, 9 * BOOT_TIMEOUT_MS); @@ -404,8 +416,7 @@ describe('#21490: an install-local uninstall runs the registered uninstall clean const run = runs[name]; expect(run.install?.exit, run.install?.output).toBe(0); expect(run.granted?.status, JSON.stringify(run.granted?.body)).toBe(201); - expect(rowsOf(run.before!.sets).map((r) => [r?.name, r?.managed_by, r?.package_id])) - .toEqual([[PERMISSION_SET, 'package', APP_ID]]); + expect(servedName(run.before!.sets), JSON.stringify(run.before!.sets.body)).toBe(PERMISSION_SET); expect(rowsOf(run.before!.bindings).map((r) => r?.id)).toEqual([recordOf(run.granted!)?.id]); }); @@ -417,10 +428,9 @@ describe('#21490: an install-local uninstall runs the registered uninstall clean expect(security, JSON.stringify(run.uninstall?.body)).toMatchObject({ success: true }); }); - it('right after the DELETE: no package-managed set, no grant of it', () => { + it('right after the DELETE: the catalog serves no such set, and no grant of it remains', () => { const run = runs[name]; - expect(run.after!.sets.status).toBe(200); - expect(rowsOf(run.after!.sets), JSON.stringify(run.after!.sets.body)).toEqual([]); + expect(servedName(run.after!.sets), JSON.stringify(run.after!.sets.body)).toBeUndefined(); expect(run.after!.bindings.status).toBe(200); expect(rowsOf(run.after!.bindings), JSON.stringify(run.after!.bindings.body)).toEqual([]); }); @@ -438,8 +448,7 @@ describe('#21490: an install-local uninstall runs the registered uninstall clean it('after a restart: still no set and no grant, and the package object is gone', () => { const run = runs[name]; expect(run.object?.status, JSON.stringify(run.object?.body)).toBe(404); - expect(run.restarted!.sets.status).toBe(200); - expect(rowsOf(run.restarted!.sets), JSON.stringify(run.restarted!.sets.body)).toEqual([]); + expect(servedName(run.restarted!.sets), JSON.stringify(run.restarted!.sets.body)).toBeUndefined(); expect(run.restarted!.bindings.status).toBe(200); expect(rowsOf(run.restarted!.bindings), JSON.stringify(run.restarted!.bindings.body)).toEqual([]); }); @@ -465,16 +474,15 @@ describe('#21490: an install-local uninstall runs the registered uninstall clean expect(run.install?.exit, run.install?.output).toBe(0); expect(run.otherInstall?.exit, run.otherInstall?.output).toBe(0); expect(run.granted?.status, JSON.stringify(run.granted?.body)).toBe(201); - expect(rowsOf(run.before!.sets).map((r) => [r?.name, r?.managed_by, r?.package_id])) - .toEqual([[PERMISSION_SET, 'package', APP_ID]]); + expect(servedName(run.before!.sets), JSON.stringify(run.before!.sets.body)).toBe(PERMISSION_SET); expect(run.uninstall?.status, JSON.stringify(run.uninstall?.body)).toBe(200); - expect(rowsOf(run.after!.sets), JSON.stringify(run.after!.sets.body)).toEqual([]); + expect(servedName(run.after!.sets), JSON.stringify(run.after!.sets.body)).toBeUndefined(); expect(rowsOf(run.after!.bindings), JSON.stringify(run.after!.bindings.body)).toEqual([]); }); it('the grant stays revoked through the other install and the restart, and the package object is gone', () => { const run = runs.reseed; - for (const answer of [run.afterOtherInstall!.sets, run.afterOtherInstall!.bindings, run.restarted!.sets, run.restarted!.bindings]) { + for (const answer of [run.afterOtherInstall!.bindings, run.restarted!.bindings]) { expect(answer.status, JSON.stringify(answer.body)).toBe(200); } expect(rowsOf(run.afterOtherInstall!.bindings), JSON.stringify(run.afterOtherInstall!.bindings.body)).toEqual([]); @@ -482,14 +490,14 @@ describe('#21490: an install-local uninstall runs the registered uninstall clean expect(run.object?.status, JSON.stringify(run.object?.body)).toBe(404); }); - it('the other package\'s hot install does not re-project the uninstalled package\'s set', () => { + it('the other package\'s hot install does not bring the uninstalled package\'s set back', () => { const run = runs.reseed; - expect(rowsOf(run.afterOtherInstall!.sets), JSON.stringify(run.afterOtherInstall!.sets.body)).toEqual([]); + expect(servedName(run.afterOtherInstall!.sets), JSON.stringify(run.afterOtherInstall!.sets.body)).toBeUndefined(); }); - it('after the restart there is no package-managed set for the uninstalled package — no orphan row', () => { + it('after the restart the catalog still serves no set for the uninstalled package', () => { const run = runs.reseed; - expect(rowsOf(run.restarted!.sets), JSON.stringify(run.restarted!.sets.body)).toEqual([]); + expect(servedName(run.restarted!.sets), JSON.stringify(run.restarted!.sets.body)).toBeUndefined(); }); }); @@ -506,16 +514,23 @@ describe('#21490: an install-local uninstall runs the registered uninstall clean expect(run.uninstall?.status, JSON.stringify(run.uninstall?.body)).toBe(200); expect(run.objectBefore?.status, JSON.stringify(run.objectBefore?.body)).toBe(200); expect(run.objectAfter?.status, JSON.stringify(run.objectAfter?.body)).toBe(404); - expect(rowsOf(run.after!.sets), JSON.stringify(run.after!.sets.body)).toEqual([]); + expect(servedName(run.after!.sets), JSON.stringify(run.after!.sets.body)).toBeUndefined(); + expect(rowsOf(run.after!.bindings), JSON.stringify(run.after!.bindings.body)).toEqual([]); }); - it('the reinstall registers the package again: its object answers, and its set is projected once', () => { + it('the reinstall registers the package again: its object answers, and the catalog serves its set again', () => { const run = runs.reinstall; expect(run.reinstall?.exit, run.reinstall?.output).toBe(0); expect(run.afterReinstall!.object.status, JSON.stringify(run.afterReinstall!.object.body)).toBe(200); - expect(run.afterReinstall!.sets.status).toBe(200); - expect(rowsOf(run.afterReinstall!.sets).map((r) => [r?.name, r?.managed_by, r?.package_id]), JSON.stringify(run.afterReinstall!.sets.body)) - .toEqual([[PERMISSION_SET, 'package', APP_ID]]); + expect(servedName(run.afterReinstall!.sets), JSON.stringify(run.afterReinstall!.sets.body)).toBe(PERMISSION_SET); + }); + + // [ADR-0090 D5] The grant the uninstall revoked is not live again: the set + // is back under the same name, and nothing names it. + it('the grant revoked by the DELETE does not come back with the set', () => { + const run = runs.reinstall; + expect(run.afterReinstall!.bindings.status, JSON.stringify(run.afterReinstall!.bindings.body)).toBe(200); + expect(rowsOf(run.afterReinstall!.bindings), JSON.stringify(run.afterReinstall!.bindings.body)).toEqual([]); }); }); }); diff --git a/packages/cloud-connection/src/marketplace-install-local-plugin.ts b/packages/cloud-connection/src/marketplace-install-local-plugin.ts index d9590e163b1..120aee2bf64 100644 --- a/packages/cloud-connection/src/marketplace-install-local-plugin.ts +++ b/packages/cloud-connection/src/marketplace-install-local-plugin.ts @@ -185,6 +185,7 @@ import type { IHttpServer, IMetadataService, IObjectQLEngine } from '@objectstac import type { DeletePackageRequest, UninstallCleanupOutcome, + UninstallDeclared, ObjectStackProtocolImplementation, PackageWritabilityEngine, } from '@objectstack/metadata-protocol'; @@ -193,7 +194,7 @@ import type { // import verb raises (answered here after the payload, id and handshake checks, // before any write), the read-only-package rule (a namespace held by a WRITABLE base is // this environment's own metadata), and the client-facing sentence rule. -import { unmanagedInstallPostureRefusal, isWritablePackage, clientFacingFailureText } from '@objectstack/metadata-protocol'; +import { unmanagedInstallPostureRefusal, isWritablePackage, clientFacingFailureText, captureUninstallDeclared } from '@objectstack/metadata-protocol'; import { resolveThrownHttpError } from '@objectstack/types'; const ROUTE_BASE = '/api/v1/marketplace/install-local'; @@ -217,7 +218,7 @@ const ROUTE_BASE = '/api/v1/marketplace/install-local'; */ type UninstallCleanupRunner = { runUninstallCleanups?( - request: Pick, + request: Pick & { declared?: UninstallDeclared }, ): Promise; }; @@ -1912,13 +1913,19 @@ export class MarketplaceInstallLocalPlugin implements Plugin { // `metadata:reloaded` in that window would re-project the very sets // they had just selected and removed. The cleanups lose nothing by // going second: `deletePackage` already runs every registered cleanup - // after its withdrawal, and the one registered today - // (`security.package-permissions`) selects by package id in the store, - // never through the registry. + // after its withdrawal, and `security.package-permissions` reads no + // registry: it selects by package id in the store, and by the names + // handed to it. [ADR-0090 D5] Those names are captured just before the + // withdrawal releases them — synchronously, so still no `await` before + // it, at the same point and through the same helper as + // `deletePackage` — and the cleanup revokes the grants naming them. + let registry: unknown; + try { registry = ctx.getService('objectql')?.registry; } catch { /* no data engine — nothing registered */ } + const declared = captureUninstallDeclared(registry, manifestId); const withdrawal = this.withdrawFromRunningKernel(ctx, manifestId); const cleanups = [ ...(withdrawal ? [withdrawal] : []), - ...await this.runUninstallCleanups(ctx, manifestId, admission.userId), + ...await this.runUninstallCleanups(ctx, manifestId, admission.userId, declared), ]; ctx.logger?.info?.( `[MarketplaceInstallLocal] uninstalled ${manifestId} (cached manifest removed; ` @@ -2045,6 +2052,7 @@ export class MarketplaceInstallLocalPlugin implements Plugin { ctx: PluginContext, manifestId: string, actor: string, + declared?: UninstallDeclared, ): Promise => { let protocol: UninstallCleanupRunner | undefined; try { protocol = ctx.getService('protocol'); } catch { /* no protocol service */ } @@ -2061,7 +2069,7 @@ export class MarketplaceInstallLocalPlugin implements Plugin { ); } else { try { - outcomes = await protocol.runUninstallCleanups({ packageId: manifestId, actor }); + outcomes = await protocol.runUninstallCleanups({ packageId: manifestId, actor, ...(declared ? { declared } : {}) }); } catch (err: any) { ctx.logger?.warn?.(`[MarketplaceInstallLocal] the uninstall cleanups of ${manifestId} could not be run: ${err?.message ?? err}`); outcomes = notRun('the uninstall cleanups could not be run'); @@ -2847,10 +2855,10 @@ export class MarketplaceInstallLocalPlugin implements Plugin { * - `service-automation` re-syncs its flows from the protocol * (`resyncFlowsFromProtocol`, the `kernel:ready` bind's own * `registerFlow`), so the package's record-change flows fire; - * - `plugin-security` re-runs its declared-permission seeding (the - * `kernel:ready` pass's own `bootstrapDeclaredPermissions`), so the - * package's permission sets are projected into `sys_permission_set` - * and can be granted. + * - `plugin-security` re-ran its declared-permission seeding here until + * ADR-0131 C3 stage 6b-2 retired it. A package's permission sets are + * now served by the security catalog from the registry as soon as the + * install registers them, so nothing re-runs for them. * * Before this, a hot install left both until the next restart: the * restart's rehydrate registers the package ahead of those sweeps, a hot @@ -2870,7 +2878,7 @@ export class MarketplaceInstallLocalPlugin implements Plugin { if (typeof ctx.trigger !== 'function') { ctx.logger?.warn?.( `[MarketplaceInstallLocal] this kernel context cannot announce metadata:reloaded — the record-change flows ` - + `and permission sets of ${manifestId} take effect only after a restart.`, + + `of ${manifestId} take effect only after a restart.`, ); return; } @@ -2879,7 +2887,7 @@ export class MarketplaceInstallLocalPlugin implements Plugin { } catch (err: any) { ctx.logger?.warn?.( `[MarketplaceInstallLocal] ${manifestId} is installed, but the metadata:reloaded re-sync FAILED — its ` - + 'record-change flows may not fire and its permission sets may be missing from sys_permission_set ' + + 'record-change flows may not fire ' + `until the runtime restarts (the restart re-reads every installed package). Cause: ${err?.message ?? err}`, ); } diff --git a/packages/cloud-connection/src/marketplace-install-local-uninstall-withdrawal.test.ts b/packages/cloud-connection/src/marketplace-install-local-uninstall-withdrawal.test.ts index dbab1c1dd09..4c04b288b8a 100644 --- a/packages/cloud-connection/src/marketplace-install-local-uninstall-withdrawal.test.ts +++ b/packages/cloud-connection/src/marketplace-install-local-uninstall-withdrawal.test.ts @@ -26,7 +26,9 @@ * - a refused withdrawal is reported on the response as a failed outcome, * never swallowed, the cleanups still run, and the cause goes to the * operator log only; - * - a registry that does not hold the package has nothing to withdraw. + * - a registry that does not hold the package has nothing to withdraw; + * - [ADR-0090 D5] the permission-set names the package held are captured + * just BEFORE the withdrawal releases them, and handed to the cleanups. */ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; @@ -103,12 +105,20 @@ async function bootPlugin(opts: { registered?: string[]; refuseWithdrawal?: string; auth?: unknown; + held?: string[]; } = {}) { const events: Array<{ step: string; id: string; ledgerOnDisk: boolean }> = []; + const handed: unknown[] = []; const packages = new Map((opts.registered ?? [APP_ID, USER_CODE_ID]).map((id) => [id, { manifest: { id } }])); const registry = { getAllPackages: () => [...packages.values()], getPackage: (id: string) => packages.get(id), + ...(opts.held ? { + securityCatalogNamesHeldBy: (id: string) => { + events.push({ step: 'capture', id, ledgerOnDisk: existsSync(ledgerFile()) }); + return packages.has(id) ? opts.held : []; + }, + } : {}), uninstallPackage: vi.fn((id: string) => { events.push({ step: 'withdraw', id, ledgerOnDisk: existsSync(ledgerFile()) }); if (opts.refuseWithdrawal) throw new Error(opts.refuseWithdrawal); @@ -123,8 +133,9 @@ async function bootPlugin(opts: { auth: opts.auth ?? installerAuthService(), objectql: withInstallerGrants({ syncSchemas: async () => undefined, registry }), protocol: { - runUninstallCleanups: vi.fn(async (request: { packageId: string }) => { + runUninstallCleanups: vi.fn(async (request: { packageId: string; declared?: unknown }) => { events.push({ step: 'cleanups', id: request.packageId, ledgerOnDisk: existsSync(ledgerFile()) }); + handed.push(request.declared); return CLEANUP_OUTCOMES; }), }, @@ -145,7 +156,7 @@ async function bootPlugin(opts: { const uninstall = async (manifestId = APP_ID) => rawApp.routes.get('DELETE /api/v1/marketplace/install-local/:manifestId')!(makeDeleteC(manifestId)); const warnings = () => logger.warn.mock.calls.map((c: any[]) => String(c[0])); - return { uninstall, events, packages, registry, warnings }; + return { uninstall, events, packages, registry, warnings, handed }; } describe('#21576: an install-local uninstall withdraws the package from the running kernel', () => { @@ -169,6 +180,27 @@ describe('#21576: an install-local uninstall withdraws the package from the runn expect(warnings()).toEqual([]); }); + it('[ADR-0090 D5] captures the package\'s permission-set names BEFORE the withdrawal, and hands them to the cleanups', async () => { + seedLedger(); + const { uninstall, events, handed } = await bootPlugin({ held: ['tasks_app_task_user'] }); + + const res = await uninstall(); + + expect(res.status, JSON.stringify(res.payload)).toBe(200); + expect(events.map((e) => e.step)).toEqual(['capture', 'withdraw', 'cleanups']); + expect(handed).toEqual([{ permission: ['tasks_app_task_user'] }]); + }); + + it('control — a registry that cannot say what the package held hands the cleanups nothing', async () => { + seedLedger(); + const { uninstall, events, handed } = await bootPlugin(); + + await uninstall(); + + expect(events.map((e) => e.step)).toEqual(['withdraw', 'cleanups']); + expect(handed).toEqual([undefined]); + }); + it('a refused caller withdraws nothing', async () => { seedLedger(); const { uninstall, registry, packages } = await bootPlugin({ diff --git a/packages/cloud-connection/vitest.config.ts b/packages/cloud-connection/vitest.config.ts index c06b74f39da..fee59daad69 100644 --- a/packages/cloud-connection/vitest.config.ts +++ b/packages/cloud-connection/vitest.config.ts @@ -69,6 +69,15 @@ export default defineConfig({ find: /^@objectstack\/objectql$/, replacement: path.resolve(__dirname, '../objectql/src/index.ts'), }, + // [#15204 6b-2, ADR-0090 D5] The install-local uninstall captures the + // package's permission-set names through `captureUninstallDeclared`, so + // every suite that imports the plugin judges THIS checkout's capture, not + // a `metadata-protocol/dist` that may be behind it. Anchored like the + // entries above, though the package exports only `.` today. + { + find: /^@objectstack\/metadata-protocol$/, + replacement: path.resolve(__dirname, '../metadata-protocol/src/index.ts'), + }, ], }, // Discovery is untouched — but this file is NOT "the alias and nothing else". diff --git a/packages/metadata-protocol/src/index.ts b/packages/metadata-protocol/src/index.ts index bfa62a288ee..fe8d552afb4 100644 --- a/packages/metadata-protocol/src/index.ts +++ b/packages/metadata-protocol/src/index.ts @@ -118,7 +118,8 @@ export type { SeedTenancySplit, SeedTenancyCollision, } from './migrations/seed-tenancy-backfill.js'; -export type { UninstallCleanup, UninstallCleanupOutcome } from './protocol.js'; +export type { UninstallCleanup, UninstallCleanupOutcome, UninstallDeclared } from './protocol.js'; +export { captureUninstallDeclared } from './protocol.js'; // [#9960] The ONE declared shape of the `deletePackage` seam, exported so the // two consumers that speak it (`@objectstack/rest`'s direct-mount package // registrar and the `@objectstack/runtime` dispatcher twin) type the seam diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index 29579b1b1e5..61507d16a40 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -5387,8 +5387,68 @@ export type PublishMaterializer = (args: { export type UninstallCleanup = (args: { packageId: string; actor?: string; + /** + * What the package declared, read from the registry just before the door + * withdrew it ({@link captureUninstallDeclared}). Optional: absent when the + * registry did not hold the package or cannot answer, and a cleanup that + * does not read it behaves as before. + */ + declared?: UninstallDeclared; }) => Promise<{ success: boolean; removed: number; error?: string }>; +/** + * [ADR-0090 D5, ADR-0131 D4] The security-catalog names an uninstalled package + * held. A grant names its set, and the package's sets are served from the + * registry, not stored, so after the withdrawal nothing else knows them. + */ +export interface UninstallDeclared { + /** The permission-set names the package held. */ + permission?: string[]; +} + +/** + * Capture what `packageId` declared, for {@link UninstallCleanup}'s `declared` + * — the ONE capture both uninstall doors (`deletePackage` and install-local's + * `DELETE`) call, at the same point: after the uninstall is decided and just + * BEFORE `registry.uninstallPackage`, which releases the names. The withdrawal + * still precedes the cleanups (#21576); only what they are handed grows. + * + * Two sources, unioned: the names the registry says the package holds (an + * installed package's claims and item slots), and the `permission` rows of the + * package's `sys_metadata` the door already read (`stored`). The second is + * what names a writable base's sets (ADR-0070): an item authored into a base + * is stored, and the registry holds it in the bare slot with no `_packageId` + * (provenance `org`, measured), so the registry alone cannot attribute it to + * the base. + * + * `undefined` when neither source names a set — so a package that declared + * none hands its cleanups exactly the request they had before. + */ +export function captureUninstallDeclared( + registry: unknown, + packageId: string, + stored?: ReadonlyArray<{ type?: unknown; name?: unknown } | null | undefined>, +): UninstallDeclared | undefined { + const names = new Set(); + const held = (registry as { securityCatalogNamesHeldBy?: (id: string, type: 'permission') => unknown } | null | undefined) + ?.securityCatalogNamesHeldBy; + if (typeof held === 'function') { + try { + const fromRegistry = held.call(registry, packageId, 'permission'); + if (Array.isArray(fromRegistry)) { + for (const n of fromRegistry) if (typeof n === 'string' && n !== '') names.add(n); + } + } catch { /* the registry could not say; the stored rows still can */ } + } + if (stored) { + for (const row of stored) { + const type = row?.type; + if ((type === 'permission' || type === 'permissions') && typeof row?.name === 'string' && row.name !== '') names.add(row.name); + } + } + return names.size > 0 ? { permission: [...names] } : undefined; +} + /** Per-cleanup outcome reported on the `deletePackage` response. */ export interface UninstallCleanupOutcome { name: string; @@ -6196,7 +6256,7 @@ export class ObjectStackProtocolImplementation implements * does — and never swallows it. */ async runUninstallCleanups( - request: Pick, + request: Pick & { declared?: UninstallDeclared }, ): Promise { const cleanups: UninstallCleanupOutcome[] = []; for (const [name, cleanup] of this.uninstallCleanups) { @@ -6204,6 +6264,7 @@ export class ObjectStackProtocolImplementation implements const r = await cleanup({ packageId: request.packageId, ...(request.actor ? { actor: request.actor } : {}), + ...(request.declared ? { declared: request.declared } : {}), }); cleanups.push({ name, @@ -22799,9 +22860,10 @@ export class ObjectStackProtocolImplementation implements ); } } - // [ADR-0094] Awaited projection: runs AFTER the package-door - // materializer (which stamps package provenance) so the projector - // sees final record state; refuses/no-ops per its own rules. + // [ADR-0094] Awaited projection: runs AFTER any materializer + // registered for the type, so the projector sees final record state; + // refuses/no-ops per its own rules. (No `permission` materializer is + // registered since ADR-0131 C3 stage 6b-2.) const publishProjection = await this.runMutationProjector({ type: args.singularType, name: args.name, @@ -24475,6 +24537,11 @@ export class ObjectStackProtocolImplementation implements // registry that lacks that method, or a throw nothing asked ahead of // time: it is logged, not fatal, because the durable row is already // gone and the next boot is clean either way. + // + // [ADR-0090 D5] What the package declared is captured first: the + // withdrawal releases its names, and the cleanups below revoke the + // grants that name them. + const declared = captureUninstallDeclared((this.engine as any)?.registry, request.packageId, rows); try { (this.engine as any)?.registry?.uninstallPackage?.(request.packageId); } catch (e) { @@ -24491,7 +24558,7 @@ export class ObjectStackProtocolImplementation implements // a security condition) is visible to the caller, never silent. // [#21490] Through the registry's one runner, which install-local's // uninstall door calls too. - const cleanups = await this.runUninstallCleanups(request); + const cleanups = await this.runUninstallCleanups({ ...request, ...(declared ? { declared } : {}) }); return { success: failed.length === 0 && deleted.length > 0, diff --git a/packages/metadata-protocol/src/protocol.uninstall-declared-capture.test.ts b/packages/metadata-protocol/src/protocol.uninstall-declared-capture.test.ts new file mode 100644 index 00000000000..7e503e1c695 --- /dev/null +++ b/packages/metadata-protocol/src/protocol.uninstall-declared-capture.test.ts @@ -0,0 +1,91 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [ADR-0090 D5, ADR-0131 D4] The uninstall cleanups are handed what the + * package declared, captured from the registry just BEFORE the withdrawal + * releases it. + * + * A grant names its permission set, and a package's sets are served from the + * registry, not stored as rows. The withdrawal (`registry.uninstallPackage`) + * runs before the cleanups (#21576) and releases the names, so a cleanup that + * looked them up itself would find nothing, and the grants naming them would + * survive the uninstall. {@link captureUninstallDeclared} is the one capture + * both doors call; these cases pin it, the runner's hand-off, and + * `deletePackage`'s order. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { ObjectStackProtocolImplementation, captureUninstallDeclared } from './index.js'; + +const PACKAGE_ID = 'com.example.tasksapp'; + +describe('captureUninstallDeclared — the one capture both uninstall doors call', () => { + it('reads the permission-set names the registry says the package holds', () => { + const held = vi.fn(() => ['tasks_app_task_user', 'tasks_app_admin']); + expect(captureUninstallDeclared({ securityCatalogNamesHeldBy: held }, PACKAGE_ID)) + .toEqual({ permission: ['tasks_app_task_user', 'tasks_app_admin'] }); + expect(held.mock.calls).toEqual([[PACKAGE_ID, 'permission']]); + }); + + it('answers nothing when no source names a set: absent, a build without the read, a throw, or none declared', () => { + expect(captureUninstallDeclared(undefined, PACKAGE_ID)).toBeUndefined(); + expect(captureUninstallDeclared({ securityCatalogNamesHeldBy: () => [] }, PACKAGE_ID, [{ type: 'object', name: 'x' }])).toBeUndefined(); + expect(captureUninstallDeclared({ uninstallPackage: () => true }, PACKAGE_ID)).toBeUndefined(); + expect(captureUninstallDeclared({ securityCatalogNamesHeldBy: () => { throw new Error('boom'); } }, PACKAGE_ID)).toBeUndefined(); + }); + + it('unions the package\'s stored `permission` rows: a writable base\'s sets are stored, not held by the registry', () => { + const stored = [ + { type: 'permission', name: 'base_set' }, + { type: 'object', name: 'base_widget' }, + { type: 'permission', name: 'tasks_app_task_user' }, + ]; + expect(captureUninstallDeclared({ securityCatalogNamesHeldBy: () => ['tasks_app_task_user'] }, PACKAGE_ID, stored)) + .toEqual({ permission: ['tasks_app_task_user', 'base_set'] }); + expect(captureUninstallDeclared(undefined, PACKAGE_ID, stored)).toEqual({ permission: ['base_set', 'tasks_app_task_user'] }); + }); + + it('keeps only non-empty string names', () => { + expect(captureUninstallDeclared({ securityCatalogNamesHeldBy: () => ['a', '', 7, null, 'b'] }, PACKAGE_ID)) + .toEqual({ permission: ['a', 'b'] }); + }); +}); + +describe('runUninstallCleanups hands `declared` to every cleanup, and only when it has one', () => { + function makeProtocol(registry: Record) { + const engine = { registry, find: async () => [] }; + const services = new Map([['package', { delete: async () => ({ success: true }) }]]); + return new ObjectStackProtocolImplementation(engine as never, () => services as never); + } + + it('passes the captured names through, beside the package id and actor', async () => { + const protocol = makeProtocol({}); + const cleanup = vi.fn(async () => ({ success: true, removed: 1 })); + protocol.registerUninstallCleanup('security.package-permissions', cleanup); + + await protocol.runUninstallCleanups({ packageId: PACKAGE_ID, actor: 'usr_1', declared: { permission: ['tasks_app_task_user'] } }); + + expect(cleanup.mock.calls).toEqual([[{ packageId: PACKAGE_ID, actor: 'usr_1', declared: { permission: ['tasks_app_task_user'] } }]]); + }); + + it('deletePackage captures BEFORE it withdraws, and the cleanups get what it captured', async () => { + const order: string[] = []; + const registry = { + assertPackageUninstallable: () => undefined, + securityCatalogNamesHeldBy: vi.fn((id: string) => { + order.push(`capture ${id}`); + // The withdrawal releases the names: after it, the registry holds none. + return order.includes(`withdraw ${id}`) ? [] : ['tasks_app_task_user']; + }), + uninstallPackage: vi.fn((id: string) => { order.push(`withdraw ${id}`); return true; }), + }; + const protocol = makeProtocol(registry); + const cleanup = vi.fn(async () => { order.push('cleanup'); return { success: true, removed: 1 }; }); + protocol.registerUninstallCleanup('security.package-permissions', cleanup); + + await protocol.deletePackage({ packageId: PACKAGE_ID, actor: 'usr_1' }); + + expect(order).toEqual([`capture ${PACKAGE_ID}`, `withdraw ${PACKAGE_ID}`, 'cleanup']); + expect(cleanup.mock.calls).toEqual([[{ packageId: PACKAGE_ID, actor: 'usr_1', declared: { permission: ['tasks_app_task_user'] } }]]); + }); +}); diff --git a/packages/objectql/src/registry-security-catalog-names-held-by.test.ts b/packages/objectql/src/registry-security-catalog-names-held-by.test.ts new file mode 100644 index 00000000000..0706dbcb409 --- /dev/null +++ b/packages/objectql/src/registry-security-catalog-names-held-by.test.ts @@ -0,0 +1,44 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [ADR-0090 D5, ADR-0131 D4] `securityCatalogNamesHeldBy` — the read the + * uninstall doors take just before `uninstallPackage` releases a package's + * names, so the cleanups can revoke the grants naming its permission sets. + * It answers for a disabled package (still installed, still holding its + * names) and nothing once the package is gone. + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import { SchemaRegistry } from './registry.js'; + +const manifest = (id: string, permissions: string[]) => + ({ id, name: `Pkg ${id}`, version: '1.0.0', permissions: permissions.map((name) => ({ name, label: name })) }) as any; + +describe('SchemaRegistry.securityCatalogNamesHeldBy', () => { + let registry: SchemaRegistry; + beforeEach(() => { + registry = new SchemaRegistry({ multiTenant: false, collisionPolicy: 'error' }); + }); + + it('answers the permission-set names a package declared, and no other package\'s', () => { + registry.installPackage(manifest('com.acme.tasks', ['tasks_user', 'tasks_admin'])); + registry.installPackage(manifest('com.acme.other', ['other_user'])); + + expect(registry.securityCatalogNamesHeldBy('com.acme.tasks', 'permission')).toEqual(['tasks_admin', 'tasks_user']); + expect(registry.securityCatalogNamesHeldBy('com.acme.tasks', 'position')).toEqual([]); + }); + + it('a disabled package still holds its names', () => { + registry.installPackage(manifest('com.acme.tasks', ['tasks_user'])); + registry.disablePackage('com.acme.tasks'); + + expect(registry.securityCatalogNamesHeldBy('com.acme.tasks', 'permission')).toEqual(['tasks_user']); + }); + + it('nothing once the package is uninstalled — which is why the doors read it first', () => { + registry.installPackage(manifest('com.acme.tasks', ['tasks_user'])); + registry.uninstallPackage('com.acme.tasks'); + + expect(registry.securityCatalogNamesHeldBy('com.acme.tasks', 'permission')).toEqual([]); + }); +}); diff --git a/packages/objectql/src/registry.ts b/packages/objectql/src/registry.ts index ad4d4f72c67..b44796831ba 100644 --- a/packages/objectql/src/registry.ts +++ b/packages/objectql/src/registry.ts @@ -2505,6 +2505,29 @@ export class SchemaRegistry { } } + /** + * Every `type` name `packageId` holds in the security catalog: its claims, + * its composite `:` item slots, and any bare slot stamped + * with it — sorted, and unfiltered by disable (a disabled package is still + * installed, and still holds its names). + * + * Read by the uninstall doors just BEFORE {@link uninstallPackage}, which + * releases all of it: the cleanups run after the withdrawal, and this is the + * last moment the names are known (ADR-0090 D5, ADR-0131 D4). + */ + securityCatalogNamesHeldBy(packageId: string, type: SecurityCatalogType): string[] { + const names = new Set(); + for (const [name, claimant] of this.securityCatalogClaims.get(type) ?? []) { + if (claimant === packageId) names.add(name); + } + const prefix = `${packageId}:`; + for (const [key, item] of this.metadata.get(type) ?? []) { + if (key.startsWith(prefix)) names.add(key.slice(prefix.length)); + else if ((item as { _packageId?: unknown } | null | undefined)?._packageId === packageId) names.add(key); + } + return [...names].sort(); + } + /** * The cold-boot half of the rule (`security-catalog-namespace.ts`, "The cold * boot"; maintainer ruling letter A on #22307, record 6063176077): every diff --git a/packages/plugins/plugin-security/src/bootstrap-declared-permissions.ts b/packages/plugins/plugin-security/src/bootstrap-declared-permissions.ts deleted file mode 100644 index da70bca61e6..00000000000 --- a/packages/plugins/plugin-security/src/bootstrap-declared-permissions.ts +++ /dev/null @@ -1,522 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * bootstrapDeclaredPermissions — seed stack-declared `permissions` into - * `sys_permission_set` (ADR-0086 D5; the exact sibling of - * `bootstrapDeclaredPositions`). - * - * `stack.permissions` has always been declarable and runtime-ENFORCED (the - * evaluator resolves declared sets through the metadata registry), but it was - * never materialized as `sys_permission_set` records — the ADR-0078 - * inert-metadata smell: the admin surface (which reads the table) can't see a - * package's sets, uninstall is undefined, and no provenance axis exists. This - * seeder closes that gap: - * - * - each declared set is upserted by `(name, organization_id)` with `managed_by: 'package'` and - * `package_id` = the registering package (`_packageId` stamped by the - * SchemaRegistry / ADR-0010 `applyProtection`, with the spec-level - * `packageId` (ADR-0086 D3) as the author-declared fallback); - * - IDEMPOTENT + UPGRADE-AWARE: a row this seeder owns - * (`managed_by:'package'`, same `package_id`) is re-seeded on every boot so - * the record always reflects the shipped declaration (version bumps - * included). Rows owned by a DIFFERENT package are skipped loudly; - * - env-authored rows are NEVER clobbered: `managed_by` of - * `platform`/`user` — or absent (legacy/pre-provenance rows, including the - * platform defaults inserted by `bootstrapPlatformAdmin`) — is left alone. - * - * Runs on `kernel:ready` after `bootstrapPlatformAdmin` (so the platform - * defaults keep their existing insert-once shape) and alongside - * `bootstrapDeclaredPositions`. - * - * The ENVIRONMENT door (env-scope metadata saves, the Setup data-door - * write-through, boot reconciliation) lives in `permission-set-projection.ts` - * (ADR-0094) — this module is the PACKAGE door only. Both project through the - * shared {@link permissionSetRowFields} row shape so they can never hydrate - * differently. - * - * [#10103] The PACKAGE door runs ONE PASS PER ORGANIZATION when a wall is in - * force: `sys_permission_set` spells its name index `unique: 'organization'`, - * and an organization-less row was measured unreadable by every principal on a - * walled deployment — Layer 0's strict `organization_id = :tenant` AND-composes - * over the driver's compatibility arm and the conjunction is the strict equality - * alone. `single` posture keeps exactly one organization-less pass. The - * ENVIRONMENT door is deliberately NOT converted: its organization-less residue - * (and `bootstrapPlatformAdmin`'s platform defaults) stays outside this change, - * unreaped and warned about loudly. See `per-organization-catalog.ts`. - */ - -import { - genId, - permissionSetRowFields, - recordDiffersFromBody, - tryInsert, - tryUpdate, - type PermissionSeedOutcome, - type ProjectionLogger, -} from './permission-set-projection.js'; -import { - buildExistingByName, - type ExistingByNameIndex, - type ExistingLookupResult, -} from './seed-name-lookup.js'; -import { defaultPermissionSets } from './objects/default-permission-sets.js'; -import { - createSeedWriteRefusals, - reportSeedWriteRefusals, - type SeedWriteRefusals, -} from './write-refusals.js'; -import { - resolveOwnOrganizationRow, - seedCtx, - warnOrganizationLessRows, -} from './organization-scope.js'; -import { - permissionSetNameCollisionDiagnostic, - permissionSetNameIsForeign, - reportPermissionSetNameCollisions, - type PermissionSetNameCollisionDiagnostic, -} from './permission-set-name-collision.js'; -// [#18091] This seeder's two remaining refusals, each with its own wording, -// token and record, over the one shared delivery rule. -import { - permissionSetDeclarationUnownedDiagnostic, - reportPermissionSetDeclarationUnowned, - reportPermissionSetRowsUnreadable, -} from './seed-refusal-diagnostics.js'; - -export type { PermissionSeedOutcome } from './permission-set-projection.js'; - -/** - * The per-name existence read used when no batched oracle was supplied (the - * ADR-0086 P2 publish materializer, which upserts exactly one set). Reports the - * same three outcomes the batched oracle does — a failed read must not read as - * "absent" on this path either. - */ -async function defaultLookup(ql: any, name: string, organizationId?: string): Promise { - let rows: any; - try { - // Limit 5, not 1, when scoped: a scoped read returns organization-less rows - // alongside this organization's own, and one row would be whichever the - // driver ordered first (#10103). - rows = await ql.find( - 'sys_permission_set', - { where: { name }, limit: organizationId ? 5 : 1 }, - { context: seedCtx(organizationId) }, - ); - } catch { - return { status: 'unknown' }; - } - // Bare array, driven against a real engine over a real `SqlDriver` — not - // inferred from `IDataEngine.find`'s declared `Promise`, which is not - // proof (this repo has a `find()` that resolves an envelope instead). The - // `{ records }` limb that stood here was dead; `engine-find-bare-array.pin.test.ts` - // pins this seam. A non-array is still `unknown` — never "no such row". - const list = Array.isArray(rows) ? rows : null; - if (list === null) return { status: 'unknown' }; - // [#10103] This organization's own row answers; an organization-less leftover - // is reported beside `absent` and never returned as `present`. One spelling of - // that question for the whole catalog — see `per-organization-catalog.ts`. - const { own, organizationLessResidue } = resolveOwnOrganizationRow(list, organizationId); - if (own) return { status: 'present', row: own }; - return organizationLessResidue - ? { status: 'absent', organizationLessResidue } - : { status: 'absent' }; -} - -interface SeedOptions { - logger?: ProjectionLogger; - /** - * Seed THIS organization's copies. Omitted = the `single`-posture pass, the - * one place an organization-less catalog row is the correct shape. - */ - organizationId?: string; - /** - * [#11532] Names that `bootstrapPlatformAdmin` still seeds organization-less - * on every boot — the PLATFORM BUCKET. Supplied by the caller because the - * caller is the one holding that array (`security-plugin.ts` hands the same - * `bootstrapPermissionSets` to the platform bootstrap and to the manifest), - * so the list is exact rather than inferred from a row's provenance. - * - * An organization-less row for one of these names is NOT a pre-fix leftover - * and the pre-fix remedy is a loop for it: re-initializing the deployment - * mints it again on the next boot. See {@link warnOrganizationLessRows}. - * - * Omitted falls back to the SHIPPED `defaultPermissionSets` — which is what - * `bootstrapPlatformAdmin` seeds unless the host passed - * `SecurityPluginOptions.defaultPermissionSets`. So the classification is - * right for every shipped composition even if this option is never threaded, - * and the option exists for the one case the fallback cannot know about: a - * host that overrode the array. Pass `[]` to state that this caller's - * deployment has no organization-less writer at all. - */ - platformBucketNames?: readonly string[]; -} - -/** - * Read declared metadata items of a type. The engine's SchemaRegistry - * (populated by `manifest.register` from the stack's `permissions` array, - * items provenance-stamped with `_packageId`) is the reliable source in every - * boot path; the metadata-service facade only surfaces these once the - * compiled-artifact loader runs (serve.ts). - * - * [#8378] The registered item IS the authoring document — there is no - * `{ name, content }` envelope to unwrap, so the `i?.content ?? i` this read - * used to carry is gone. Same measurement #7519 made for `MetadataFacade`, - * re-taken at this seam: `registerMetadataCollections` (objectql `engine.ts`) - * registers each stack-collection element as-is, and `loadMetaFromDb` registers - * `convertStoredItem(JSON.parse(record.metadata))` — the parsed body, never the - * `sys_metadata` row. Nothing in the tree produces the envelope. - * - * Removal is a FIX, not a tidy-up. The types read through here (`permission`, - * `capability`, `object`) all reject `content` as an unrecognized key, so - * whenever the key did appear the unwrap replaced a whole authoring document - * with one of its values — and `''`, falsy but non-nullish, passed `??` and - * then died at the `filter(Boolean)` below, dropping the item with no - * diagnostic at all. - */ -export function readDeclared(engine: any, type: string): any[] { - try { - const reg = engine?.registry; - if (reg?.listItems) { - return (reg.listItems(type) ?? []).filter(Boolean); - } - } catch { /* fall through */ } - return []; -} - -/** - * ADR-0086 two-doors — is this `sys_permission_set` row ENVIRONMENT-owned? - * - * The one spelling of the fact this seeder already acted on: a row the PACKAGE - * door does not own — `managed_by` `admin`/`platform`, the legacy `user`, or - * absent (pre-provenance rows, including `bootstrapPlatformAdmin`'s platform - * defaults) — is environment-authored config that package materialization - * never clobbers. {@link upsertPackagePermissionSet}'s last branch and the boot - * loop's unowned-vs-environment split both ask THIS predicate, so the two can - * never disagree about which rows are the environment's. - */ -function isEnvironmentOwnedRow(row: any): boolean { - return !!row && typeof row === 'object' && row.managed_by !== 'package'; -} - -/** - * [#21669] Does the environment already own a row under this name? - * - * The engine registry the boot loop walks holds MORE than package - * declarations: `loadMetaFromDb` hydrates every env-wide `sys_metadata` - * `permission` row into the same collection, and a set an environment - * authored itself — the Setup Clone action, a Setup "New", a metadata-door - * save — carries no package id there, because none exists. Read as a - * declaration, such an item is "unowned"; it is not a declaration at all. Its - * row stands, the environment door owns it, and Setup lists it. - * - * Answered from the batched oracle the loop already holds — no extra read. - * The row is this pass's own, or — on a per-organization pass — the - * organization-less row the ENVIRONMENT door writes (`permission-set- - * projection.ts` is deliberately not per-organization; see this file's - * header). `unknown` is NOT an answer: a read that failed cannot prove the - * environment owns the name, so the caller falls through to the unowned - * refusal exactly as before. - */ -async function environmentOwnsName(existingByName: ExistingByNameIndex, name: string): Promise { - const lookup = await existingByName.get(name); - const row = lookup.status === 'present' - ? lookup.row - : lookup.status === 'absent' ? lookup.organizationLessResidue : undefined; - return isEnvironmentOwnedRow(row); -} - -/** - * Upsert ONE declared/published PermissionSet body into `sys_permission_set` - * under the owning `packageId`, applying the ADR-0086 provenance rules - * (own-row re-seed, foreign-package refuse, env-authored never clobbered). - * Shared by the boot seeder (every declared set) and the publish-time - * materializer (ADR-0086 P2 — a package-door set promoted from a draft). Returns - * a one-hot outcome so callers can aggregate. - */ -export async function upsertPackagePermissionSet( - ql: any, - ps: any, - packageId: string | null | undefined, - logger?: SeedOptions['logger'], - opts?: { - /** - * Existence oracle to consult instead of this function's own per-name - * `SELECT` (#10946). The boot loop passes ONE batched read covering every - * declared name; the publish materializer, which upserts a single set, - * passes nothing and keeps the per-name read. - */ - existingByName?: ExistingByNameIndex; - /** - * Seed THIS organization's copy (#10103). Omitted = the pre-existing - * organization-less behaviour, which the `single`-posture pass and the - * ADR-0086 P2 publish materializer both want. - */ - organizationId?: string; - /** Collects names whose pre-fix organization-less row is still standing. */ - residue?: string[]; - /** - * Collects writes the database REFUSED, so the pass can report them once - * instead of returning a zero that reads as "nothing to do". Passed by the - * boot catalog loop; the ADR-0086 P2 publish materializer passes nothing - * and keeps its own honest outcome (`success: false` with a reason when it - * materialized nothing). - */ - refusals?: SeedWriteRefusals; - /** - * [#17516] Collects set-name collisions so the pass reports them ONCE - * instead of a line per dropped set. Passed by the boot catalog loop; the - * ADR-0086 P2 publish materializer passes nothing and the refusal is - * reported at the branch instead — ⛔ never dropped, which is the whole - * point of this card. - */ - collisions?: PermissionSetNameCollisionDiagnostic[]; - }, -): Promise { - const out: PermissionSeedOutcome = { seeded: 0, updated: 0, unchanged: 0, unreadable: 0, skippedEnvAuthored: 0, skippedForeign: 0, skippedUnowned: 0 }; - if (!ps?.name) return out; - // A `managed_by:'package'` row without a `package_id` would make uninstall - // undefined again — the exact ambiguity ADR-0086 D3 exists to remove — so a - // set with no resolvable owner is skipped rather than materialized unowned. - if (!packageId) { - out.skippedUnowned += 1; - // [#18091] ⛔ The refusal is unchanged — an unowned row would re-create the - // ADR-0086 D3 ambiguity. What changed is that it arrives. Measured on the - // pre-fix tree with no logger, through BOTH doors that reach this branch - // (the boot loop and the ADR-0086 P2 publish materializer, which passes no - // collector): author-visible console lines = 0. - // - // [#18571] And a counter, because the LINE is not the programmatic half: a - // caller that reads no log at all — a boot report, a test — asked the - // outcome and got six zeros back, indistinguishable from a pass with - // nothing to do. Both doors above increment it; the boot loop forwards it. - // - // Its own wording, because the consequence is not the capability axis': - // the declared set stays runtime-enforced and only the RECORD is missing. - reportPermissionSetDeclarationUnowned(logger, permissionSetDeclarationUnownedDiagnostic({ - name: String(ps.name), - ...(opts?.organizationId ? { organizationId: opts.organizationId } : {}), - })); - return out; - } - - // ⛔ Three outcomes, not two (#10946 / #3807). `unknown` — the read FAILED — - // is not "no such row": inserting on it would re-create a set that already - // exists, and on a batched read one failure speaks for every declared name at - // once. Declining is the answer; the caller's warn reports it. - const organizationId = opts?.organizationId; - const lookup = opts?.existingByName - ? await opts.existingByName.get(String(ps.name)) - : await defaultLookup(ql, String(ps.name), organizationId); - if (lookup.status === 'unknown') { - out.unreadable += 1; - return out; - } - // [#10103] `absent` can still carry a PRE-FIX organization-less row that is - // merely VISIBLE here through the driver's compatibility arm. It is not this - // organization's row, so the copy below is created either way — the leftover - // is only reported. Reading it as "already seeded" is the silent no-op the - // per-organization catalog exists to prevent. - if (lookup.status === 'absent' && lookup.organizationLessResidue && ps.name) { - opts?.residue?.push(String(ps.name)); - } - const existing = lookup.status === 'present' ? lookup.row : undefined; - if (!existing?.id) { - const row = { - id: genId('ps'), - name: ps.name, - ...permissionSetRowFields(ps), - active: true, - package_id: packageId, - managed_by: 'package', - }; - const created = await tryInsert(ql, 'sys_permission_set', row, organizationId, opts?.refusals); - if (created) { - out.seeded += 1; - // A batched oracle is a snapshot taken before the loop — tell it about - // the row we just made, so a name declared twice in one batch still - // reaches the collision branch below instead of a second insert. - opts?.existingByName?.remember(String(ps.name), row); - } - return out; - } - - if (!isEnvironmentOwnedRow(existing)) { - if (!permissionSetNameIsForeign(existing.package_id, packageId)) { - // Our own row — re-seed so the record always reflects the shipped/published - // declaration (idempotent; covers version bumps without bookkeeping). - // - // [#10946] "Idempotent" was implemented as "write the same columns every - // time", which on a remote libsql/Turso database is two HTTP round trips - // per set on every boot to change nothing. `recordDiffersFromBody` is the - // SAME comparison the ADR-0094 boot reconciler already trusts to decide - // whether a record drifted, over exactly the columns - // `permissionSetRowFields` writes — so a row it calls equal is a row this - // UPDATE could not have changed. - // - // ⚠️ The skip is on EQUALITY, never on "we have seen this name". A row - // whose stored value differs — a version bump, a hand-edit, a partially - // applied write — still gets its UPDATE, because dropping that leg would - // turn the loop into a no-op that reconciles nothing while showing a - // beautiful round-trip curve. - if (!recordDiffersFromBody(existing, ps)) { - out.unchanged += 1; - } else if (await tryUpdate(ql, 'sys_permission_set', { id: existing.id, ...permissionSetRowFields(ps) }, organizationId, opts?.refusals)) { - out.updated += 1; - } - } else { - // [#17516] The SKIP is unchanged and correct — ADR-0086 D4: a package - // never writes into a foreign record. What changed is that it is no - // longer invisible. - // - // ⚠️ The premise this branch used to state — "Package-namespaced object - // api names make set-name collisions a packaging bug, not a merge case" - // — is FALSIFIED by ADR-0130 D1, which lets N packages co-own one - // namespace (the ADR records it under "What was NOT decided"). A - // collision is therefore a legal configuration that gets MORE common as - // co-ownership lands, not a packaging error that should never happen. So - // the author reading it is the normal case, not the pathological one. - // - // ⛔ And the old line did not refuse loudly, whatever it claimed: - // `logger?.warn?.(…)` is optionally chained TWICE, so a caller passing no - // logger produced NO OUTPUT AT ALL and an entire declared permission set - // disappeared with one counter moved. The report now goes through - // `reportPermissionSetNameCollisions`, which prints with no sink injected - // (#10556: silent-by-declaration is rejected), and the diagnostic RECORD - // travels back on the outcome so a caller that reads no log at all — a - // boot report, a test — can still ask what happened. - out.skippedForeign += 1; - const diagnostic = permissionSetNameCollisionDiagnostic({ - name: String(ps.name), - declaredBy: packageId, - ownedBy: typeof existing.package_id === 'string' ? existing.package_id : null, - ...(organizationId ? { organizationId } : {}), - }); - out.collisions = [diagnostic]; - // The boot loop collects and reports ONCE per pass. The ADR-0086 P2 - // publish materializer upserts a single set and passes no collector, so - // it reports here — it has no pass to summarise, and inheriting the old - // silence is the one outcome this card forbids. - if (opts?.collisions) opts.collisions.push(diagnostic); - else reportPermissionSetNameCollisions(logger, [diagnostic], organizationId); - } - return out; - } - - // {@link isEnvironmentOwnedRow}: `admin`/`platform`/`user` — or absent - // (legacy rows, incl. bootstrapPlatformAdmin defaults): env-authored config. - // Never clobbered by package materialization. - out.skippedEnvAuthored += 1; - return out; -} - -/** - * [#11532] The names the SHIPPED `bootstrapPlatformAdmin` seeds - * organization-less on every boot. Computed once from the same declaration the - * platform bootstrap iterates, so the two cannot drift within a release. - */ -const SHIPPED_PLATFORM_BUCKET_NAMES: readonly string[] = defaultPermissionSets - .map((ps) => ps.name) - .filter((n): n is string => typeof n === 'string' && n !== ''); - -export async function bootstrapDeclaredPermissions( - ql: any, - metadataService: any, - options: SeedOptions = {}, -): Promise { - const out: PermissionSeedOutcome = { seeded: 0, updated: 0, unchanged: 0, unreadable: 0, skippedEnvAuthored: 0, skippedForeign: 0, skippedUnowned: 0 }; - if (!ql || typeof ql.find !== 'function' || typeof ql.insert !== 'function') return out; - - let sets: any[] = readDeclared(ql, 'permission'); - if (sets.length === 0) { - try { - const listed = metadataService?.list?.('permission'); - sets = typeof (listed as any)?.then === 'function' ? await listed : (listed ?? []); - } catch { sets = []; } - } - if (!Array.isArray(sets) || sets.length === 0) return out; - - // [#10946] ONE existence read for the whole declaration, before the loop — - // the set of names is known in full here. See `seed-name-lookup.ts` for why - // a read that cannot ANSWER must not be read as "none of them exist". - const organizationId = options.organizationId; - const existingByName = await buildExistingByName( - ql, - 'sys_permission_set', - sets.map((ps) => ps?.name), - options.logger, - organizationId, - ); - // Names for which a PRE-FIX organization-less row is still standing. This - // organization's own row is created regardless — the leftover is reported, - // never treated as "already seeded" (#10103). - const residue: string[] = []; - // One log per pass, not per refused row: a legacy platform-wide unique index - // refuses EVERY declared permission set, and a line each would bury the remedy. - const refusals = createSeedWriteRefusals(); - // [#17516] Declared sets dropped because another package owns the name. - const collisions: PermissionSetNameCollisionDiagnostic[] = []; - - for (const ps of sets) { - if (!ps?.name) continue; - // Registry provenance first (ADR-0010 `_packageId`), author-declared - // spec `packageId` (ADR-0086 D3) as fallback. - const packageId: string | undefined = ps._packageId ?? ps.packageId ?? undefined; - // [#21669] Tell an ENVIRONMENT-owned set apart from a package declaration - // BEFORE judging ownership. A registry item with no package id whose row - // the environment already owns (a Setup clone: `managed_by:'admin'`, no - // `package_id`) is not an unowned declaration — it is not a declaration — - // and the `permission_set_declaration_unowned` warning told the operator, - // on every boot and every `metadata:reloaded`, that Setup could not see a - // set Setup lists and edits. It lands where every package declaration - // over an environment row lands: `skippedEnvAuthored`, never clobbered. - // ⛔ A declaration with no owner AND no environment row still reaches the - // unowned refusal below, unchanged — that warning is true. - if (!packageId && await environmentOwnsName(existingByName, String(ps.name))) { - out.skippedEnvAuthored += 1; - continue; - } - const r = await upsertPackagePermissionSet(ql, ps, packageId, options.logger, { existingByName, organizationId, residue, refusals, collisions }); - out.seeded += r.seeded; - out.updated += r.updated; - out.unchanged += r.unchanged; - out.unreadable += r.unreadable; - out.skippedEnvAuthored += r.skippedEnvAuthored; - out.skippedForeign += r.skippedForeign; - out.skippedUnowned += r.skippedUnowned; - } - - if (organizationId) { - warnOrganizationLessRows( - options.logger, - 'sys_permission_set', - residue, - organizationId, - options.platformBucketNames ?? SHIPPED_PLATFORM_BUCKET_NAMES, - ); - } - // Before the counts, so an operator reads WHY the count is zero beside it. - reportSeedWriteRefusals(options.logger, refusals, organizationId); - // [#17516] Said once per pass, and said even when no logger was injected — - // the whole defect was that this refusal reached nobody. The records go back - // on the outcome too, for a caller that reads no log at all. - reportPermissionSetNameCollisions(options.logger, collisions, organizationId); - if (collisions.length > 0) out.collisions = collisions; - if (out.unreadable > 0) { - // Said once, with the count: these sets were neither seeded nor reconciled - // because the record could not be READ. Silence here would read exactly - // like "everything was already in order". - // [#18091] …and said even when no logger was injected: silence here reads - // exactly like "everything was already in order", which is the whole defect. - reportPermissionSetRowsUnreadable(options.logger, { - unreadable: out.unreadable, - total: sets.length, - ...(organizationId ? { organizationId } : {}), - }); - } - - options.logger?.info?.('[security] declared permission sets seeded into sys_permission_set (ADR-0086 D5)', { - ...out, total: sets.length, ...(organizationId ? { organization: organizationId } : {}), - }); - return out; -} diff --git a/packages/plugins/plugin-security/src/bootstrap-platform-admin-refused-existence-read.test.ts b/packages/plugins/plugin-security/src/bootstrap-platform-admin-refused-existence-read.test.ts index d57f5e44193..6e35b982e44 100644 --- a/packages/plugins/plugin-security/src/bootstrap-platform-admin-refused-existence-read.test.ts +++ b/packages/plugins/plugin-security/src/bootstrap-platform-admin-refused-existence-read.test.ts @@ -1,22 +1,20 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. /** - * [#22169] `bootstrapPlatformAdmin`'s existence read: a read that did not + * [#22169] `bootstrapPlatformAdmin`'s one catalog read: a read that did not * answer is not an absent row. * - * The seed loop reads each default permission set BY NAME and inserts when it - * finds none. Its `tryFind` answers `[]` for a refused read as well as for an - * empty table, and the loop inserted on both — so on a table without the - * per-organization unique index a refused read minted a second row of a set - * that already existed, and a table holding such duplicates can never build - * that index again. Measured on a real engine with that index dropped: two - * declared sets, two INSERTs, under a refused read. + * [ADR-0131 D3/D4] The bootstrap writes no `sys_permission_set` row. It still + * READS the legacy `admin_full_access` row an older release seeded, because + * that row's id is how the holder scan reaches a grant written before the + * name column existed (leg C, restrict-only). A refused read cannot say no + * such grant exists, so the promotion is withheld — loudly — rather than risk + * minting a second administrator. * - * The double below has no `update` or `delete` member — the seed pass without - * `resync` calls neither — so it is not an engine double those dispatch - * contracts govern. Its `find` answers from its rows, bounded by the caller's - * `limit`; the refused read is a spy laid over it, so the two inputs the pass - * must tell apart — an answer, and a throw — come from one table. + * The double has no `update` or `delete` member — the bootstrap calls neither — + * so it is not an engine double those dispatch contracts govern. Its `find` + * answers from its rows, bounded by the caller's `limit`; the refused read is a + * spy laid over it. */ import { describe, it, expect, afterEach, vi } from 'vitest'; @@ -26,7 +24,7 @@ afterEach(() => { vi.restoreAllMocks(); }); function makeQl(seedRows: any[] = []) { const rows: any[] = seedRows.map((r) => ({ ...r })); - const inserted: any[] = []; + const inserted: Array<{ object: string; data: any }> = []; return { rows, inserted, @@ -40,9 +38,7 @@ function makeQl(seedRows: any[] = []) { return typeof q?.limit === 'number' ? matched.slice(0, q.limit) : matched; }, async insert(object: string, data: any) { - if (object !== 'sys_permission_set') return null; - inserted.push({ ...data }); - rows.push({ ...data }); + inserted.push({ object, data: { ...data } }); return { id: data.id }; }, }; @@ -64,43 +60,30 @@ function capture() { return { warns, logger: { info: () => {}, warn: (msg: string, meta?: any) => warns.push({ msg, meta }) } }; } -describe('#22169 — bootstrapPlatformAdmin declines the insert when its existence read was refused', () => { - it('a refused read inserts NOTHING, and says so once for the whole pass', async () => { +describe('#22169 — bootstrapPlatformAdmin withholds the promotion when its legacy read was refused', () => { + it('a refused read writes NOTHING, reports the read that failed, and says so once', async () => { const ql = makeQl([{ id: 'ps_existing', name: 'admin_full_access' }]); refuseSetReads(ql); const { warns, logger } = capture(); - const r = await bootstrapPlatformAdmin(ql, [set('admin_full_access'), set('member_default'), set('viewer_readonly')], { logger }); + const r = await bootstrapPlatformAdmin(ql, [set('admin_full_access'), set('member_default')], { logger }); - expect(ql.inserted, '⛔ no row minted on a read that did not answer').toEqual([]); - expect(r.seeded).toBe(0); - const lines = warns.filter((w) => w.msg.includes('could not be read')); - expect(lines, 'one line per pass, never one per set').toHaveLength(1); - expect(lines[0]!.meta).toMatchObject({ - unreadable: 3, total: 3, names: ['admin_full_access', 'member_default', 'viewer_readonly'], - }); - // The promotion half reports the read that failed, not a set that is "missing". + expect(ql.inserted, '⛔ nothing written on a read that did not answer').toEqual([]); expect(r.reason).toBe('admin_permission_set_unreadable'); + expect(r.adminPromoted).toBe(false); + expect(warns.filter((w) => w.msg.includes('promotion WITHHELD'))).toHaveLength(1); }); - it('control: an EMPTY answer is still "absent" — a fresh install seeds every default set', async () => { + it('control: an EMPTY answer is "no legacy row" — the pass goes on, and writes no catalog row', async () => { const ql = makeQl(); const { warns, logger } = capture(); const r = await bootstrapPlatformAdmin(ql, [set('admin_full_access'), set('member_default')], { logger }); - expect(ql.inserted.map((row) => row.name)).toEqual(['admin_full_access', 'member_default']); - expect(r.seeded).toBe(2); - expect(warns.filter((w) => w.msg.includes('could not be read'))).toEqual([]); - }); - - it('control: an existing row is found and left alone — no insert, no unreadable line', async () => { - const ql = makeQl([{ id: 'ps_existing', name: 'member_default' }]); - const { warns, logger } = capture(); - - await bootstrapPlatformAdmin(ql, [set('member_default')], { logger }); - - expect(ql.inserted).toEqual([]); - expect(warns.filter((w) => w.msg.includes('could not be read'))).toEqual([]); + expect(ql.inserted.filter((w) => w.object === 'sys_permission_set')).toEqual([]); + // No users in this double, so the pass ends at "nobody to promote yet" — + // past the legacy read, which is the point. + expect(r.reason).toBe('no_users'); + expect(warns.filter((w) => w.msg.includes('promotion WITHHELD'))).toEqual([]); }); }); diff --git a/packages/plugins/plugin-security/src/bootstrap-platform-admin.ts b/packages/plugins/plugin-security/src/bootstrap-platform-admin.ts index e67616aba1e..edf2a164d98 100644 --- a/packages/plugins/plugin-security/src/bootstrap-platform-admin.ts +++ b/packages/plugins/plugin-security/src/bootstrap-platform-admin.ts @@ -3,12 +3,8 @@ /** * bootstrapPlatformAdmin — first-boot platform admin promotion. * - * Two responsibilities, both idempotent and run on `kernel:ready`: - * - * 1. **Seed `sys_permission_set` rows** for each `defaultPermissionSets` - * entry (admin_full_access / member_default / viewer_readonly). - * - * 2. **Answer the platform-admin question, POSTURE-KEYED** (#11184 ruling + * One responsibility, idempotent and run on `kernel:ready`: **answer the + * platform-admin question, POSTURE-KEYED** (#11184 ruling * 2026-08-23; re-anchored by #11663, maintainer acceptance 2026-08-25, * leg L4 = #11974): * - `single`: promote the first registered human user by inserting a @@ -48,31 +44,12 @@ * `ensureDefaultOrganization`). Install that plugin to get * multi-tenant bootstrap. * - * ## Provenance of the seeded permission-set rows (commit 712e185db, ruled 2026-08-15) - * - * The seed insert stamps `managed_by: 'platform'` **explicitly**, so a fresh - * install's default sets are platform-owned and `os meta resync` reconciles - * them — which is what #2705 built the resync flag for. This also puts the - * seeder in line with its two siblings this package had (the built-in position - * and system capability seeders, both since retired by ADR-0131), which always - * stamped `'platform'` rather than inheriting a default. - * - * ⚠️ **Installs created BEFORE that ruling carry `'admin'` on these rows.** - * The pre-ruling insert omitted `managed_by` altogether, so the value came from - * the declared `defaultValue: 'admin'` in `objects/sys-permission-set.object.ts` - * — measured on a real engine (commit db923a3a8: a seeded row stored `'admin'`, and a real - * resync returned `resynced 0 / resyncSkipped 8`, skipping every shipped - * default set). - * - * For those legacy rows the resync SKIP stands, permanently and by decision: - * a stored `'admin'` is **indistinguishable** between "the old seeder's field - * default" and "an administrator took this set over in Setup". So there is - * deliberately **no migration and no restamp** — rewriting them to `'platform'` - * would make genuine admin customizations reconcilable and could silently - * overwrite them on the next `os meta resync`. Report, don't rewrite. A legacy - * install that wants the platform defaults reconciled has to re-own the rows - * deliberately (or re-seed with `--fresh`); that is an operator's choice to - * make, not one a boot should make on their behalf. + * [ADR-0131 D3/D4] It writes no `sys_permission_set` row. The platform's + * default sets (admin_full_access / member_default / viewer_readonly …) are + * definitions this plugin ships on its manifest and the security catalog + * serves; the promotion grant names its set. The materialization that used to + * seed those rows insert-once — and the `os meta resync` reconcile of them — + * retired with the rest of the catalog seeders. */ import { postureEnforcesWall, type PermissionSet } from '@objectstack/spec/security'; @@ -85,12 +62,6 @@ import { import { normalizePlatformAdminEmail, resolvePlatformAdminEmails } from '@objectstack/core'; import type { SeedSettlementSnapshot } from '@objectstack/spec/contracts'; import { claimSeedOwnership } from './claim-seed-ownership.js'; -import { - createSeedWriteRefusals, - reportSeedWriteRefusals, - type SeedWriteRefusals, -} from './write-refusals.js'; -import { reportThroughSink } from './seed-refusal-sink.js'; import { resolvePlatformAdminStanding, type PlatformAdminStandingEntry, @@ -105,6 +76,7 @@ import { serializePlatformAdminStandingSnapshot, } from './platform-admin-standing-audit.js'; import { GRANT_SET_NAME_FIELD, grantSetNameOf } from './grant-permission-set-name.js'; +import { reportThroughSink } from './seed-refusal-sink.js'; /** * The order the standing-audit read states TO THE DRIVER. @@ -219,7 +191,7 @@ async function recordPlatformAdminStandingChange( // for that context to be forgotten — it is also a second row on the // tenant-audit write-call-site census for one write. let insertRefusal: unknown; - await tryInsert(ql, PLATFORM_ADMIN_STANDING_LEDGER, row, undefined, (e) => { + await tryInsert(ql, PLATFORM_ADMIN_STANDING_LEDGER, row, (e) => { insertRefusal = e; }); if (insertRefusal !== undefined) { @@ -251,24 +223,6 @@ interface BootstrapOptions { */ error?: (message: string, meta?: any) => void; }; - /** - * [#2705] Force re-materialization of the default permission-set rows from - * the compiled declaration. - * - * Default (`false`) keeps the insert-once shape: an existing row is left - * untouched so an admin's Setup customizations survive every restart. This is - * correct for prod boot. - * - * `os meta resync` sets it to `true` to reconcile the DB rows to the shipped - * `dist` after a source edit — the dev loop that insert-once otherwise makes - * silently stale (a changed default set is served with its OLD value until a - * `--fresh` wipe). Only platform-owned rows (`managed_by` absent or - * `'platform'`) are overwritten. Rows carrying any other provenance are left - * alone: `'user'` / `'admin'` (taken over in Setup — or, on an install older - * than commit 712e185db, seeded before the platform stamped its own rows) and - * `'package'` (owned by package metadata). - */ - resync?: boolean; /** * The seed pipeline's tally at the moment this bootstrap runs, read by the * caller through the published `seed-settlement` contract. @@ -277,7 +231,7 @@ interface BootstrapOptions { * the claim pass is the only step here whose answer depends on whether the * platform's own seeder has finished writing, and a pass that cannot say so * reports "claimed 0 of 0" for both "nothing to claim" and "nothing had - * landed yet". Absent for callers with no kernel context (`os meta resync`), + * landed yet". Absent for callers with no kernel context, * which the claim reports as `unattested` rather than guessing. */ seedSettlement?: SeedSettlementSnapshot | undefined; @@ -391,40 +345,22 @@ async function tryFind( } } -// ⛔ The `catch` RECORDS the refusal before it answers, when the caller passed -// a log to record into. Answering `null`/`false` alone is what made a refused -// write indistinguishable from "nothing to do": `seeded` never grows, the pass -// returns normally, and the boot reports a successful seed of zero rows. Still -// no rethrow — this pass reports, it does not decide whether the deployment -// boots. See `reportSeedWriteRefusals` in `write-refusals.ts`. +// Never rethrows — this pass reports, it does not decide whether the +// deployment boots. A caller that must tell a refused write from "nothing to +// do" passes `onRefusal` and is told. async function tryInsert( - ql: any, object: string, data: any, refusals?: SeedWriteRefusals, - // Symmetric with {@link tryFind}'s observer, and for the same reason: a - // caller outside the SEED pass needs the refusal itself, not a `null` that - // the seed reporter will later summarize on a channel that is not its own. + ql: any, object: string, data: any, + // Symmetric with {@link tryFind}'s observer, and for the same reason. onRefusal?: (error: unknown) => void, ): Promise { try { return await ql.insert(object, data, { context: SYSTEM_CTX }); } catch (e) { - refusals?.record(object, e); onRefusal?.(e); return null; } } -async function tryUpdate( - ql: any, object: string, data: any, refusals?: SeedWriteRefusals, -): Promise { - try { - await ql.update(object, data, { context: SYSTEM_CTX }); - return true; - } catch (e) { - refusals?.record(object, e); - return false; - } -} - function genId(prefix: string): string { const rand = Math.random().toString(36).slice(2, 10); const ts = Date.now().toString(36); @@ -507,40 +443,6 @@ export function shouldReplayBootstrapFor(opCtx: { return resolvePlatformAdminEmails().emails.length > 0; } -/** - * The platform-owned definition facets of a default permission set — the - * fields the runtime resolver hydrates back into ExecutionContext - * (`resolve-authz-context.ts` → systemPermissions / tabPermissions / object & - * field masks). Single source for both the first-boot insert and the `#2705` - * resync update so the two paths can never drift. Identity/provenance columns - * (`id`, `name`, `active`, `managed_by`, `package_id`) are deliberately NOT - * here — resync reconciles the declaration, never the ownership. - * - * [commit 712e185db] `managed_by` must stay out of this helper even though the seed insert - * now stamps it. Both paths share these fields, so adding it here would make - * every resync RESTAMP the row it reconciles -- silently converting a legacy - * `admin`-owned row (which may be a real Setup takeover) into a platform-owned - * one and clobbering it on that same pass. The insert stamps provenance at its - * own call site precisely so the resync update cannot. - * - * `description` / `adminScope` are read defensively: neither is on the typed - * PermissionSet shape (name/label/objects/fields/...), but both persist when a - * runtime declaration provides them without tripping the dts typecheck. - */ -function platformOwnedFields(ps: PermissionSet): Record { - return { - label: ps.label ?? ps.name, - description: (ps as any).description ?? null, - object_permissions: JSON.stringify(ps.objects ?? {}), - field_permissions: JSON.stringify(ps.fields ?? {}), - system_permissions: JSON.stringify(ps.systemPermissions ?? []), - row_level_security: JSON.stringify(ps.rowLevelSecurity ?? []), - tab_permissions: JSON.stringify(ps.tabPermissions ?? {}), - // [ADR-0090 D12] Delegated-admin scope travels with the set row. - admin_scope: (ps as any).adminScope ? JSON.stringify((ps as any).adminScope) : null, - }; -} - /** * The permission set whose unscoped grant makes a user the platform admin under * `single`. Named once so the bootstrap's guard and @@ -753,17 +655,15 @@ export async function findExistingPlatformAdmin( } /** - * Persist seed permission sets and answer the posture-keyed platform-admin - * question: promote the first registered human user under `single`, report - * config-derived standing (and write nothing) under walled postures. Safe to - * call multiple times. + * Answer the posture-keyed platform-admin question: promote the first + * registered human user under `single`, report config-derived standing (and + * write nothing) under walled postures. Safe to call multiple times. */ export async function bootstrapPlatformAdmin( ql: any, bootstrapPermissionSets: PermissionSet[], options: BootstrapOptions = {}, ): Promise<{ - seeded: number; adminPromoted: boolean; reason?: string; /** Count of seeded rows re-owned to the freshly-promoted admin. */ @@ -787,10 +687,6 @@ export async function bootstrapPlatformAdmin( * to get right). */ adminUserId?: string; - /** [#2705] Existing platform-owned rows reconciled to dist under `resync`. */ - resynced?: number; - /** [#2705] Existing rows left untouched by `resync` (admin/package-owned). */ - resyncSkipped?: number; /** * [commit 9b9581b11] WHY this target was chosen, when one was. `declared-owner` means * `OS_PLATFORM_OWNER_EMAIL` named them; `oldest-authenticable` means nobody @@ -810,128 +706,22 @@ export async function bootstrapPlatformAdmin( }> { const logger = options.logger; if (!ql || typeof ql.find !== 'function' || typeof ql.insert !== 'function') { - return { seeded: 0, adminPromoted: false, reason: 'objectql_unavailable' }; + return { adminPromoted: false, reason: 'objectql_unavailable' }; } - // 1. Seed permission set rows. - const seeded: Record = {}; - let resynced = 0; - let resyncSkipped = 0; - // One log per pass, not per refused row: a legacy platform-wide unique index - // refuses EVERY default permission set, and a line each would bury the remedy. - const refusals = createSeedWriteRefusals(); - // [#22169] Names whose existence read was REFUSED, so nothing was inserted - // for them — reported once below, after the loop. - const unreadable: string[] = []; - for (const ps of bootstrapPermissionSets) { - if (!ps.name) continue; - // ⛔ "Refused" is not "absent". `tryFind` answers `[]` for both, and this - // loop used to INSERT on both: on a table without the per-organization - // unique index (remote tables built before its retrofit) a refused read - // minted a second row of a set that already existed, and a table holding - // such duplicates can never build that index again. The observer tells - // the two apart; a read that could not answer declines the insert. - let readRefused = false; - const existing = await tryFind( - ql, 'sys_permission_set', { name: ps.name }, 1, undefined, undefined, - () => { readRefused = true; }, - ); - if (readRefused) { - unreadable.push(ps.name); - continue; - } - if (existing.length > 0 && existing[0].id) { - const row = existing[0]; - seeded[ps.name] = row.id; - // Insert-once by default: an existing row is never clobbered on restart, - // which is what protects an admin's Setup edits. Under `resync` - // (`os meta resync`, #2705) reconcile the row to the shipped dist so a dev - // source edit takes effect without `--fresh` -- but only for rows the - // platform still owns. - if (options.resync) { - if (!row.managed_by || row.managed_by === 'platform') { - if (await tryUpdate(ql, 'sys_permission_set', { id: row.id, ...platformOwnedFields(ps) }, refusals)) { - resynced += 1; - } - } else { - resyncSkipped += 1; - // [commit 712e185db] Neutral by ruling: state the provenance and the action, and - // claim NOTHING about intent. This used to say "(intentional - // override)", which is a lie for every row on a pre-ruling install -- - // there the only writer may have been this very seeder one call - // earlier, inheriting `defaultValue: 'admin'` rather than any admin - // deciding anything. The stored value cannot tell the two apart, so - // the log must not pretend it can. - logger?.warn?.( - `[security] resync left ${ps.name} untouched — row is ${row.managed_by}-owned`, - { name: ps.name, managedBy: row.managed_by }, - ); - } - } - continue; - } - const id = genId('ps'); - const created = await tryInsert(ql, 'sys_permission_set', { - id, - name: ps.name, - ...platformOwnedFields(ps), - active: true, - // [commit 712e185db] Stamp provenance EXPLICITLY rather than letting it fall to the - // declaration's `defaultValue: 'admin'`. Without this the platform's own - // default sets are stored indistinguishably from admin-authored ones, so - // `os meta resync` skips every single one of them (measured in commit db923a3a8: - // resynced 0 / resyncSkipped 8) -- the exact inverse of what #2705 built - // the flag for. Matched the built-in position and system capability - // seeders (both since retired by ADR-0131), which stamped `'platform'`. - managed_by: 'platform', - }, refusals); - if (created?.id) seeded[ps.name] = created.id; - else if (created) seeded[ps.name] = id; - } - - // Reported HERE rather than at function end: every `return` below this point - // is an early exit of the PROMOTION half, and the catalog seed above is - // finished either way. Placing it at the end would make the diagnosis - // conditional on how promotion happened to resolve. - reportSeedWriteRefusals(logger, refusals); - if (unreadable.length > 0) { - // [#22169] ONCE per pass with the names, never a line per set: a refused - // read usually means the whole table is unreadable, and a per-name line - // would bury its own remedy. `warn`, through the sink derivation the - // sibling seeders' unreadable lines use, so it is heard with no logger - // injected: nothing was written, so nothing claims to have landed. - reportThroughSink( - logger, - '[security] platform default permission sets left untouched — their sys_permission_set rows could not ' + - 'be read, so none was inserted: a read that did not answer is not an absent row, and inserting on it ' + - 'duplicates a set that may already exist. A set that is genuinely absent is seeded by the next boot ' + - 'whose read answers.', - { unreadable: unreadable.length, total: bootstrapPermissionSets.length, names: unreadable }, - ); - } - - const seededCount = Object.keys(seeded).length; - // [#11532] Under a walled posture these rows are organization-less BY RULING - // (#10103, 2026-08-20) and unreadable through the wall, and the catalog pass - // that runs next reports them once per organization. Saying so HERE is what - // stops the operator's first sight of them being a warning that calls the - // platform's own output legacy state: the fresh walled rig logged - // `seeded: 8` with nothing to indicate the rows carried no organization at - // all. Not a behaviour change — the seeding above is byte-identical. - if (seededCount > 0 && postureEnforcesWall(resolveTenancyPosture())) { - logger?.info?.( - '[security] platform default permission sets seeded WITHOUT an organization (the platform ' + - 'bucket) — ruled 2026-08-20 and unchanged: the platform-admin grant points at the ' + - 'admin_full_access row by id. Under a walled posture they are unreadable through the ' + - 'tenant wall; each organization gets its own copies from the per-organization catalog ' + - 'pass, so no principal is missing a set.', - { seeded: seededCount, names: Object.keys(seeded).sort() }, - ); - } - // Attached to every return below so `os meta resync` can report the reconcile - // outcome even when admin promotion short-circuits (the common dev case: a DB - // that already has an admin returns `already_have_admin`). - const resyncCounts = { resynced, resyncSkipped }; + // 1. [ADR-0131 D3/D4] No catalog row is written: the platform's default + // sets are definitions this plugin ships on its manifest, served from + // the registry, and a grant names its set. The one read left is the + // LEGACY `admin_full_access` row an older release seeded, whose id is how + // leg C of {@link findPlatformAdminGrantHolder} reaches a grant written + // before the name column existed. ⛔ "Refused" is not "absent": a read + // that did not answer cannot say no such grant exists, so the promotion + // is withheld below rather than risk minting a second administrator. + let legacyAdminRowUnreadable = false; + const legacyAdminRows = await tryFind( + ql, 'sys_permission_set', { name: PLATFORM_ADMIN_PERMISSION_SET_NAME }, 1, undefined, undefined, + () => { legacyAdminRowUnreadable = true; }, + ); // 2. The platform-admin question, POSTURE-KEYED (#11184 ruling 2026-08-23, // verbatim: 「1509 选择 env 指定 owner 邮箱」; re-anchored by #11663 L4): @@ -954,18 +744,28 @@ export async function bootstrapPlatformAdmin( // [ADR-0131 D4] The grant names `admin_full_access`; the set is the catalog // definition this plugin ships, so no `sys_permission_set` row is needed to - // grant it. The seeded row's id, when there is one, feeds only the legacy + // grant it. The legacy row's id, when there is one, feeds only the // restrict-only leg of the holder scan ({@link findPlatformAdminGrantHolder}). if (!bootstrapPermissionSets.some((ps) => ps.name === PLATFORM_ADMIN_PERMISSION_SET_NAME)) { - return { seeded: seededCount, adminPromoted: false, reason: 'admin_permission_set_missing', ...resyncCounts }; + return { adminPromoted: false, reason: 'admin_permission_set_missing' }; } - if (unreadable.includes(PLATFORM_ADMIN_PERMISSION_SET_NAME)) { + if (legacyAdminRowUnreadable) { // [#22169] The set's row table did not answer, so the legacy leg cannot ask // whether an unnamed grant already makes someone the administrator; no - // promotion is made on a question this pass could not ask. - return { seeded: seededCount, adminPromoted: false, reason: 'admin_permission_set_unreadable', ...resyncCounts }; + // promotion is made on a question this pass could not ask. Said through + // the sink derivation, so it is heard with no logger injected. + reportThroughSink( + logger, + '[security] platform-admin promotion WITHHELD: the legacy admin_full_access sys_permission_set row ' + + 'could not be read, so this pass cannot ask whether a grant written before the name column existed ' + + 'already makes someone the platform administrator. Nothing was written; the next pass whose read ' + + 'answers decides.', + { name: PLATFORM_ADMIN_PERMISSION_SET_NAME }, + ); + return { adminPromoted: false, reason: 'admin_permission_set_unreadable' }; } - const adminPsId: string | undefined = seeded[PLATFORM_ADMIN_PERMISSION_SET_NAME]; + const adminPsId: string | undefined = + typeof legacyAdminRows[0]?.id === 'string' && legacyAdminRows[0].id !== '' ? legacyAdminRows[0].id : undefined; // ── Does this deployment ALREADY have a platform admin? (commit 1c83ca226) ─ // @@ -1010,7 +810,6 @@ export async function bootstrapPlatformAdmin( // config-derived standing, or to refuse fail-closed when nobody is declared. if (!walled && unscopedHolder) { return { - seeded: seededCount, adminPromoted: false, reason: 'already_have_admin', // The promotion is a no-op forever; the CLAIM is not. The seed-settle @@ -1018,7 +817,6 @@ export async function bootstrapPlatformAdmin( // has run at all, asks {@link findExistingPlatformAdmin}, which answers // with this same holder (see `adminUserId` above). ...(unscopedHolder.user_id ? { adminUserId: String(unscopedHolder.user_id) } : {}), - ...resyncCounts, ...grantScanCounts, }; } @@ -1040,10 +838,8 @@ export async function bootstrapPlatformAdmin( if (logger?.warn) logger.warn(withheld); else logger?.info?.(withheld); return { - seeded: seededCount, adminPromoted: false, reason: 'admin_grant_unnamed', - ...resyncCounts, ...grantScanCounts, }; } @@ -1071,7 +867,7 @@ export async function bootstrapPlatformAdmin( // process). The startup half (walled + undeclared ⇒ REFUSE BOOT, naming // the variable) lives in plugin-auth's `init()`; this is the // defense-in-depth line for paths that reach the bootstrap without that - // guard (`os meta resync`, embeddings without plugin-auth). + // guard (embeddings without plugin-auth). // // [#11663 L5] ⛔ This line is no longer skipped when a legacy holder exists. // It used to be, and the reason it used to be has expired: L4's skip was @@ -1106,10 +902,8 @@ export async function bootstrapPlatformAdmin( if (logger?.error) logger.error(message); else logger?.warn?.(message); return { - seeded: seededCount, adminPromoted: false, reason: 'walled_owner_email_undeclared', - ...resyncCounts, ...grantScanCounts, }; } @@ -1142,10 +936,8 @@ export async function bootstrapPlatformAdmin( // CHANGE of standing, plus the first-boot baseline. await recordPlatformAdminStandingChange(ql, standing, logger); return { - seeded: seededCount, adminPromoted: false, reason: 'walled_config_derived', - ...resyncCounts, ...grantScanCounts, }; } @@ -1251,10 +1043,8 @@ export async function bootstrapPlatformAdmin( if (!inserted) { logger?.warn?.(`[security] failed to grant admin_full_access to first user ${chosen.email ?? chosen.id}`); return { - seeded: seededCount, adminPromoted: false, reason: 'insert_failed', - ...resyncCounts, ...grantScanCounts, }; } @@ -1286,12 +1076,10 @@ export async function bootstrapPlatformAdmin( } return { - seeded: seededCount, adminPromoted: true, ownershipClaimed, adminUserId: String(chosen.id), basis: audit.basis, - ...resyncCounts, ...grantScanCounts, }; }; @@ -1454,7 +1242,6 @@ export async function bootstrapPlatformAdmin( if (logger?.warn) logger.warn(message); else logger?.info?.(message); return { - seeded: seededCount, adminPromoted: false, // [commit 9b9581b11, batch #100] The ruling allows either a distinct code or a // fold into `declared_owner_not_authenticable` with verification named @@ -1463,7 +1250,6 @@ export async function bootstrapPlatformAdmin( // reading a structured sink can tell "register a login" apart from // "click the link in your mailbox". reason: unverifiedOnly ? 'declared_owner_not_verified' : 'declared_owner_not_authenticable', - ...resyncCounts, ...grantScanCounts, }; } @@ -1487,7 +1273,7 @@ export async function bootstrapPlatformAdmin( } if (scannedHumans === 0) { logger?.info?.('[security] no human users yet — first sign-up will be promoted to platform admin'); - return { seeded: seededCount, adminPromoted: false, reason: 'no_users', ...resyncCounts, ...grantScanCounts }; + return { adminPromoted: false, reason: 'no_users', ...grantScanCounts }; } if (!target) { // [#14348] Humans exist, but not one of them can sign in. Measured on a @@ -1522,10 +1308,8 @@ export async function bootstrapPlatformAdmin( else logger?.info?.(truncation); } return { - seeded: seededCount, adminPromoted: false, reason: 'no_authenticable_user', - ...resyncCounts, ...grantScanCounts, }; } diff --git a/packages/plugins/plugin-security/src/builtin-positions.boot.test.ts b/packages/plugins/plugin-security/src/builtin-positions.boot.test.ts index 11adcea9334..f88b4004cd0 100644 --- a/packages/plugins/plugin-security/src/builtin-positions.boot.test.ts +++ b/packages/plugins/plugin-security/src/builtin-positions.boot.test.ts @@ -39,7 +39,9 @@ * ## The goldens * * The census and the write ledger: no boot writes a `sys_position` row in any - * scenario, and an existing database keeps exactly the rows it had. The census + * scenario, and an existing database keeps exactly the rows it had. Since + * stage 6b-2 no boot writes a `sys_permission_set` row either, and the grant + * golden holds without one. The census * instrument is proven able to see a row by a positive control (one * organization-authored create, read back as exactly one row). * @@ -183,6 +185,14 @@ interface Booted { ledger: string[]; /** The rows the boot left, one string per row. */ census: string[]; + /** [C3 stage 6b-2] The `sys_permission_set` rows the boot left, by name. */ + permissionSetCensus: string[]; +} + +/** Every `sys_permission_set` row's name and organization, past every scope. */ +async function permissionSetCensus(engine: ObjectQL): Promise { + const rows = (await engine.find('sys_permission_set', { where: {}, limit: 5000, context: SYS })) as any[]; + return rows.map((r) => `${r.name} | ${r.organization_id ?? '-'}`).sort(); } /** Every `sys_position` row, one string per row, past every scope. */ @@ -337,22 +347,14 @@ async function boot(scenario: Scenario): Promise { } recording = false; - return { engine, metadata, ledger, census: await positionCensus(engine) }; + return { engine, metadata, ledger, census: await positionCensus(engine), permissionSetCensus: await permissionSetCensus(engine) }; } /** The four principals' resolved grants — needs the organization and its memberships. */ async function grantsByPrincipal(engine: ObjectQL, posture: 'single' | 'isolated'): Promise> { - const orgCtx = { isSystem: true, tenantId: ORG }; - // Walled: the organization's own copies of the sets its grants point at - // (the per-organization catalog), cloned from the platform bucket the boot - // just wrote — as the name-column equivalence suite does. - if (posture === 'isolated') { - for (const name of ['organization_admin', 'organization_admin_no_bypass', 'viewer_readonly']) { - const [bucket] = await engine.find('sys_permission_set', { where: { name }, context: SYS }); - const { id: _id, created_at: _c, updated_at: _u, ...rest } = bucket as any; - await engine.insert('sys_permission_set', { ...rest, id: `ps_${name}_${ORG}`, organization_id: ORG }, { context: orgCtx } as any); - } - } + // [C3 stage 6b-2] No `sys_permission_set` row exists in any posture: every + // grant below names its set, and the resolver takes the set from the + // security catalog (ADR-0131 D3/D4). // [ADR-0131 D4] The boot's organization-admin backfill already granted it by // name, in every posture — no organization copy of the set is needed — so // the reconcile finds nothing to do. @@ -360,13 +362,9 @@ async function grantsByPrincipal(engine: ObjectQL, posture: 'single' | 'isolated expect(reconciled.action).toBe('noop'); // The member's set, granted through the data door by an administrator. - const [viewer] = await engine.find('sys_permission_set', { - where: { name: 'viewer_readonly', ...(posture === 'isolated' ? { organization_id: ORG } : {}) }, - context: SYS, - }); await engine.insert( 'sys_user_permission_set', - { user_id: 'usr_member', permission_set: (viewer as any).name }, + { user_id: 'usr_member', permission_set: 'viewer_readonly' }, { context: { userId: 'usr_orgadmin', positions: [], permissions: ['qa_admin'], tenantId: ORG } } as any, ); @@ -498,6 +496,27 @@ for (const name of Object.keys(SCENARIOS) as ScenarioName[]) { expect(await positionCensus(engine)).toEqual(census); }); + // [C3 stage 6b-2] The catalog seeders are retired: no boot writes a + // `sys_permission_set` row — not the platform defaults, not a package's + // sets, not an organization's copies (the late organization included) — + // while the first platform administrator and the organization + // administrator are still granted (the grant golden below) by name. + it('writes no sys_permission_set row, and still grants the first platform admin and the organization admin', async () => { + const { engine, permissionSetCensus: rows } = booted.get(name)!; + expect(rows).toEqual([]); + const holders = (await engine.find('sys_user_permission_set', { where: {}, limit: 100, context: SYS })) as any[]; + const held = holders.map((g) => `${g.user_id} -> ${g.permission_set}`).sort(); + if (scenario.posture === 'single') expect(held).toContain('usr_admin -> admin_full_access'); + if (scenario.organizationAtBoot) { + expect(held.some((h) => h.startsWith('usr_orgadmin -> organization_admin'))).toBe(true); + } + // Positive control: the census sees a row — one insert reads back as one. + await engine.insert('sys_permission_set', { id: 'ps_census_control', name: 'zz_census_control', label: 'Control' }, { context: SYS } as any); + expect(await permissionSetCensus(engine)).toEqual(['zz_census_control | -']); + await engine.delete('sys_permission_set', { where: { id: 'ps_census_control' }, context: SYS } as any); + expect(await permissionSetCensus(engine)).toEqual([]); + }); + if (scenario.organizationAtBoot) { it('grants every principal what it was granted before the declarations', async () => { const { engine } = booted.get(name)!; diff --git a/packages/plugins/plugin-security/src/cleanup-package-permissions.test.ts b/packages/plugins/plugin-security/src/cleanup-package-permissions.test.ts index b2c666f58b2..70b8d20d101 100644 --- a/packages/plugins/plugin-security/src/cleanup-package-permissions.test.ts +++ b/packages/plugins/plugin-security/src/cleanup-package-permissions.test.ts @@ -150,3 +150,33 @@ describe('cleanupPackagePermissions (#2747)', () => { }); }); }); + +describe('[ADR-0131 D4, ADR-0090 D5] the grants naming a set the package declared', () => { + // A set served from the registry has no row: the door hands the cleanup the + // names it captured before the withdrawal, and the grants naming them go. + function withNamedGrants() { + const ql = makeQl(); + ql.tables.sys_user_permission_set.push( + { id: 'ups_named_1', user_id: 'u2', permission_set: 'crm_catalog_user' }, + { id: 'ups_named_2', user_id: 'u3', permission_set: 'crm_catalog_user' }, + { id: 'ups_named_other', user_id: 'u2', permission_set: 'other_catalog_set' }, + ); + return ql; + } + + it('revokes every user grant naming a declared set, and only those', async () => { + const ql = withNamedGrants(); + const out = await cleanupPackagePermissions(ql, 'com.example.crm', undefined, { permission: ['crm_catalog_user'] }); + + expect(out.userGrants).toBe(3); // the legacy row's grant (ups_1) and the two naming the declared set + expect(ql.tables.sys_user_permission_set.map((r: any) => r.id)).toEqual(['ups_env', 'ups_named_other']); + }); + + it('control — handed no names, a grant naming a catalog set survives: the cleanup is as it was', async () => { + const ql = withNamedGrants(); + const out = await cleanupPackagePermissions(ql, 'com.example.crm'); + + expect(out).toEqual({ sets: 2, positionBindings: 2, userGrants: 1, suggestions: 2, refused: 0 }); + expect(ql.tables.sys_user_permission_set.map((r: any) => r.id)).toEqual(['ups_env', 'ups_named_1', 'ups_named_2', 'ups_named_other']); + }); +}); diff --git a/packages/plugins/plugin-security/src/cleanup-package-permissions.ts b/packages/plugins/plugin-security/src/cleanup-package-permissions.ts index 0d9d2eb35e8..0287a152e7b 100644 --- a/packages/plugins/plugin-security/src/cleanup-package-permissions.ts +++ b/packages/plugins/plugin-security/src/cleanup-package-permissions.ts @@ -110,6 +110,7 @@ export async function cleanupPackagePermissions( ql: any, packageId: string, logger?: { info?: (m: string, meta?: any) => void; warn?: (m: string, meta?: any) => void }, + declared?: { permission?: readonly string[] }, ): Promise { const out: PackagePermissionCleanupOutcome = { sets: 0, positionBindings: 0, userGrants: 0, suggestions: 0, refused: 0, @@ -151,6 +152,21 @@ export async function cleanupPackagePermissions( } out.sets = await deleteRows(ql, 'sys_permission_set', sets, refusals); + // [ADR-0131 D3/D4, ADR-0090 D5] The package's sets are served from the + // registry, so no row above stands for them: the grants naming them are + // reached by the names the uninstall door captured before it withdrew the + // package. A name belongs to one holder (the registry refuses a second at + // install), so every grant naming it was a grant of this package's set. + // Position bindings have no name column; a legacy one is reached above. + for (const name of new Set(declared?.permission ?? [])) { + if (typeof name !== 'string' || name === '') continue; + out.userGrants += await deleteRows( + ql, 'sys_user_permission_set', + await tryFind(ql, 'sys_user_permission_set', { permission_set: name }), + refusals, + ); + } + // Suggestion rows in every status — reinstall re-prompts fresh (D5). out.suggestions = await deleteRows( ql, 'sys_audience_binding_suggestion', diff --git a/packages/plugins/plugin-security/src/declared-capability-context.ts b/packages/plugins/plugin-security/src/declared-capability-context.ts index 37b9dbfb40b..90baecf594f 100644 --- a/packages/plugins/plugin-security/src/declared-capability-context.ts +++ b/packages/plugins/plugin-security/src/declared-capability-context.ts @@ -44,7 +44,31 @@ */ import type { AnchorBindingContext } from '@objectstack/spec/security'; -import { readDeclared } from './bootstrap-declared-permissions.js'; + +/** + * Read the declared metadata items of a type from the engine's + * `SchemaRegistry` — every package manifest's items, provenance-stamped with + * `_packageId`, plus the environment items `loadMetaFromDb` hydrates into the + * same collection. The registry is the catalog's one home (ADR-0131 D3); this + * is a read of it, never of a catalog row. + * + * [#8378] The registered item IS the authoring document — there is no + * `{ name, content }` envelope to unwrap: `registerMetadataCollections` + * (objectql `engine.ts`) registers each stack-collection element as-is, and + * `loadMetaFromDb` registers the parsed body, never the `sys_metadata` row. + * + * An unreadable or absent registry answers `[]`; the callers fall back to the + * metadata service or fail closed on an empty answer. + */ +export function readDeclared(engine: any, type: string): any[] { + try { + const reg = engine?.registry; + if (reg?.listItems) { + return (reg.listItems(type) ?? []).filter(Boolean); + } + } catch { /* fall through */ } + return []; +} /** * Read this stack's declared authorization capabilities as an diff --git a/packages/plugins/plugin-security/src/everyone-rederivation-on-permission-mutation.test.ts b/packages/plugins/plugin-security/src/everyone-rederivation-on-permission-mutation.test.ts new file mode 100644 index 00000000000..5b9509d29ff --- /dev/null +++ b/packages/plugins/plugin-security/src/everyone-rederivation-on-permission-mutation.test.ts @@ -0,0 +1,121 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#22801 → A, C3 stage 6b-2] Every landed `permission` mutation derives the + * `everyone` anchor again, off `onMetadataMutation` (the publish materializer + * it rode retired with the seeder, ADR-0131 D3). Pinned at the wiring: which + * events re-derive, and that a bootstrap re-run never adds a second listener. + * What a derivation declares is U2's own suite. + */ + +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { assertEngineFindOnePredicate, assertEngineUpdateDispatch, type EngineFindOneQueryInput } from '@objectstack/metadata-core'; +import { SecurityPlugin } from './security-plugin.js'; + +afterEach(() => { vi.restoreAllMocks(); }); + +type MutationListener = (evt: { type: string; name: string; state: 'active' | 'draft' | 'deleted' }) => void; + +/** Field equality plus `$in`; anything else is REFUSED, never read as "no rows". */ +function rowMatches(row: any, where: Record = {}): boolean { + return Object.entries(where).every(([k, v]) => { + if (k.startsWith('$')) throw new Error(`this double cannot answer '${k}'`); + const actual = row?.[k] ?? null; + if (v !== null && typeof v === 'object' && !Array.isArray(v)) { + const ops = Object.keys(v as Record); + if (ops.length === 1 && ops[0] === '$in') return ((v as any).$in as unknown[]).some((m) => (m ?? null) === actual); + throw new Error(`this double cannot answer ${JSON.stringify(ops)}`); + } + return actual === (v ?? null); + }); +} + +/** Boot the real plugin over an in-memory engine, with a protocol that exposes the mutation signal. */ +async function boot() { + const tables: Record = {}; + const ql: any = { + registerMiddleware: () => {}, + // Only the identity objects the plugin's boot gate requires. + getSchema: (name: string) => (name === 'sys_user' || name === 'sys_member' ? { name, fields: {} } : undefined), + registry: { listItems: () => [] }, + find: async (object: string, opts?: any) => { + const matched = (tables[object] ?? []).filter((r) => rowMatches(r, opts?.where ?? {})); + // The caller's bound, by presence, after the filter (`check:objectql-double-limit`). + return (typeof opts?.limit === 'number' ? matched.slice(0, opts.limit) : matched).map((r) => ({ ...r })); + }, + findOne: async (object: string, query?: EngineFindOneQueryInput) => { assertEngineFindOnePredicate(object, query); return null; }, + count: async () => 0, + insert: async (object: string, data: any) => { + const row = { id: data?.id ?? `${object}_${(tables[object] ?? []).length + 1}`, ...data }; + (tables[object] ??= []).push(row); + return { ...row }; + }, + update: async (_object: string, data: any, options?: any) => { assertEngineUpdateDispatch(data, options); return 0; }, + }; + const listeners: MutationListener[] = []; + const protocol = { + // Present so the projection wiring adds no listener of its own. + registerMutationProjector: vi.fn(), + onMetadataMutation: (listener: MutationListener) => { listeners.push(listener); return () => {}; }, + }; + const services: Record = { + manifest: { register: vi.fn() }, + objectql: ql, + metadata: { get: async () => null, list: async () => [] }, + protocol, + }; + const hook = vi.fn(); + const ctx: any = { + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, + registerService: vi.fn(), + getService: (name: string) => { if (!(name in services)) throw new Error(`no service: ${name}`); return services[name]; }, + hook, + }; + const derive = vi.spyOn(SecurityPlugin.prototype as any, 'declareEveryone').mockResolvedValue(undefined); + const plugin = new SecurityPlugin(); + await plugin.init(ctx); + await plugin.start(ctx); + const fire = async (event: string) => { + for (const [, cb] of hook.mock.calls.filter((c: any[]) => c[0] === event)) await cb(); + }; + const settle = async () => { for (let i = 0; i < 5; i++) await new Promise((r) => setTimeout(r, 0)); }; + return { ctx, fire, settle, listeners, derive }; +} + +describe('[#22801 → A] a landed permission mutation re-derives everyone', () => { + it('registers ONE listener, and a bootstrap re-run never adds a second', async () => { + const { ctx, fire, listeners } = await boot(); + await fire('kernel:ready'); + // Positive control: the bootstrap really completed. + expect(ctx.logger.info.mock.calls.filter((c: any[]) => String(c[0]).includes('platform bootstrap complete'))).toHaveLength(1); + expect(listeners).toHaveLength(1); + await fire('kernel:ready'); + expect(listeners).toHaveLength(1); + }); + + it('a published or deleted permission derives everyone again — whatever the body said', async () => { + const { fire, settle, listeners, derive } = await boot(); + await fire('kernel:ready'); + const before = derive.mock.calls.length; + + listeners[0]!({ type: 'permission', name: 'crm_sales_user', state: 'active' }); + await settle(); + expect(derive.mock.calls.length - before).toBe(1); + + listeners[0]!({ type: 'permission', name: 'crm_sales_user', state: 'deleted' }); + await settle(); + expect(derive.mock.calls.length - before).toBe(2); + }); + + it('a draft, or a mutation of another type, derives nothing', async () => { + const { fire, settle, listeners, derive } = await boot(); + await fire('kernel:ready'); + const before = derive.mock.calls.length; + + listeners[0]!({ type: 'permission', name: 'crm_sales_user', state: 'draft' }); + listeners[0]!({ type: 'position', name: 'sales_rep', state: 'active' }); + listeners[0]!({ type: 'object', name: 'crm_lead', state: 'active' }); + await settle(); + expect(derive.mock.calls.length - before).toBe(0); + }); +}); diff --git a/packages/plugins/plugin-security/src/grant-permission-set-name.writers.test.ts b/packages/plugins/plugin-security/src/grant-permission-set-name.writers.test.ts index b34933df9e1..7262d5eb04b 100644 --- a/packages/plugins/plugin-security/src/grant-permission-set-name.writers.test.ts +++ b/packages/plugins/plugin-security/src/grant-permission-set-name.writers.test.ts @@ -93,8 +93,8 @@ describe('[ADR-0131 D4] the grant writers in plugin-security write the set by na expect(grants).toHaveLength(1); expectNamedOnly(grants[0], 'admin_full_access'); - // The holder is read by name: with every catalog row gone it still answers. - await engine.delete('sys_permission_set', { where: {}, multi: true, context: SYS }); + // [C3 stage 6b-2] The bootstrap wrote no catalog row, and the holder is + // read by name with none. expect(await engine.find('sys_permission_set', { context: SYS })).toEqual([]); expect(await findExistingPlatformAdmin(engine, defaultPermissionSets)).toBe('usr_first'); }); diff --git a/packages/plugins/plugin-security/src/index.ts b/packages/plugins/plugin-security/src/index.ts index 7ba8d61c5dc..1d87a5bf8e4 100644 --- a/packages/plugins/plugin-security/src/index.ts +++ b/packages/plugins/plugin-security/src/index.ts @@ -54,11 +54,9 @@ export type { InvitationPlacementIntent, InvitationPlacementService, } from './invitation-placement.js'; -export { bootstrapDeclaredPermissions } from './bootstrap-declared-permissions.js'; // [#17516] The set-name collision diagnostic. EXPORTED because its whole -// purpose is to be the ONE derivation every door shares: the runtime door below -// raises it today, and the compile-time door (`os build` / `os validate`, which -// lives in another package) must consume these rather than re-deriving either +// purpose is to be the ONE derivation every door shares: the compile-time door +// (`os build` / `os validate`, which lives in another package) must consume these rather than re-deriving either // the predicate or the wording — that drift is what this card is about, one // layer up. export { @@ -72,32 +70,6 @@ export type { CollisionReportSink, PermissionSetNameCollisionDiagnostic, } from './permission-set-name-collision.js'; -// [#18091] The seeders' remaining refusals. EXPORTED on the criterion the -// block above states and no wider: a refusal an AUTHOR can cause has a second -// door by construction — the author-time one (`@objectstack/lint`, `os build` / -// `os validate`) — and a door that re-spells the token or re-derives the -// wording is the drift these modules exist to prevent. A package declaring a -// CURATED platform capability name, and a declaration carrying no owning -// package, are both decidable from the declaration alone with no database. -// ⛔ The two unreadable-rows summaries are deliberately NOT here: an unreadable -// database is a runtime condition no compile-time door can raise, so they stay -// package-private for the reason `position_name_fold_grant` does. -export { - CAPABILITY_DECLARATION_UNOWNED, - CAPABILITY_PLATFORM_NAME_REFUSED, - PERMISSION_SET_DECLARATION_UNOWNED, - capabilityDeclarationUnownedDiagnostic, - capabilityPlatformNameRefusedDiagnostic, - permissionSetDeclarationUnownedDiagnostic, - reportCapabilityDeclarationUnowned, - reportCapabilityPlatformNameRefused, - reportPermissionSetDeclarationUnowned, -} from './seed-refusal-diagnostics.js'; -export type { - CapabilityDeclarationUnownedDiagnostic, - CapabilityPlatformNameRefusedDiagnostic, - PermissionSetDeclarationUnownedDiagnostic, -} from './seed-refusal-diagnostics.js'; // [ADR-0094] sys_permission_set pure-projection machinery. export { permissionSetRowFields, @@ -119,7 +91,6 @@ export type { PackagePermissionCleanupOutcome } from './cleanup-package-permissi export { objectPostureGate, registerObjectPostureGate } from './object-posture-gate.js'; export type { ObjectPostureGateContext } from './object-posture-gate.js'; export { claimSeedOwnership } from './claim-seed-ownership.js'; -export { normalizeManagedByVocab } from './normalize-managed-by.js'; export { appDefaultPermissionSetName, appSecurityPluginOptions, @@ -149,18 +120,6 @@ export type { SuggestionReconcileOutcome, WithheldAcceptance, } from './suggested-audience-bindings.js'; -// [field report — rc→GA declared≠enforced surfacing] "declared ≠ enforced" -// per-set diagnostics (overlay_shadow / provenance_skip). -export { - computePermissionSetDriftDiagnostics, - persistPermissionSetDriftDiagnostics, - runPermissionSetDriftDiagnostics, -} from './permission-set-drift.js'; -export type { - PermissionSetDriftStatus, - PermissionSetDriftDiagnostic, - DriftDiagnosticsOptions, -} from './permission-set-drift.js'; // [maintainer ruling 2026-08-24 — 「同意 第一步(创业阶段,Salesforce 式)」] // Lock the base, clone to customize: the write-door provenance rule and its // refusals, plus the DETECTION READING for overlays that already exist. diff --git a/packages/plugins/plugin-security/src/normalize-managed-by.ts b/packages/plugins/plugin-security/src/normalize-managed-by.ts deleted file mode 100644 index e3f38ae0526..00000000000 --- a/packages/plugins/plugin-security/src/normalize-managed-by.ts +++ /dev/null @@ -1,196 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * normalizeManagedByVocab — heal legacy `managed_by` values on the RBAC - * catalogs to the unified tri-state vocabulary (A4 #2920). - * - * The three RBAC catalogs (`sys_capability`, `sys_permission_set`, - * `sys_position`) historically spoke three different provenance dialects: - * - capability: platform / package / admin (already canonical) - * - permission set: platform / package / user - * - position: system / config / user - * - * A4 unifies all three on **platform / package / admin**. New rows are written - * canonically by the seeders/projector; this reconciler rewrites the residual - * legacy values on rows those writers do NOT re-touch — env-authored permission - * sets stamped `'user'`, and older tenant positions stamped `'system'` / - * `'config'` / `'user'`. Built-in position rows and declared package sets - * self-heal on their own bootstrap upsert, so this only mops up the rest. - * - * NOT purely cosmetic: the system-row write gate's provenance map - * (SYSTEM_ROW_PROVENANCE in security-plugin.ts) branches on `managed_by` - * values, so it must recognize BOTH the canonical and the legacy vocabulary — - * renaming a stored value without updating that map silently disarms the gate - * (#2926 ①). Keep the two in lockstep whenever this vocabulary changes. - * Idempotent: canonical rows are skipped, so a re-run is a no-op. - * Non-fatal to boot, like the sibling boot reconcilers — but [#15840] no longer - * best-effort about its own reads: a catalog read that does not answer refuses - * the pass instead of reporting the same counts an already-canonical catalog - * reports. The `kernel:ready` caller catches the refusal and carries on. - * - * Runs on `kernel:ready` after the seeders, as `isSystem` (the field is - * `readonly`, so only a system write may set it). - */ - -const SYSTEM_CTX = { isSystem: true }; - -/** legacy value -> canonical value, per object. */ -const POSITION_MAP: Record = { - system: 'platform', - config: 'package', - user: 'admin', -}; -const PERMISSION_SET_MAP: Record = { - user: 'admin', -}; - -interface NormalizeOptions { - logger?: { - info: (message: string, meta?: Record) => void; - warn: (message: string, meta?: Record) => void; - /** - * [#15840] The level the ruling names for a refused pass. Optional, unlike - * its two siblings, so every caller that compiles today still compiles: it - * is an input this module asks for, not a channel it publishes. - * - * ⚠️ Three parameters, not two: the platform `Logger` contract - * (`packages/spec/src/contracts/logger.ts`) takes the `Error` in its OWN - * second argument at this level and only this level. Declaring the sibling - * `(message, meta)` shape here makes the real `ctx.logger` unassignable. - */ - error?: (message: string, error?: Error, meta?: Record) => void; - }; -} - -/** - * [#15840] Read the legacy rows, or REFUSE — never invent an empty catalog. - * - * The `catch { return []; }` that stood here is the read-seam invention rule's - * worst case, and it was measured (report 5553806224): an unreadable catalog and - * an already-canonical one were BYTE-IDENTICAL on both channels — the same - * `{ positions: 0, permissionSets: 0 }` and zero log lines at any level — while - * the row that needed healing stayed legacy. "I could not read the catalog" was - * reported as "the catalog is already canonical". - * - * #15840's ruling (decision batch #105 item 5, option A) is that a read fault - * REFUSES the normalisation pass for that batch and reports at `error`; ⛔ it - * never answers "already canonical". So the fault leaves this function as a - * throw, and {@link normalizeManagedByVocab} lets it out. - * - * The consumer contract for that throw already exists and is the reason a - * refusal is decidable here at all: `security-plugin.ts`'s `kernel:ready` - * bootstrap wraps this call in `try { … } catch { logger.warn('[security] - * managed_by vocab normalization failed (non-fatal)') }`, so boot proceeds and - * the remaining bootstrap steps still run. Nothing reached that handler before, - * because the fault was swallowed one frame below. - * - * ⚠️ Refusing is also the CHEAPER report. The report-and-continue option was - * measured at four lines per boot — this pass calls the read once per legacy - * value, three for `sys_position` and one for `sys_permission_set` — so a - * whole-catalog outage said the same thing four times. A refusal aborts at the - * first un-answered read, which is exactly one `error` line per refused boot. - * - * ⛔ Not a relaxation: the system-row write gate's provenance map recognizes - * BOTH the canonical and the legacy vocabulary (see the header note and - * #2926 ①), so rows left un-normalised by a refusal are still gated. Nothing is - * granted, widened or disarmed by declining to rewrite them; the next boot - * asks again. - */ -function readRefused(object: string, cause?: unknown): Error { - const why = cause === undefined ? 'the engine did not answer with a row array' : (cause as Error)?.message; - return new Error( - `[security] managed_by normalize REFUSED for ${object} — the catalog read did not answer, ` + - `so this pass cannot tell "already canonical" from "could not ask": ${why}`, - ); -} - -async function findOrRefuse(ql: any, object: string, where: any): Promise { - let rows: any; - try { - rows = await ql.find(object, { where, limit: 10_000, fields: ['id', 'managed_by'] }, { context: SYSTEM_CTX }); - } catch (e) { - throw readRefused(object, e); - } - // Bare array, driven — `engine-find-bare-array.pin.test.ts` boots a real - // engine over a real `SqlDriver` and pins this seam. The `{ records }` limb - // that stood here was dead code that read as a contract; a non-array answer - // is now a refusal for the same reason a throw is — the pass did not get the - // rows, so it must not report on them. - if (Array.isArray(rows)) return rows; - throw readRefused(object); -} - -async function normalizeObject( - ql: any, - object: string, - map: Record, - logger?: NormalizeOptions['logger'], -): Promise { - let updated = 0; - for (const [legacy, canonical] of Object.entries(map)) { - // Narrow equality scan per legacy value keeps the where-clause - // driver-portable (no IN / OR predicate). - let rows: any[]; - try { - rows = await findOrRefuse(ql, object, { managed_by: legacy }); - } catch (e) { - // [#15840] The one report the ruling names, emitted where the count that - // will NOT be returned is still known: rows healed before the refusal - // stay healed, and saying so is the difference between a refusal and a - // rollback. Reported once — the throw aborts the whole pass. - logger?.error?.((e as Error).message, e instanceof Error ? e : undefined, { - object, - legacyValue: legacy, - healedBeforeRefusal: updated, - consequence: - 'the remaining legacy rows keep their legacy managed_by; the write gate recognizes ' + - 'both vocabularies, so nothing is disarmed, and the next boot asks again', - }); - throw e; - } - for (const row of rows) { - if (!row?.id) continue; - try { - await ql.update(object, { id: row.id, managed_by: canonical }, { context: SYSTEM_CTX }); - updated += 1; - } catch (e) { - logger?.warn?.(`[security] managed_by normalize failed for ${object}:${row.id}`, { - error: (e as Error).message, - }); - } - } - } - return updated; -} - -/** - * Rewrite legacy `managed_by` values on `sys_permission_set` and `sys_position` - * to the unified tri-state vocab. Returns a per-object count of rows healed. - * - * [#15840] THROWS if a catalog read does not answer. The returned counts are an - * attestation — "these rows were legacy and are now canonical" — and a pass that - * could not read the catalog has nothing to attest, so it refuses rather than - * reporting `{ positions: 0, permissionSets: 0 }`, which is what an - * already-canonical catalog reports. Callers already handle this: the - * `kernel:ready` bootstrap catches it, reports at `warn` as non-fatal, and - * continues. An engine with no `find`/`update` at all is NOT a read fault and - * still returns zeros. - */ -export async function normalizeManagedByVocab( - ql: any, - options: NormalizeOptions = {}, -): Promise<{ permissionSets: number; positions: number }> { - if (!ql || typeof ql.find !== 'function' || typeof ql.update !== 'function') { - return { permissionSets: 0, positions: 0 }; - } - const positions = await normalizeObject(ql, 'sys_position', POSITION_MAP, options.logger); - const permissionSets = await normalizeObject(ql, 'sys_permission_set', PERMISSION_SET_MAP, options.logger); - const total = positions + permissionSets; - if (total > 0) { - options.logger?.info?.('[security] managed_by vocab normalized to platform/package/admin, the one vocabulary every RBAC catalog shares', { - positions, - permissionSets, - }); - } - return { permissionSets, positions }; -} diff --git a/packages/plugins/plugin-security/src/objects/sys-permission-set.object.ts b/packages/plugins/plugin-security/src/objects/sys-permission-set.object.ts index fe388882bfa..b9ac9972ca5 100644 --- a/packages/plugins/plugin-security/src/objects/sys-permission-set.object.ts +++ b/packages/plugins/plugin-security/src/objects/sys-permission-set.object.ts @@ -166,32 +166,10 @@ export const SysPermissionSet = ObjectSchema.create({ data: { provider: 'object', object: 'sys_permission_set' }, // [ADR-0094] Surface provenance + the customized flag so admins can tell // a packaged set (and whether they've overlaid it) from an env set. - // [field report — rc→GA declared≠enforced surfacing] `drift_status` - // alongside them: `customized` alone stays `0` on a row whose - // `managed_by` is itself wrong (the provenance-skip + overlay-shadow - // confound), so it is not a substitute for this column. - columns: ['label', 'name', 'managed_by', 'customized', 'drift_status', 'active', 'updated_at'], + columns: ['label', 'name', 'managed_by', 'customized', 'active', 'updated_at'], sort: [{ field: 'label', order: 'asc' }], pagination: { pageSize: 50 }, }, - // [field report — rc→GA declared≠enforced surfacing] The LOUD surface: - // every package-declared set whose enforced grants differ from the - // shipped artifact, naming the set AND the cause (`drift_status`), - // updated every boot by `runPermissionSetDriftDiagnostics`. An in-sync - // set is never in this view — `drift_status` is written `null`, not - // `'in_sync'` (see `permission-set-drift.ts`), so this filter is a data - // fact, not merely a display choice: noise here is what would make the - // next real drift easy to ignore. - drifted: { - type: 'grid', - name: 'drifted', - label: 'Needs Attention', - data: { provider: 'object', object: 'sys_permission_set' }, - columns: ['label', 'name', 'managed_by', 'drift_status', 'drift_detail', 'updated_at'], - filter: [{ field: 'drift_status', operator: 'is_not_null' }], - sort: [{ field: 'updated_at', order: 'desc' }], - pagination: { pageSize: 50 }, - }, }, fields: { @@ -292,7 +270,7 @@ export const SysPermissionSet = ObjectSchema.create({ maxLength: 255, description: 'Package that ships this permission set (absent = environment-authored). ' + - 'Populated by bootstrapDeclaredPermissions; makes package uninstall/upgrade well-defined.', + 'Set on rows an earlier release seeded for a package\'s set; no current writer sets it.', group: 'Provenance', }), @@ -302,8 +280,9 @@ export const SysPermissionSet = ObjectSchema.create({ // constrained `select` so the value is a labelled, i18n-able badge. // Back-compat: legacy env rows carry 'user' (== admin). No runtime path // branches on 'user' (every read keys on 'package' / 'platform', both - // unchanged), so those rows stay semantically correct; the boot normalizer - // (`normalizeManagedByVocab`) rewrites them to 'admin' idempotently. + // unchanged), so those rows stay semantically correct. The boot normalizer + // that rewrote them to 'admin' (`normalizeManagedByVocab`) retired with + // ADR-0131 C3 stage 6b-2, so such rows keep 'user'. managed_by: Field.select({ label: 'Managed By', required: false, @@ -312,9 +291,9 @@ export const SysPermissionSet = ObjectSchema.create({ description: "Record provenance, on the one platform / package / admin vocabulary that capabilities, " + "permission sets and positions all share: 'platform' = shipped by the " + - "platform; 'package' = versioned package metadata (re-seeded on upgrade, read-mostly " + - "for admins); 'admin' = created/owned in this environment by an administrator " + - "(live-edited, never touched by package seeding). Legacy rows may carry 'user' (== admin).", + "platform; 'package' = a row an earlier release seeded for a package's set " + + "(read-mostly for admins); 'admin' = created/owned in this environment by an administrator " + + "(live-edited). Legacy rows may carry 'user' (== admin).", options: [ { value: 'platform', label: 'Platform' }, { value: 'package', label: 'Package' }, @@ -337,44 +316,6 @@ export const SysPermissionSet = ObjectSchema.create({ group: 'Provenance', }), - // [field report — rc→GA declared≠enforced surfacing] Set when this - // package-declared set's ENFORCED grants differ from the shipped - // artifact — `runPermissionSetDriftDiagnostics` (permission-set-drift.ts) - // recomputes it every boot. Absent (null) means enforced == declared, OR - // this set is not currently package-declared — an in-sync set is never - // given a value here, on purpose (a badge that fires on every packaged - // set is noise, and noise is how the next real one gets ignored). - // ⚠️ NOT a substitute for, and not derived from, `customized`: that flag - // is forced `false` on a row whose `managed_by` is itself wrong (the - // provenance-skip + overlay-shadow confound measured on the field- - // reported environment), which is exactly the case this field still - // needs to catch. - drift_status: Field.select({ - label: 'Declaration Drift', - required: false, - readonly: true, - description: - "Cause of a declared≠enforced mismatch for this package-declared set. 'overlay_shadow' = a Studio-" + - "authored environment overlay is shadowing the packaged declaration. 'provenance_skip' = this row predates package provenance tracking, so boot sync " + - "treats it as environment-authored and never reconciles it with the package (no automated fix — file " + - "an issue / see the ops runbook for the manual adoption recipe). Absent = enforced grants match the " + - "shipped artifact, or this set is not currently package-declared.", - options: [ - { value: 'overlay_shadow', label: 'Overlay shadow' }, - { value: 'provenance_skip', label: 'Provenance skip' }, - { value: 'other', label: 'Drift (other)' }, - ], - group: 'Provenance', - }), - - drift_detail: Field.textarea({ - label: 'Drift Detail', - required: false, - readonly: true, - description: 'Human-readable detail for `drift_status` — names the grant-count mismatch and its cause.', - group: 'Provenance', - }), - // ── System ─────────────────────────────────────────────────── id: Field.text({ label: 'Permission Set ID', diff --git a/packages/plugins/plugin-security/src/organization-scope.ts b/packages/plugins/plugin-security/src/organization-scope.ts index 7ff44b69017..9dc7148da58 100644 --- a/packages/plugins/plugin-security/src/organization-scope.ts +++ b/packages/plugins/plugin-security/src/organization-scope.ts @@ -2,9 +2,9 @@ /** * Organization scoping for the RBAC catalog's per-organization passes: the - * system context one pass runs under, the organizations a boot sweep - * enumerates, the one read that tells an organization's own row apart from - * organization-less residue, and the loud guard that stands in place of a reap. + * system context one pass runs under and the one read that tells an + * organization's own row apart from organization-less residue (the sweep and + * the reap guard retired with their one caller, the seeder; #15204 stage 6b-2). * * ## Why the catalog is materialized per organization * @@ -66,8 +66,8 @@ * because it never touched a junction table; here the junctions ARE the * grants. * - * So the pass says so instead. {@link warnOrganizationLessRows} names the - * rows and names the remedy, and — this is the load-bearing half — the pass + * So the pass said so instead. Its guard (retired with the seeder) named the + * rows and named the remedy, and — this is the load-bearing half — the pass * still CREATES the organization's own copy. The failure shape it exists to * prevent is the silent no-op: a tenant-threaded pass sees the pre-fix * organization-less row through the driver's compatibility arm, reads the name @@ -78,9 +78,6 @@ * here", and every seeder in this catalog routes through it. */ -import { postureEnforcesWall, type TenancyPosture } from '@objectstack/spec/security'; -import type { SeedLogger } from './write-refusals.js'; - /** * How many organizations one boot-time seeding sweep enumerates. * @@ -92,8 +89,6 @@ import type { SeedLogger } from './write-refusals.js'; */ export const SEED_ORGANIZATION_SCAN_LIMIT = 500; -const ORGANIZATION_OBJECT = 'sys_organization'; - /** * The system context ONE seeding pass runs under. * @@ -107,52 +102,6 @@ export function seedCtx(organizationId?: string): { isSystem: true; tenantId?: s return organizationId ? { isSystem: true, tenantId: organizationId } : { isSystem: true }; } -/** Does this posture want per-organization catalog rows? */ -export function catalogIsPerOrganization(posture: TenancyPosture): boolean { - return postureEnforcesWall(posture); -} - -/** - * Enumerate the organizations whose catalog needs seeding. - * - * Returns `null` — not `[]` — when the read FAILS, because the two mean - * opposite things: zero organizations is "nothing to seed", an unreadable - * `sys_organization` is "we do not know". Conflating them turns an outage into - * a silent empty sweep, so a failure is warned and the caller must not proceed - * as though the installation had no tenants. - */ -export async function listSeedOrganizationIds( - ql: any, - logger?: SeedLogger, -): Promise { - let rows: any; - try { - rows = await ql.find(ORGANIZATION_OBJECT, { - fields: ['id'], - limit: SEED_ORGANIZATION_SCAN_LIMIT, - context: seedCtx(), - }); - } catch (e) { - logger?.warn?.( - '[security] could not enumerate organizations — the RBAC catalog was NOT seeded per ' + - 'organization at this call; seeding retries on the next boot and on organization creation', - { object: ORGANIZATION_OBJECT, error: (e as Error)?.message }, - ); - return null; - } - const ids = (Array.isArray(rows) ? rows : []) - .map((r: any) => r?.id) - .filter((id: unknown): id is string => typeof id === 'string' && id !== ''); - if (ids.length >= SEED_ORGANIZATION_SCAN_LIMIT) { - logger?.warn?.( - '[security] organization scan hit its bound — organizations past it are seeded when they are ' + - 'created and on the next boot sweep', - { scanned: ids.length, limit: SEED_ORGANIZATION_SCAN_LIMIT }, - ); - } - return ids; -} - /** The organization a stored row belongs to, `null` for an organization-less one. */ export function rowOrganizationId(row: any): string | null { return (row?.organization_id ?? row?.organizationId) ?? null; @@ -188,99 +137,3 @@ export function resolveOwnOrganizationRow( const residue = list.find((r) => rowOrganizationId(r) === null) ?? null; return { own, organizationLessResidue: residue }; } - -/** - * The machine-readable half of the guard below: WHY this organization-less row - * is visible to a per-organization pass. The two answers take opposite - * remedies, so the classification is a field rather than something a reader has - * to infer from prose (#11532). - */ -export type OrganizationLessRowOrigin = 'platform-bucket' | 'pre-fix-residue'; - -/** - * The loud guard that stands in place of a reap — and the ONE place that tells - * the platform bucket apart from a genuine pre-fix leftover. - * - * Called once per pass with everything the pass found, so an operator gets ONE - * actionable line per class rather than a warning per name. - * - * ## The two classes, and why conflating them was a defect (#11532) - * - * - **`pre-fix-residue`** — a row from before the per-organization conversion. - * Nothing regenerates it, so the ruled remedy holds: re-initialize the - * deployment (correct while it is pre-launch, which is the premise this whole - * repair was ruled on), or adopt each row by hand. - * - * - **`platform-bucket`** — a name `bootstrapPlatformAdmin` still seeds - * organization-less on EVERY boot (`platformBucketNames`). Calling one of - * these "pre-fix" tells an operator they are carrying legacy state they never - * had: on the measured fresh walled rig they were minted 1.3 s before the - * first organization existed, by the very code that then warned about them. - * And the pre-fix remedy does not terminate here — re-initializing recreates - * exactly these rows on the next boot, so the only branch that ends is hand - * adoption, which is also the branch an operator is least likely to pick and - * which hands a platform-wide bucket to one tenant. - * - * Membership is decided by NAME, not by `managed_by`, because the question the - * remedy turns on is "will a re-initialized deployment have this row again?" — - * and for these names it will, whatever provenance the current row carries (a - * pre-ruling install (before commit 712e185db) stores `'admin'` on the very same names). - * - * The pass that emits either warning has ALREADY created the organization's own - * copies — both describe rows beside that catalog, never a refusal to seed. - */ -export function warnOrganizationLessRows( - logger: SeedLogger | undefined, - object: string, - names: string[], - organizationId: string, - platformBucketNames?: Iterable, -): void { - if (names.length === 0) return; - const bucketNames = new Set(platformBucketNames ?? []); - const bucket = names.filter((n) => bucketNames.has(n)); - const residue = names.filter((n) => !bucketNames.has(n)); - - if (bucket.length > 0) { - logger?.warn?.( - `[security] organization-less ${object} rows for the PLATFORM BUCKET are visible to this ` + - `organization's pass. They are not leftovers from an older release: bootstrapPlatformAdmin ` + - `seeds these names without an organization on every boot, including the one that just ran, ` + - `and the ruling of 2026-08-20 keeps it that way (unreaped, reported, outside the ` + - `per-organization conversion) because the platform-admin grant points at the ` + - `admin_full_access row by id. This organization's own copies WERE created, so its catalog ` + - `is complete and no action is required. Re-initializing the deployment does NOT clear ` + - `them — the next boot mints them again. Adopting one by hand (stamping it with an ` + - `organization) does remove it from this list, but hands a platform-wide row to a single ` + - `tenant, so do that only if that is what you mean.`, - { - object, - organization: organizationId, - origin: 'platform-bucket' satisfies OrganizationLessRowOrigin, - names: [...bucket].sort(), - count: bucket.length, - }, - ); - } - - if (residue.length > 0) { - logger?.warn?.( - `[security] pre-fix organization-less ${object} rows are still present for names this ` + - `organization seeds — under a walled posture a row that belongs to no organization is ` + - `invalid state, not a platform-wide default. This organization's own rows WERE created, so ` + - `its catalog is complete; the leftovers below are readable through the driver's ` + - `compatibility arm and belong to nobody. Remedy: re-initialize the deployment, or adopt each ` + - `row by hand by stamping it with the organization that should own it. They are NOT deleted ` + - `automatically — grants (sys_user_position, sys_position_permission_set, ` + - `sys_user_permission_set) point at these row ids, so reaping them would revoke standing ` + - `access with no signal at the moment of loss.`, - { - object, - organization: organizationId, - origin: 'pre-fix-residue' satisfies OrganizationLessRowOrigin, - names: [...residue].sort(), - count: residue.length, - }, - ); - } -} diff --git a/packages/plugins/plugin-security/src/packaged-permission-set-lock.ts b/packages/plugins/plugin-security/src/packaged-permission-set-lock.ts index 34a2f9eaccc..7c399f10d5b 100644 --- a/packages/plugins/plugin-security/src/packaged-permission-set-lock.ts +++ b/packages/plugins/plugin-security/src/packaged-permission-set-lock.ts @@ -46,7 +46,7 @@ * page in it at all. * * ⭐ The answer comes from the engine's SchemaRegistry instead — the same - * source `bootstrapDeclaredPermissions`' {@link readDeclared} already uses, + * source {@link readDeclared} (the retired `bootstrapDeclaredPermissions`' read) uses, * so this plugin keeps ONE spelling of "package-declared" rather than two (Prime * Directive #8). That read is an in-memory array: no page, no cap, no `$in`, * no driver. Truncation is not a failure mode it HAS, which is a structural diff --git a/packages/plugins/plugin-security/src/permission-set-drift.ts b/packages/plugins/plugin-security/src/permission-set-drift.ts deleted file mode 100644 index 5beec3e4326..00000000000 --- a/packages/plugins/plugin-security/src/permission-set-drift.ts +++ /dev/null @@ -1,355 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * "declared ≠ enforced" surfacing for package-declared `sys_permission_set` - * rows (field report: rc→GA upgraded envs freeze a package's permission set - * at a first-boot/overlay snapshot while the shipped artifact keeps moving — - * grants stay frozen for weeks, silently, and the only signal was a boot log - * counter). - * - * TWO INDEPENDENT MECHANISMS can make a package-declared set's ENFORCED - * grants (what `sys_permission_set` actually stores, and what the evaluator - * resolves) diverge from its shipped ARTIFACT (what `readDeclared` reads from - * the engine's SchemaRegistry, the same source `bootstrapDeclaredPermissions` - * seeds from) — and neither may be assumed to be the only one live on a given - * environment: - * - * - **`overlay_shadow`** — an active `sys_metadata` overlay row exists for - * the set's name (a Studio permission-matrix save materialized one, back - * when `permission` still allowed org overrides, or through the - * `OS_METADATA_WRITABLE` hatch since). `reconcilePermissionSetProjection` - * re-projects that overlay's content onto the record on EVERY boot - * (ADR-0094 D4 step 1, unconditionally, keyed only on overlay presence), - * so the overlay wins forever regardless of what the package ships next. - * - **`provenance_skip`** — the row's `managed_by` is not `'package'` (a - * legacy/pre-provenance insert — see `bootstrap-declared-permissions.ts`), - * so `upsertPackagePermissionSet`'s package door refuses to touch it - * (`skippedEnvAuthored`) on every boot, forever. - * - * ⚠️ THE TWO MECHANISMS CONFOUND EACH OTHER. When BOTH are present on one row - * — the exact field-report shape — `upsertEnvPermissionSet`'s `customized` - * flag (`existing.managed_by === 'package' ? !!customized : false`) is - * FORCED FALSE, because the row's `managed_by` is wrong (provenance_skip), - * even though an overlay genuinely IS shadowing it. That is measurably why - * `customized` stayed `0` on the field-reported environment — the ADR-0094 - * surface an admin would check said "not customized" while an overlay had - * frozen the set for two weeks. So detection here does NOT trust - * `managed_by` to decide whether an overlay is shadowing a row — overlay - * presence is checked directly against `sys_metadata`, independent of the - * row's (possibly wrong) provenance column, exactly so this confounded case - * is still attributed correctly (`overlay_shadow`, not silently missed). - * - * WHAT THIS MODULE DOES NOT DO: it never guesses which mechanism explains a - * mismatch — it re-derives the fact each boot from the current `sys_metadata` - * / `sys_permission_set` state, the same sources the two write-doors read. - * It also never adopts/migrates a row (out of scope by 2026-08-20 maintainer - * ruling — "新项目还没上线,不需要清理旧数据,也没有老客户升级"). - * - * ⚠️ Scope note shared with `reconcilePermissionSetProjection`: only - * ENV-WIDE overlays (`sys_metadata.organization_id IS NULL`) are considered - * — the per-organization overlay residue the projection file's header already - * documents as deliberately out of scope (#10103) stays out of scope here - * too, for the same reason (this module answers the identical "is there an - * overlay" question the reconciler already answers, from the same rows). - */ - -import { - recordDiffersFromBody, - tryFind, - tryUpdate, - type ProjectionLogger, -} from './permission-set-projection.js'; -import { - createSeedWriteRefusals, - logSeedDurabilityFailure, -} from './write-refusals.js'; -import { readDeclared } from './bootstrap-declared-permissions.js'; -import { classifyPackagedPermissionSet } from './packaged-permission-set-lock.js'; -import { buildExistingByName } from './seed-name-lookup.js'; - -/** - * Cause a package-declared set's enforced grants can diverge from its shipped - * artifact. `in_sync` and `other` are internal/testing values only — - * {@link persistPermissionSetDriftDiagnostics} writes `null` for `in_sync` - * (pin: an in-sync set must not be surfaced at all, not even as a quiet - * "ok" badge) and keeps `other` as a truthful catch-all for the one case - * neither named mechanism explains (a package-owned row whose own boot-seed - * write failed — `unreadable` — so its content is stale for a reason that is - * neither an overlay nor a provenance skip). - */ -export type PermissionSetDriftStatus = 'in_sync' | 'overlay_shadow' | 'provenance_skip' | 'other'; - -export interface PermissionSetDriftDiagnostic { - id: string; - name: string; - packageId: string; - status: PermissionSetDriftStatus; - /** Human-readable detail naming the grant-count mismatch and the cause. `null` when in sync. */ - detail: string | null; - /** The row's stored `drift_status` before this pass (already normalized: `'in_sync'` read back as `null`). */ - priorStatus: string | null; - priorDetail: string | null; -} - -export interface DriftDiagnosticsOptions { - logger?: ProjectionLogger; - organizationId?: string; -} - -function countGrantedObjects(objects: any): number { - return objects && typeof objects === 'object' ? Object.keys(objects).length : 0; -} - -/** True when `row` carries an ACTIVE env-wide `sys_metadata` overlay for `name` (independent of `managed_by`). */ -function buildOverlayNameSet(rows: any[]): Set { - const out = new Set(); - for (const r of rows) { - if ((r?.organization_id ?? null) !== null || !r?.name) continue; // env-wide overlays only — see module header - out.add(String(r.name)); - } - return out; -} - -/** - * Compute (never write) the drift diagnostic for every currently package- - * declared permission set. Pure function over the passed `ql` — a boot pass - * and a test both call this the same way; {@link persistPermissionSetDriftDiagnostics} - * is the only thing that writes. - */ -export async function computePermissionSetDriftDiagnostics( - ql: any, - opts: DriftDiagnosticsOptions = {}, -): Promise { - const out: PermissionSetDriftDiagnostic[] = []; - if (!ql || typeof ql.find !== 'function') return out; - - // [#21860] "Package-declared" is the lock's verdict for the NAME — a code - // (artifact) package ships it — read through the one classifier the write - // doors and the overlay detection reading ask. - // A package id on the item alone is not that answer: a set saved into a - // writable runtime package carries one after the first metadata list read, - // and this report used to diagnose it as package-declared. - // The per-item test that follows the verdict only picks which bodies of a - // code-shipped name are compared, exactly as before. - const codeShipped = new Map(); - const isCodeShipped = (name: string): boolean => { - let shipped = codeShipped.get(name); - if (shipped === undefined) { - shipped = classifyPackagedPermissionSet(name, ql).status === 'packaged'; - codeShipped.set(name, shipped); - } - return shipped; - }; - const declared = readDeclared(ql, 'permission').filter( - (ps) => ps?.name && (ps._packageId ?? ps.packageId) && isCodeShipped(String(ps.name)), - ); - if (declared.length === 0) return out; - - const organizationId = opts.organizationId; - const existingByName = await buildExistingByName( - ql, - 'sys_permission_set', - declared.map((ps) => ps.name), - opts.logger, - organizationId, - ); - - // Same bulk overlay read `reconcilePermissionSetProjection` performs (ADR-0094 - // D4 step 1) — both `permission` and its legacy plural spelling. - const overlayRows: any[] = []; - for (const type of ['permission', 'permissions']) { - overlayRows.push(...(await tryFind(ql, 'sys_metadata', { type, state: 'active' }, 1000))); - } - const overlayNames = buildOverlayNameSet(overlayRows); - - for (const ps of declared) { - const name = String(ps.name); - const packageId = String(ps._packageId ?? ps.packageId); - const lookup = await existingByName.get(name); - if (lookup.status !== 'present') continue; // not yet materialized — nothing to diagnose (boot seeding creates it) - const row = lookup.row; - const priorStatus: string | null = row?.drift_status ?? null; - const priorDetail: string | null = row?.drift_detail ?? null; - - const differs = recordDiffersFromBody(row, ps); - if (!differs) { - out.push({ id: row.id, name, packageId, status: 'in_sync', detail: null, priorStatus, priorDetail }); - continue; - } - - const declaredCount = countGrantedObjects(ps.objects); - const enforcedCount = countGrantedObjects( - (() => { try { return JSON.parse(row.object_permissions ?? '{}'); } catch { return {}; } })(), - ); - const hasOverlay = overlayNames.has(name); - - let status: PermissionSetDriftStatus; - let detail: string; - if (hasOverlay) { - status = 'overlay_shadow'; - detail = - `An environment customization overlay (sys_metadata, type:'permission', name:'${name}') is shadowing ` + - `this package-declared set — enforced grants (${enforcedCount} object(s)) come from the overlay, not ` + - `the shipped artifact (${declaredCount} object(s)).`; - } else if (row.managed_by !== 'package') { - status = 'provenance_skip'; - detail = - `This row's managed_by ('${row.managed_by ?? 'none'}') predates package provenance tracking, so boot ` + - `sync treats it as environment-authored and never reconciles it with the package — enforced grants ` + - `(${enforcedCount} object(s)) are frozen at an old snapshot vs. the shipped artifact's ` + - `${declaredCount} object(s).`; - } else { - status = 'other'; - detail = - `Enforced grants (${enforcedCount} object(s)) differ from the shipped artifact (${declaredCount} ` + - `object(s)) for a reason that is neither an overlay nor a provenance skip — check boot logs for ` + - `'[security] declared permission sets left untouched'.`; - } - out.push({ id: row.id, name, packageId, status, detail, priorStatus, priorDetail }); - } - return out; -} - -/** - * Report, ONCE per pass, the drift-diagnostic writes the store refused. - * - * ## Why this is not {@link reportSeedWriteRefusals} - * - * The shared ACCUMULATOR (`createSeedWriteRefusals`) is reused as-is — it - * carries the shipped `isUniqueViolationError` classification and the - * value-free `code`/`errno` channel, and re-deriving either here is exactly - * the local-regex defect that module was written to retire. The shared - * REPORTER is not, and deliberately: every sentence it prints is about - * seeding the RBAC catalog — "the catalog is INCOMPLETE", "this pass's - * 'seeded' count", and a remedy naming the legacy PLATFORM-WIDE unique index - * on the catalog name column. None of that is true here. This pass seeds - * nothing; it writes two diagnostic columns onto rows that already exist, by - * id. Printing that text over this failure would send an operator to - * `os migrate` for a defect that is not there — the same "a confident wrong - * answer is worse than no answer" reasoning that makes `other` its own class - * in `reportSeedWriteRefusals` rather than a relabelled unique violation. - * - * ## Why ONE line and not one per class - * - * `reportSeedWriteRefusals` splits its classes because their REMEDIES differ. - * Here they do not: an update-by-id of two nullable diagnostic columns has no - * unique constraint to violate, so the class is a fact for `meta` (it travels - * there, per (object, class), with the driver codes) and not a reason to print - * a second sentence. - * - * ## Why the durability channel - * - * AGENTS.md "Degradation log levels", one question — after the degradation, - * does the system still look normal while something it claims is persisted - * has not landed? Yes, precisely: `drift_status` / `drift_detail` are what - * Setup's "Needs Attention" surface reads, so a refused write leaves that - * screen showing a CLEAN environment over sets that are still drifted. - * ⚠️ `check:durability-log-level` does not vouch for this choice — `ql.update` - * is not in its `DURABILITY_CRITICAL_CALLEES` vocabulary, so its green here - * means the site is outside the gate's reach (NOT MEASURED), never approval. - */ -function reportDriftWriteRefusals( - logger: ProjectionLogger | undefined, - refusals: ReturnType, - organizationId?: string, -): void { - const entries = refusals.report(); - if (entries.length === 0) return; - logSeedDurabilityFailure( - logger, - `[security] ${refusals.total} package-declared permission set drift diagnostic(s) were REFUSED by the ` + - `store — the "declared ≠ enforced" verdict this pass computed did NOT persist, so those rows keep ` + - `their PREVIOUS drift_status/drift_detail (usually none at all), Setup's "Needs Attention" surface goes ` + - `on showing them as CLEAN, and THE DEPLOYMENT WILL GO ON LOOKING HEALTHY while those sets keep ` + - `enforcing grants that differ from the shipped artifact. The drifted set NAMES are not lost with the ` + - `write: the "[security] package-declared permission set(s) enforcing grants that differ from the ` + - `shipped artifact" line is logged alongside this one and names every one of them — it is no longer ` + - `gated behind the count of writes that landed. What the store actually said is in the query engine's ` + - `"Update operation failed" entries logged just before this one, which keep the driver's own identifier ` + - `with the bound statement and its values cut. Remedy: read those entries — a sys_permission_set ` + - `missing its drift_status/drift_detail columns is a deployment SCHEMA defect, reported by ` + - `"os migrate plan" and fixed by "os migrate apply"; anything else is a store outage, which this pass ` + - `re-computes and re-attempts on the next boot. Either way nothing is lost, and nothing is recorded ` + - `either until a write lands.`, - { - refused: refusals.total, - refusals: entries, - ...(organizationId ? { organization: organizationId } : {}), - }, - ); -} - -/** - * Write the computed diagnostics onto their `sys_permission_set` rows. - * EQUALITY-GATED (#10946 discipline): a row whose stored `drift_status` / - * `drift_detail` already match is left untouched — a steady-state boot pays - * zero writes, matching the round-trip contract every other pass in this file - * keeps. - * - * `in_sync` is written as `null` — the "not surfaced" pin is a data-level - * fact, not merely a filtered view: a quiet set carries no `drift_status` - * value at all, so a client reading the raw record (not only the Setup - * "Needs Attention" view) sees nothing to worry about either. - * - * Answers `refused` alongside `updated`. ⚠️ They are not two spellings of the - * same pass: `updated` counts the verdicts that LANDED and `refused` counts - * the ones the store rejected, and a caller asking "was there drift?" while - * reading only `updated` reads a wholly refused pass as a clean one — the - * defect {@link reportDriftWriteRefusals} and the gate in - * {@link runPermissionSetDriftDiagnostics} exist to close. - */ -export async function persistPermissionSetDriftDiagnostics( - ql: any, - diagnostics: readonly PermissionSetDriftDiagnostic[], - opts: DriftDiagnosticsOptions = {}, -): Promise<{ updated: number; refused: number }> { - // ⛔ The refusal LOG is what makes a refused write distinguishable from - // "nothing to write". `tryUpdate` answers `false` for both, and this - // function's only output used to be a count of the writes that LANDED — so - // a pass whose every write was refused returned `{ updated: 0 }`, which is - // byte-identical to the steady-state boot the equality gate above is built - // to produce. See the report below for what that silence cost. - const refusals = createSeedWriteRefusals(); - let updated = 0; - for (const d of diagnostics) { - const status: string | null = d.status === 'in_sync' ? null : d.status; - const detail: string | null = d.status === 'in_sync' ? null : d.detail; - if (d.priorStatus === status && d.priorDetail === detail) continue; - if (await tryUpdate(ql, 'sys_permission_set', { id: d.id, drift_status: status, drift_detail: detail }, opts.organizationId, refusals)) { - updated += 1; - } - } - reportDriftWriteRefusals(opts.logger, refusals, opts.organizationId); - return { updated, refused: refusals.total }; -} - -/** Compute + persist in one call — what boot wiring uses. */ -export async function runPermissionSetDriftDiagnostics( - ql: any, - opts: DriftDiagnosticsOptions = {}, -): Promise<{ diagnostics: PermissionSetDriftDiagnostic[]; updated: number; refused: number }> { - const diagnostics = await computePermissionSetDriftDiagnostics(ql, opts); - const { updated, refused } = await persistPermissionSetDriftDiagnostics(ql, diagnostics, opts); - // ⛔ `updated > 0` ALONE was the suppressor. A boot on which every drift - // write is refused computes the drift correctly, persists none of it, and — - // under the old gate — printed nothing at all, which is byte-identical to a - // deployment with no drift. `refused > 0` re-opens exactly that case and - // nothing else: a steady-state boot (equality-gated, nothing to write, - // nothing refused) stays as quiet as it was. - if (updated > 0 || refused > 0) { - opts.logger?.warn?.( - '[security] package-declared permission set(s) enforcing grants that differ from the shipped artifact', - { - updated, - // Present ONLY when non-zero, so the steady-state line is unchanged - // byte-for-byte for anything reading it. Read it together with - // `updated`: `updated` counts the verdicts that LANDED, never the - // verdicts that were reached. - ...(refused > 0 ? { refused } : {}), - drifted: diagnostics.filter((d) => d.status !== 'in_sync').map((d) => ({ name: d.name, status: d.status })), - ...(opts.organizationId ? { organization: opts.organizationId } : {}), - }, - ); - } - return { diagnostics, updated, refused }; -} diff --git a/packages/plugins/plugin-security/src/permission-set-name-collision.ts b/packages/plugins/plugin-security/src/permission-set-name-collision.ts index 5aa5ff77e88..10d20d61bcb 100644 --- a/packages/plugins/plugin-security/src/permission-set-name-collision.ts +++ b/packages/plugins/plugin-security/src/permission-set-name-collision.ts @@ -7,9 +7,10 @@ * * ## What was wrong * - * `bootstrapDeclaredPermissions` refuses to write into a `sys_permission_set` - * row another package owns (ADR-0086 D4: a package never writes into a foreign - * record). That refusal is CORRECT and is unchanged here. What was wrong is + * `bootstrapDeclaredPermissions` refused to write into a `sys_permission_set` + * row another package owned (ADR-0086 D4: a package never writes into a foreign + * record); it retired with ADR-0131 C3 stage 6b-2, and the registry now refuses + * a second holder of a set name at install. What was wrong is * that it was INVISIBLE: the whole declared set vanished with an internal * counter incremented and one `logger?.warn?.(…)` line — optionally chained * TWICE, so a caller that passed no logger produced no output at all. The diff --git a/packages/plugins/plugin-security/src/permission-set-projection.ts b/packages/plugins/plugin-security/src/permission-set-projection.ts index a6e81290d8d..c38888ee3d1 100644 --- a/packages/plugins/plugin-security/src/permission-set-projection.ts +++ b/packages/plugins/plugin-security/src/permission-set-projection.ts @@ -134,8 +134,8 @@ export async function tryFind(ql: any, object: string, where: any, limit = 100, * every caller that wants "the rows I could see", wrong for anyone who has to * decide whether a row is GONE. `[]` from that helper means "no row matched OR * the read blew up", and a caller reading it as "gone" reports a deletion that - * an outage invented. `listSeedOrganizationIds` in `per-organization-catalog.ts` - * states the same rule for the same shape: zero organizations is "nothing to + * an outage invented. The retired seeder's organization enumeration + * stated the same rule for the same shape: zero organizations is "nothing to * seed", an unreadable table is "we do not know". * * So `unknown` is its own answer and the caller must decide what it means — @@ -476,7 +476,7 @@ const isProjectionEcho = (v: any): boolean => /** * Read the DECLARED (artifact) body for a permission set from the engine's - * SchemaRegistry — the same source `bootstrapDeclaredPermissions` seeds from, + * SchemaRegistry — the same source the retired `bootstrapDeclaredPermissions` seeded from, * and the one store the env projection never writes, so it can't be poisoned * by our own registry sync. It is the body for a declared set with no overlay * at all, and the reset target when an overlay IS lifted off one — which since diff --git a/packages/plugins/plugin-security/src/security-plugin.ts b/packages/plugins/plugin-security/src/security-plugin.ts index 0986060dcc5..74ddadadc84 100644 --- a/packages/plugins/plugin-security/src/security-plugin.ts +++ b/packages/plugins/plugin-security/src/security-plugin.ts @@ -58,14 +58,12 @@ import { jsonColumnCheckRefusalCarriedBy, storedFormCheckJudge } from './rls-che import type { ExplainDecision, ExplainOperation } from '@objectstack/spec/security'; import type { II18nService, IMetadataService, IObjectQLEngine } from '@objectstack/spec/contracts'; -import { bootstrapDeclaredPermissions, upsertPackagePermissionSet, readDeclared } from './bootstrap-declared-permissions.js'; import { applyManagedWriteDenies } from './managed-object-write-denies.js'; import { createPermissionSetWriteThrough, registerPermissionSetProjection, reconcilePermissionSetProjection, } from './permission-set-projection.js'; -import { runPermissionSetDriftDiagnostics } from './permission-set-drift.js'; import { reportPackagedPermissionSetOverlays } from './packaged-permission-set-overlay-detection.js'; import { registerObjectPostureGate } from './object-posture-gate.js'; import { registerPackagedPermissionSetLockGate } from './packaged-permission-set-lock-gate.js'; @@ -79,12 +77,7 @@ import { type SuggestionListFilter, } from './suggested-audience-bindings.js'; import { cleanupPackagePermissions } from './cleanup-package-permissions.js'; -import { - catalogIsPerOrganization, - listSeedOrganizationIds, -} from './organization-scope.js'; -import { normalizeManagedByVocab } from './normalize-managed-by.js'; -import { readDeclaredCapabilityContext } from './declared-capability-context.js'; +import { readDeclared, readDeclaredCapabilityContext } from './declared-capability-context.js'; import { RLSCompiler, RLS_DENY_FILTER, compiledPolicyNameOf, policyDeclaresClause } from './rls-compiler.js'; import { computeTenantLayer0Verdict, @@ -588,12 +581,11 @@ interface CbpRelation { * • `platform` / `package` are managed; `admin`/∅ (tenant-authored) rows are * the admin's. * • sys_position additionally keeps its LEGACY values `system` (→ platform) - * and `config` (→ package) in the managed map: the boot normalizer - * (normalize-managed-by.ts) heals stored rows to the canonical vocabulary, - * but rows written before the normalizer runs — or in a store it has not - * touched yet — must not lose protection in the interim. Dropping the - * legacy keys here is what silently disarmed this gate for sys_position - * after the A4 rename (#2926 ①). + * and `config` (→ package) in the managed map: no boot pass heals stored + * rows to the canonical vocabulary any more (ADR-0131 retired it with the + * catalog seeders), so a row still carrying a legacy value keeps its + * protection here. Dropping the legacy keys is what silently disarmed this + * gate for sys_position after the A4 rename (#2926 ①). * The map value for each managed `managed_by` is the human owner label used in * the (business-message-only) deny text. */ @@ -4431,9 +4423,9 @@ export class SecurityPlugin implements Plugin { ); // Defer platform admin bootstrap until all plugins finish starting — - // sys_user / sys_permission_set objects must be registered (by - // plugin-auth and platform-objects respectively) before we can - // insert seed rows. Falls back to immediate execution when the + // sys_user / sys_user_permission_set objects must be registered (by + // plugin-auth and platform-objects respectively) before the promotion + // can write its grant. Falls back to immediate execution when the // kernel does not expose `hook` (test stubs). let bootstrapRanOnce = false; const readSeedSettlement = (): SeedSettlementSnapshot | undefined => readSeedSettlementSnapshot(ctx); @@ -4443,81 +4435,19 @@ export class SecurityPlugin implements Plugin { // onMetadataMutation fallback appends listeners, and re-wiring that would // project each save N times. let envProjectionWired = false; + // [#22801 → A] Same once-guard for the `permission` mutation listener + // that re-derives `everyone` (registered in `runBootstrap`). + let everyoneMutationWired = false; // [#3325 / ADR-0092] Union the better-auth managed-object write denies into // the default sets from the LIVE registry, replacing the hand-maintained // baseline as the source of truth (a newly-declared identity table is then // covered without editing a list). This mutates the shared // `bootstrapPermissionSets` instances IN PLACE — the same objects the - // permission evaluator resolves and `bootstrapPlatformAdmin` serializes into - // the seed row — so it must run BEFORE the seeder below, and once (the - // transform is idempotent, but the once-flag avoids re-reading the registry / - // re-logging on every runBootstrap re-entry). See managed-object-write-denies.ts. + // permission evaluator resolves — so it runs once, before the bootstrap + // below (the transform is idempotent, but the once-flag avoids re-reading + // the registry / re-logging on every runBootstrap re-entry). See managed-object-write-denies.ts. let managedDeniesApplied = false; - // ── The RBAC catalog, materialized PER ORGANIZATION ────────────────── - // - // `sys_position` and `sys_permission_set` both spell their name index - // `unique: 'organization'`, and both were seeded organization-less. On a - // walled deployment that made the whole catalog unreadable to EVERY - // principal at every rung: this plugin's Layer 0 composes a strict - // `organization_id = :tenant` and the middleware ANDs it over the driver's - // `(organization_id = :tenant OR organization_id IS NULL)`, and the - // conjunction of the two is the strict equality alone. Nothing about the - // wall changes here, at either layer — the rows get an owner instead. - // - // The catalog step is named ONCE and driven from two places (the boot sweep - // below, and the organization-creation hook further down) so a newly - // created organization can never be seeded differently from one present at - // boot. [ADR-0131 D2/D3] Positions get no row from the boot: the six - // built-ins are declared by this plugin (`builtin-positions.ts`), a - // package's positions are registry items, and the `everyone` baseline is - // that anchor's declared `permissionSets` (`declareEveryoneBaseline`). See `organization-scope.ts` for the doctrine and the loud guard - // that stands in place of a reap, and `rowMatchesDeclaration` for why the - // boot sweep is O(changed declarations) rather than O(organizations x rows) - // of blind writes. - // - // `organizationId` undefined is the `single`-posture pass and reproduces - // the pre-existing organization-less behaviour exactly. - // [#11532] The names `bootstrapPlatformAdmin` seeds organization-less on - // every boot. The per-organization pass sees those rows through the driver's - // compatibility arm and used to report them as PRE-FIX leftovers with a - // remedy ("re-initialize the deployment") that mints them again — on a fresh - // walled deployment the whole line was false and its first branch was a - // loop. Handed over from here because this is where the one array lives: - // the same `bootstrapPermissionSets` goes to the platform bootstrap above - // and onto the manifest as `permissions`, so the two can never disagree. - const platformBucketNames = this.bootstrapPermissionSets - .map((p) => p.name) - .filter((n): n is string => typeof n === 'string' && n !== ''); - const seedCatalogPermissions = async (organizationId?: string): Promise => { - try { - await bootstrapDeclaredPermissions(ql, this.metadata, { logger: ctx.logger, organizationId, platformBucketNames }); - } catch (e) { - ctx.logger.warn('[security] declared-permission seeding failed', { error: (e as Error).message, organization: organizationId }); - } - }; - /** - * Every catalog step for ONE organization, in boot order. Used by the - * organization-creation hook; the boot sweep drives the same step - * interleaved with the organization-independent bootstrap work. - */ - const seedCatalogForOrganization = async (organizationId?: string): Promise => { - await seedCatalogPermissions(organizationId); - }; - /** - * Which passes this boot performs: one per organization under a walled - * posture, exactly one organization-less pass under `single`, and NONE when - * the organization enumeration failed (already warned — an unreadable - * `sys_organization` is "we do not know", never "there are no tenants"). - * A walled deployment with zero organizations yet is also none; each - * organization is seeded as it is created. - */ - const catalogSeedPasses = async (): Promise> => { - if (!catalogIsPerOrganization(this.tenancyPosture)) return [undefined]; - const ids = await listSeedOrganizationIds(ql, ctx.logger); - return ids ?? []; - }; - const runBootstrap = async () => { try { if (!managedDeniesApplied) { @@ -4545,87 +4475,43 @@ export class SecurityPlugin implements Plugin { // real answer: a later pass that returns none (walled posture, an // unreadable engine) must not erase the admin an earlier pass resolved. if (report?.adminUserId) this.claimTargetAdminUserId = report.adminUserId; - // Which organizations this boot seeds. Resolved ONCE per bootstrap run. - const catalogPasses = await catalogSeedPasses(); - // [ADR-0086 D5] Seed stack-declared permission sets into - // sys_permission_set with package provenance (managed_by:'package' + - // package_id) — packages ship working default access for their own - // objects, and the admin surface finally sees them. Runs AFTER - // bootstrapPlatformAdmin so the platform defaults keep their - // insert-once, provenance-less shape (env config, never clobbered). - for (const organizationId of catalogPasses) { - await seedCatalogPermissions(organizationId); - } - - // [ADR-0086 P2 — 块1] Register the publish-time materializer so a - // permission set authored/edited through the PACKAGE door (saved as a - // `permission` draft, then published) lands in sys_permission_set with - // managed_by:'package' + package_id — the exact provenance the boot - // seeder stamps, only now on the runtime publish path instead of only at - // boot. Idempotent: registerPublishMaterializer replaces on re-run, and - // upsertPackagePermissionSet refuses to clobber env- or foreign-owned - // rows (ADR-0086 D4), so the two doors never overwrite each other. try { const protocol: any = ctx.getService?.('protocol'); - if (protocol && typeof protocol.registerPublishMaterializer === 'function') { - protocol.registerPublishMaterializer( - 'permission', - async (args: { body: unknown; packageId: string | null; organizationId: string | null }) => { - const r = await upsertPackagePermissionSet(ql, args.body, args.packageId, ctx.logger); - // [#10946] `unchanged` counts here. The question this line asks - // is "did the record end up matching the published body", NOT - // "was a write issued" — and until the seeder stopped re-writing - // rows it had nothing to change, those two were accidentally the - // same number. A re-publish of an identical body now lands in - // `unchanged`, and reading only `seeded + updated` would report - // that converged publish as a FAILURE and skip the audience - // reconcile below. The three refusal branches (foreign package, - // env-authored name, no owning package) all leave `unchanged` at - // 0, so every case that reported 0 before still reports 0. - const applied = r.seeded + r.updated + r.unchanged; - // [ADR-0090 D5] A published set carrying the install-time - // suggestion flag surfaces (or retires) its pending - // suggestion row right away — same convergent sync as boot — - // and `everyone` is derived again, so an accepted set whose new - // body fails the derivation stops granting at once. - if (applied > 0 && (args.body as { isDefault?: boolean } | null)?.isDefault !== undefined) { - try { - await reconcileAudienceBindingSuggestions(ql, ctx.logger); - } catch (e) { - ctx.logger.warn('[security] audience-binding suggestion sync failed (non-fatal)', { error: (e as Error).message }); - } - await this.declareEveryone(ctx.logger); + // [ADR-0090 D5, #22801 → A] Every landed `permission` mutation — + // publish, direct-active save or delete, through any door — runs the + // suggestion reconcile and derives `everyone` again, so an accepted + // set whose new body no longer qualifies (dropped `isDefault`, a + // forbidden bit, deleted) stops granting at once rather than at the + // next boot. Unconditional on the body: whether a set still qualifies + // is `readAcceptedEveryoneSets`' question, asked of what the catalog + // now serves. Drafts change nothing the catalog serves. Wired once; + // the listener is fire-and-forget, so it owns its failures, and + // `declareEveryone` never throws. + if (!everyoneMutationWired && protocol && typeof protocol.onMetadataMutation === 'function') { + protocol.onMetadataMutation((evt: { type?: string; state?: string }) => { + if (evt?.type !== 'permission' || evt.state === 'draft') return; + void (async () => { + try { + await reconcileAudienceBindingSuggestions(ql, ctx.logger); + } catch (e) { + ctx.logger.warn('[security] audience-binding suggestion sync failed (non-fatal)', { error: (e as Error).message }); } - // A publish that materialized nothing did NOT go live — report it - // as a failure with the reason so the package-door UI never shows - // a clean publish over a set the admin surface can't see (ADR-0049 - // honesty). The upsert only lands zero rows when it refused: the - // name is owned by another package, owned by the env door, or the - // publish carried no owning package_id to stamp. - if (applied === 0) { - return { - success: false, inserted: 0, updated: 0, - error: r.skippedForeign > 0 - ? 'permission set name is owned by another package' - : r.skippedEnvAuthored > 0 - ? 'permission set name is owned by the environment (edit it through the admin door)' - : 'permission set was not materialized (publish carried no owning package)', - }; - } - return { success: true, inserted: r.seeded, updated: r.updated }; - }, - ); + await this.declareEveryone(ctx.logger); + })(); + }); + everyoneMutationWired = true; } - // [#2747] Uninstall counterpart of the materializer above: when the - // owning package is uninstalled, revoke its data-plane permission + // [#2747] When the owning package is uninstalled, revoke its data-plane permission // rows (package-owned sets + their position/user bindings + the - // package's suggestion rows) so grants die with the package — the - // "no ghost grants" clause of ADR-0090 D5. + // package's suggestion rows) and the grants naming the sets it + // declared (`args.declared`, captured by the door before it withdrew + // the package) so grants die with the package — the "no ghost + // grants" clause of ADR-0090 D5. if (protocol && typeof protocol.registerUninstallCleanup === 'function') { protocol.registerUninstallCleanup( 'security.package-permissions', - async (args: { packageId: string }) => { - const r = await cleanupPackagePermissions(ql, args.packageId, ctx.logger); + async (args: { packageId: string; declared?: { permission?: string[] } }) => { + const r = await cleanupPackagePermissions(ql, args.packageId, ctx.logger, args.declared); // [#22801 → A] The package's acceptance rows are gone with it; // `everyone` is derived again without its sets. await this.declareEveryone(ctx.logger); @@ -4677,18 +4563,6 @@ export class SecurityPlugin implements Plugin { } catch (e) { ctx.logger.warn('[security] permission-set projection reconciliation failed (ADR-0094)', { error: (e as Error).message }); } - // [field report — rc→GA declared≠enforced surfacing] Recompute - // "declared ≠ enforced" per package-declared set AFTER both doors - // have settled for this boot (the package door above, and the env - // door's reconciliation just above this line) — so `drift_status` - // reflects the boot's TRUE final state, not a mid-boot snapshot. - // Own try/catch: a failure here must never take down boot, and must - // never be read as "reconciliation also failed" in the log above. - try { - await runPermissionSetDriftDiagnostics(ql, { logger: ctx.logger }); - } catch (e) { - ctx.logger.warn('[security] permission-set drift diagnostics failed', { error: (e as Error).message }); - } // [maintainer ruling 2026-08-24 — 「同意 第一步(创业阶段,Salesforce // 式)」, item 3] The DETECTION READING for sets that were already // silently forked before the save door was locked: count + NAMES, @@ -4698,22 +4572,16 @@ export class SecurityPlugin implements Plugin { // cold-boot check holding such a fork does not boot at all; its // remedy is the offline `os migrate security-catalog-overlays`. // - // Distinct from the drift diagnostic above and deliberately run - // beside it: drift reports a set whose grants ALREADY differ from - // the artifact, so an overlay that currently happens to match is - // invisible to it — while that fork is entirely real and freezes - // the set the moment the package ships its next version. - // // Own try/catch, same discipline as its neighbours: a failure here - // must never take down boot, and must never read as "the drift - // diagnostics also failed". + // must never take down boot, and must never read as "the + // reconciliation also failed". try { await reportPackagedPermissionSetOverlays(ql, { logger: ctx.logger }); } catch (e) { ctx.logger.warn('[security] packaged permission-set overlay detection failed', { error: (e as Error).message }); } } catch (e) { - ctx.logger.warn('[security] permission publish-materializer registration failed', { error: (e as Error).message }); + ctx.logger.warn('[security] permission-set door registration failed', { error: (e as Error).message }); } // [ADR-0090 D5/D9] Reconcile the suggested-audience-binding surface: // every declared `isDefault: true` set the `everyone` definition does @@ -4725,15 +4593,6 @@ export class SecurityPlugin implements Plugin { } catch (e) { ctx.logger.warn('[security] audience-binding suggestion sync failed (non-fatal)', { error: (e as Error).message }); } - // [A4 #2920] Heal residual legacy `managed_by` values (env sets stamped - // 'user'; older positions stamped system/config/user) onto the unified - // platform/package/admin vocab. Runs after the seeders so their canonical - // writes land first; idempotent and non-fatal. - try { - await normalizeManagedByVocab(ql, { logger: ctx.logger }); - } catch (e) { - ctx.logger.warn('[security] managed_by vocab normalization failed (non-fatal)', { error: (e as Error).message }); - } bootstrapRanOnce = true; ctx.logger.info('[security] platform bootstrap complete', report); return report; @@ -4751,8 +4610,8 @@ export class SecurityPlugin implements Plugin { // [ADR-0131 D4] Name the permission set on every grant written before // `sys_user_permission_set.permission_set` existed — once per deployment, // recorded in `sys_migration`. At `kernel:bootstrapped`, not `kernel:ready`: - // every `kernel:ready` handler has settled by then (the bootstrap above that - // seeds the catalog rows among them), so the security catalog read each + // every `kernel:ready` handler has settled by then (the bootstrap above + // among them), so the security catalog read each // name is verified through sees every definition this boot registers. A // name it still does not resolve leaves the verdict unrecorded, so the next // boot tries again. See `grant-permission-set-name-backfill.ts`. @@ -4835,45 +4694,6 @@ export class SecurityPlugin implements Plugin { }); } - // ── Project the permission sets of a package that arrives AFTER the boot ── - // - // [#21322, ADR-0086 D5 — a package's sets are seeded ON INSTALL] The - // declared-permission seeding above runs once, at - // `kernel:ready`, over whatever the engine registry holds by then. A package - // registered later — `os package install` into a running runtime (the - // install-local plugin), an artifact reload — was never projected: its sets - // resolved for the evaluator (`/meta/permission` listed them) while - // `sys_permission_set` had no row, so no admin could grant them until a - // restart re-ran this pass. Every such door announces `metadata:reloaded`, - // the platform's one post-boot re-sync signal (the automation engine - // re-binds flows off the same event), so re-run the SAME step here: the - // same function, the same organization passes, the same provenance rules. - // Idempotent and upgrade-aware by construction (it re-seeds only rows it - // owns and never clobbers env-authored ones), so a reload that changed no - // permission set writes nothing. - // - // Only once the boot's own pass has run: it is what lets - // `bootstrapPlatformAdmin` write the platform defaults first, in their - // insert-once shape, and a reload cannot arrive ahead of it on a real - // kernel anyway. Never throws — `trigger` dispatch PROPAGATES, and a - // subscriber failure must not fail the install or publish that announced. - if (typeof (ctx as any).hook === 'function') { - (ctx as any).hook('metadata:reloaded', async () => { - if (!bootstrapRanOnce) return; - try { - for (const organizationId of await catalogSeedPasses()) { - await seedCatalogPermissions(organizationId); - } - } catch (e) { - ctx.logger.warn( - '[security] declared permission sets were NOT re-projected after a metadata reload — a package ' + - 'registered after boot has no sys_permission_set row until the next restart', - { error: (e as Error).message }, - ); - } - }); - } - // ── Re-run the seed-ownership CLAIM when the seed actually settles ──────── // // Subscribed in `init()`, not here — see {@link claimSeedOwnershipOnSettle}. @@ -5000,80 +4820,12 @@ export class SecurityPlugin implements Plugin { void runOrgAdminBackfill(); } - // ── Seed the RBAC catalog for a NEWLY CREATED organization ────────── - // - // The boot sweep covers the organizations that exist when the kernel is - // ready. Every organization minted afterwards needs the same catalog, and - // waiting for the next restart is not a repair: until it runs, that - // tenant's Setup lists no positions, no permission sets and no sharing - // rules — the exact symptom per-organization materialization exists to fix, - // only scoped to the newest tenant. Steady state therefore rides THIS hook, - // which is also why the boot sweep can afford to be a read-mostly - // reconciliation rather than a blind rewrite. - // - // Walled postures only: under `single` there is no organization to seed - // for, and the one organization-less pass at boot is the correct shape. - // Idempotent by construction (every seeder upserts by - // `(name, organization_id)`), so a double-fire is harmless. - ql.registerMiddleware(async (opCtx: any, next: () => Promise) => { - await next(); - if (opCtx?.object !== 'sys_organization') return; - const op = opCtx?.operation; - if (op !== 'insert' && op !== 'create') return; - if (!catalogIsPerOrganization(this.tenancyPosture)) return; - const organizationId = this.extractOrganizationIdFromWrite(opCtx); - if (!organizationId) { - // Never fall back to an organization-less pass here. That is the - // `single`-posture shape, and running it on a walled deployment would - // mint exactly the invalid-state row this change exists to remove. - ctx.logger.warn?.( - '[security] an organization was created but its id could not be read off the write — its ' + - 'RBAC catalog was NOT seeded now; the next boot sweep covers it', - { object: 'sys_organization' }, - ); - return; - } - try { - await seedCatalogForOrganization(organizationId); - ctx.logger.info?.('[security] RBAC catalog seeded for a newly created organization', { - organization: organizationId, - }); - } catch (e) { - ctx.logger.warn?.('[security] catalog seeding for a new organization failed (retried on next boot)', { - organization: organizationId, error: (e as Error).message, - }); - } - }); - // Per-organization seed DATA replay on `sys_organization` insert (business // records) lives in `@objectstack/organizations`, along with // `claimOrphanOrgRows` / `cloneOrgSeedData`. Install that plugin for - // multi-tenant deployments. The hook above is a different thing: the RBAC - // CATALOG, which plugin-security owns and seeds from declarations. - } - - /** - * The id of the organization a `sys_organization` insert just created. - * - * Read from the write's own result first and its input second, because the - * two disagree in the case that matters: an insert that lets the engine mint - * the id carries none on the way in. Returns `undefined` rather than guessing - * — the caller refuses to run an organization-less pass on a walled - * deployment, which is the whole point. - */ - private extractOrganizationIdFromWrite(opCtx: any): string | undefined { - const candidates = [ - opCtx?.result?.id, - Array.isArray(opCtx?.result) ? opCtx.result[0]?.id : undefined, - opCtx?.data?.id, - opCtx?.doc?.id, - opCtx?.record?.id, - opCtx?.id, - ]; - for (const c of candidates) { - if (typeof c === 'string' && c !== '') return c; - } - return undefined; + // multi-tenant deployments. The RBAC catalog needs no such replay: this + // plugin writes no catalog row, and every organization reads the same + // registry (ADR-0131 D3). } /** @@ -7108,7 +6860,7 @@ export class SecurityPlugin implements Plugin { * overlay layer. Fails CLOSED and never depends on the caller's grants, so * a platform admin with `modifyAllRecords` cannot forge provenance either. * System/boot writes never reach here (the middleware short-circuits on - * `isSystem`), so the seeder and the publish materializer are unaffected. + * `isSystem`), so system writes are unaffected. */ /** * [ADR-0090 D5/D9] Reject binding a HIGH-PRIVILEGE permission set to an diff --git a/packages/plugins/plugin-security/src/seed-refusal-diagnostics.ts b/packages/plugins/plugin-security/src/seed-refusal-diagnostics.ts deleted file mode 100644 index ca6a3fe2a59..00000000000 --- a/packages/plugins/plugin-security/src/seed-refusal-diagnostics.ts +++ /dev/null @@ -1,393 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * [#18091] The five remaining refusal diagnostics of the two declared-metadata - * seeders — one wording, one token and one record PER SITE. - * - * ## What was wrong - * - * `bootstrapDeclaredCapabilities` and `bootstrapDeclaredPermissions` refuse to - * write in five more places, and every one of those refusals was spelled - * `logger?.warn?.(…)` — optionally chained TWICE, so a caller that injected no - * logger got NO OUTPUT AT ALL. Measured on the pre-fix tree, each site driven - * with no logger while all five console channels were spied: - * - * ```text - * curated platform capability refused skippedPlatform = 1 lines = 0 - * capability declaration unowned skippedUnowned = 1 lines = 0 - * capability rows unreadable unreadable = 1 lines = 0 - * permission set declaration unowned (no counter) lines = 0 - * permission set rows unreadable unreadable = 1 lines = 0 - * LIT CONTROL capability_name_collision skippedForeign = 1 lines = 1 - * LIT CONTROL permission_set_name_… skippedForeign = 1 lines = 1 - * ``` - * - * The two lit controls are the already-repaired axes in the SAME harness, so - * the zeros are a reading rather than an artefact of the probe. - * - * ⛔ Not one of the skips changes here. They are correct under ADR-0086 D4 (a - * package never writes into a foreign record) and ADR-0086 D3 (a row that - * cannot prove its owner makes uninstall undefined). The defect is only that - * the refusal never reached the author who caused it. - * - * ## Why five diagnostics and not one - * - * #18023's delivery established the rule and it is applied here rather than - * re-argued: the token, the record, the wording and the CONSEQUENCE are - * site-specific — four of the six parts a refusal report is made of. The five - * consequences below are genuinely different facts about the deployment: - * - * - a curated-platform-name hijack still RESOLVES (the curated pass owns the - * row) and loses only the declaring package's authored metadata; - * - an unowned CAPABILITY declaration may resolve, may fall back to the - * back-compat derived placeholder, or may exist nowhere at all — three - * outcomes, decided by what already stands in `sys_capability`; - * - an unowned PERMISSION SET keeps every grant working (the evaluator - * resolves declared sets through the metadata registry) and loses only the - * RECORD — the Setup surface, the provenance axis and uninstall; - * - an unreadable read wrote nothing and compared nothing, so nothing is lost - * and nothing arrived either — and what "nothing arrived" costs differs - * again between the two tables. - * - * Wording them into one generic "declaration skipped" sentence would send every - * author of the first kind hunting for a broken grant that is not broken. - * - * ## What IS shared - * - * Exactly one thing: WHERE the line goes. {@link reportThroughSink} carries the - * delivery rule for all five, so this card adds one derivation rather than five - * more copies of the two lines #17516 and #18023 each wrote out by hand. - * - * ## Why `event` and not `code` - * - * Every token below is a snake_case DATA VALUE, not an ADR-0112 error code: it - * is never routed to `error.code`, never reaches a wire refusal, and travels as - * the structured half of a warning about an artifact — the same discrimination - * `CAPABILITY_NAME_COLLISION` and `PERMISSION_SET_NAME_COLLISION` already make. - */ - -import { reportThroughSink, type CollisionReportSink } from './seed-refusal-sink.js'; - -export type { CollisionReportSink }; - -// ─────────────────────────────────────────────────────────────────────────── -// Tokens — one grep string per refusal kind. -// ─────────────────────────────────────────────────────────────────────────── - -/** A package declared a capability whose name is a CURATED platform capability. */ -export const CAPABILITY_PLATFORM_NAME_REFUSED = 'capability_platform_name_refused'; - -/** A declared capability carries no resolvable owning package. */ -export const CAPABILITY_DECLARATION_UNOWNED = 'capability_declaration_unowned'; - -/** Declared capabilities left untouched because `sys_capability` could not be read. */ -export const CAPABILITY_ROWS_UNREADABLE = 'capability_rows_unreadable'; - -/** A declared permission set carries no resolvable owning package. */ -export const PERMISSION_SET_DECLARATION_UNOWNED = 'permission_set_declaration_unowned'; - -/** Declared sets left untouched because `sys_permission_set` could not be read. */ -export const PERMISSION_SET_ROWS_UNREADABLE = 'permission_set_rows_unreadable'; - -/** - * Severity is `warning` on all five for the #4632 reason: this is FUNCTIONAL - * degradation, never a durability failure. Nothing claimed to be persisted - * silently failed to land. - */ -type RefusalSeverity = 'warning'; - -/** The shape every diagnostic in this module shares — ⛔ the wording is not part of it. */ -interface SeedRefusalDiagnosticBase { - readonly severity: RefusalSeverity; - readonly message: string; - readonly fix: string; -} - -// ─────────────────────────────────────────────────────────────────────────── -// SITE 1 — a package declares a CURATED platform capability name. -// ─────────────────────────────────────────────────────────────────────────── - -/** One declared capability refused because its name is platform-curated. */ -export interface CapabilityPlatformNameRefusedDiagnostic extends SeedRefusalDiagnosticBase { - readonly event: typeof CAPABILITY_PLATFORM_NAME_REFUSED; - /** The declared capability's name — a member of `PLATFORM_CAPABILITY_NAMES`. */ - readonly name: string; - /** - * The package whose declaration was dropped. `null` when the declaration - * carries no resolvable owner either: this refusal is decided BEFORE the - * owner check, so both faults can be true of one declaration and printing - * `undefined` at an author is not an option. - */ - readonly declaredBy: string | null; - /** The bootstrap permission set(s) granting it — the blast radius, named. */ - readonly grantedBy: readonly string[]; -} - -/** - * Word the curated-name refusal. - * - * ⚠️ The consequence differs from a foreign-owner collision even though both - * end in "the declaration was not applied": the curated pass of - * `bootstrapSystemCapabilities` seeds every curated name UNCONDITIONALLY, so - * the row is there whatever this seeder decides. Nothing is denied. What the - * author loses is the authored label/description/scope and the ADR-0086 D3 - * provenance claim — and the remedy is not "co-own the namespace" (the - * ADR-0130 D1 escape the collision diagnostic offers) but "rename", because a - * curated platform name is never available to a package on any terms. - */ -export function capabilityPlatformNameRefusedDiagnostic(input: { - name: string; - declaredBy?: string | null; - grantedBy?: readonly string[]; -}): CapabilityPlatformNameRefusedDiagnostic { - const declaredBy = input.declaredBy ?? null; - const declarer = declaredBy ?? '(a declaration with no owning package)'; - const grantedBy = [...(input.grantedBy ?? [])]; - const blastRadius = grantedBy.length > 0 - ? `Permission set(s) granting it: ${grantedBy.join(', ')} — those grants keep resolving, ` + - `against the PLATFORM's capability rather than this package's declaration.` - : `No bootstrap permission set grants it, so nothing is denied today — but the declaration ` + - `is still inert.`; - return { - event: CAPABILITY_PLATFORM_NAME_REFUSED, - severity: 'warning', - name: input.name, - declaredBy, - grantedBy, - message: - `[security] [${CAPABILITY_PLATFORM_NAME_REFUSED}] Package ${declarer} declares capability ` + - `"${input.name}", but that name is a CURATED PLATFORM capability. The declaration was NOT ` + - `applied — a package must never redefine a platform-owned capability — so the curated pass ` + - `keeps the sys_capability row, the declaring package's authored label, description and ` + - `scope are not in effect, and the capability carries no package provenance (ADR-0086 D3). ` + - `The name still resolves against the curated row, which is why nothing else reports this. ` + - `${blastRadius}`, - fix: - `Rename the capability in package ${declarer} to a name it owns (prefix it with the ` + - `package's own module prefix). ⛔ A curated platform name is not co-ownable: unlike a ` + - `package-to-package collision (ADR-0130 D1) there is no arrangement under which a package ` + - `may declare it.`, - }; -} - -/** Report one curated-name refusal so it reaches the author with no sink injected. */ -export function reportCapabilityPlatformNameRefused( - logger: CollisionReportSink | undefined, - d: CapabilityPlatformNameRefusedDiagnostic, -): void { - reportThroughSink(logger, `${d.message} Fix: ${d.fix}`, { - event: d.event, - name: d.name, - declaredBy: d.declaredBy, - grantedBy: [...d.grantedBy], - fix: d.fix, - }); -} - -// ─────────────────────────────────────────────────────────────────────────── -// SITE 2 — a declared CAPABILITY with no resolvable owning package. -// ─────────────────────────────────────────────────────────────────────────── - -/** One declared capability refused for want of an owning package. */ -export interface CapabilityDeclarationUnownedDiagnostic extends SeedRefusalDiagnosticBase { - readonly event: typeof CAPABILITY_DECLARATION_UNOWNED; - readonly name: string; - /** The bootstrap permission set(s) granting it. */ - readonly grantedBy: readonly string[]; - /** Whether a `sys_capability` row already resolves the name — it decides the consequence. */ - readonly hasRow: boolean; -} - -/** - * Word the unowned-capability refusal. - * - * ⚠️ [#4967 Part 3] The three-way consequence below is LOAD-BEARING and is - * preserved verbatim from the sentence this card found at the call site: a - * seeder-side warn that names the capability but not the permission set(s) that - * grant it does not tell the reader what happened. `hasRow` is the reason the - * existence read is taken BEFORE the refusal rather than after it. - */ -export function capabilityDeclarationUnownedDiagnostic(input: { - name: string; - grantedBy?: readonly string[]; - hasRow: boolean; -}): CapabilityDeclarationUnownedDiagnostic { - const grantedBy = [...(input.grantedBy ?? [])]; - const granted = grantedBy.length > 0 - ? `granted by ${grantedBy.join(', ')}` - : 'granted by no bootstrap permission set'; - const consequence = input.hasRow - ? 'an existing sys_capability row already resolves it and is left as-is — the declaration adds no package provenance' - : grantedBy.length > 0 - ? 'falls back to the back-compat derived placeholder — the grant resolves, but with no package provenance (ADR-0086 D3: uninstall undefined)' - : 'nothing derives it either — the capability is materialized nowhere'; - return { - event: CAPABILITY_DECLARATION_UNOWNED, - severity: 'warning', - name: input.name, - grantedBy, - hasRow: input.hasRow, - message: - `[security] [${CAPABILITY_DECLARATION_UNOWNED}] declared capability "${input.name}" has no ` + - `owning package (${granted}): ${consequence}`, - fix: - `Stamp the declaring package on the capability — the SchemaRegistry's _packageId ` + - `(ADR-0010), or the spec-level packageId (ADR-0086 D3) as the author-declared fallback. ` + - `A package-managed row with no package_id is the ambiguity ADR-0086 D3 removes, which is ` + - `why this seeder writes none.`, - }; -} - -/** Report one unowned-capability refusal so it reaches the author with no sink injected. */ -export function reportCapabilityDeclarationUnowned( - logger: CollisionReportSink | undefined, - d: CapabilityDeclarationUnownedDiagnostic, -): void { - reportThroughSink(logger, `${d.message}. Fix: ${d.fix}`, { - event: d.event, - name: d.name, - grantedBy: [...d.grantedBy], - hasRow: d.hasRow, - fix: d.fix, - }); -} - -// ─────────────────────────────────────────────────────────────────────────── -// SITE 3 — declared capabilities whose rows could not be READ. -// ─────────────────────────────────────────────────────────────────────────── - -/** - * Report the unreadable-rows summary for the capability pass. - * - * ONE line per pass with the count, never a line per name: a database that is - * down refuses every name at once and a warn each is a flood that buries its - * own meaning. - * - * ⚠️ The consequence has to be SAID. "Unreadable" alone states none, and the - * two halves differ: a genuinely new declaration among these names has no row - * at all, while one whose stored metadata drifted from the shipped declaration - * keeps the stale value. Nothing was written, so nothing is lost — the next - * boot with a readable database does both — but until then the registry does - * not reflect what these packages declare. - * - * ⛔ This one is deliberately NOT exported from the package entry: unlike the - * two refusals an author can cause, an unreadable database is a runtime - * condition no compile-time door can raise, so there is no second consumer by - * construction (the discrimination `position_name_fold_grant` already makes). - */ -export function reportCapabilityRowsUnreadable( - logger: CollisionReportSink | undefined, - input: { unreadable: number; total: number }, -): void { - reportThroughSink( - logger, - `[security] [${CAPABILITY_ROWS_UNREADABLE}] declared capabilities left untouched — their ` + - `sys_capability rows could not be read. ${input.unreadable} of ${input.total} declaration(s) ` + - `were neither created nor reconciled: a genuinely new one among them has no sys_capability ` + - `row, and one whose stored label, description or scope has drifted from the shipped ` + - `declaration keeps the stale value. Nothing was written and nothing is lost — the next boot ` + - `with a readable database does both — but until then the registry does not reflect what ` + - `these packages declare.`, - { event: CAPABILITY_ROWS_UNREADABLE, unreadable: input.unreadable, total: input.total }, - ); -} - -// ─────────────────────────────────────────────────────────────────────────── -// SITE 4 — a declared PERMISSION SET with no resolvable owning package. -// ─────────────────────────────────────────────────────────────────────────── - -/** One declared permission set refused for want of an owning package. */ -export interface PermissionSetDeclarationUnownedDiagnostic extends SeedRefusalDiagnosticBase { - readonly event: typeof PERMISSION_SET_DECLARATION_UNOWNED; - readonly name: string; - /** The organization whose pass refused it, when the walled per-organization door is in force. */ - readonly organizationId?: string; -} - -/** - * Word the unowned-permission-set refusal. - * - * ⚠️ The consequence is NOT the capability axis'. `stack.permissions` has - * always been runtime-ENFORCED — the evaluator resolves declared sets through - * the metadata registry — so every grant in the refused set keeps working. What - * is lost is the RECORD (ADR-0086 D5, the ADR-0078 inert-metadata smell this - * seeder exists to close): the Setup admin surface reads `sys_permission_set` - * and cannot see the set, there is no provenance axis for it, and uninstall - * (`cleanupPackagePermissions`) has nothing to reap. An author told "not - * materialized" and left to guess will go looking for a denied user who does - * not exist. - */ -export function permissionSetDeclarationUnownedDiagnostic(input: { - name: string; - organizationId?: string; -}): PermissionSetDeclarationUnownedDiagnostic { - return { - event: PERMISSION_SET_DECLARATION_UNOWNED, - severity: 'warning', - name: input.name, - ...(input.organizationId ? { organizationId: input.organizationId } : {}), - message: - `[security] [${PERMISSION_SET_DECLARATION_UNOWNED}] declared permission set ` + - `"${input.name}" has no owning package — not materialized. A managed_by:'package' row ` + - `without a package_id is the ambiguity ADR-0086 D3 removes, so no row is written. The ` + - `evaluator still resolves the DECLARED set through the metadata registry, so its object, ` + - `field, tab and system permissions all keep working — what is missing is the RECORD: the ` + - `Setup admin surface reads sys_permission_set and cannot see this set, it carries no ` + - `provenance axis, and uninstall has nothing to reap (ADR-0086 D5).`, - fix: - `Stamp the declaring package on the set — the SchemaRegistry's _packageId (ADR-0010), or ` + - `the spec-level packageId (ADR-0086 D3) as the author-declared fallback.`, - }; -} - -/** Report one unowned-set refusal so it reaches the author with no sink injected. */ -export function reportPermissionSetDeclarationUnowned( - logger: CollisionReportSink | undefined, - d: PermissionSetDeclarationUnownedDiagnostic, -): void { - reportThroughSink(logger, `${d.message} Fix: ${d.fix}`, { - event: d.event, - name: d.name, - ...(d.organizationId ? { organization: d.organizationId } : {}), - fix: d.fix, - }); -} - -// ─────────────────────────────────────────────────────────────────────────── -// SITE 5 — declared permission sets whose rows could not be READ. -// ─────────────────────────────────────────────────────────────────────────── - -/** - * Report the unreadable-rows summary for the permission-set pass. - * - * ⚠️ Said once with the count, and the consequence spelled out for the SAME - * reason as the capability summary and with a DIFFERENT content: silence here - * reads exactly like "everything was already in order". The declared sets stay - * runtime-enforced through the metadata registry, so nothing is denied — what - * did not happen is the materialization, and a drifted row keeps its stale - * grants until a boot that can read the table. - * - * ⛔ Package-private for the same reason as its capability sibling: an - * unreadable database is a runtime condition, not an authoring fault. - */ -export function reportPermissionSetRowsUnreadable( - logger: CollisionReportSink | undefined, - input: { unreadable: number; total: number; organizationId?: string }, -): void { - reportThroughSink( - logger, - `[security] [${PERMISSION_SET_ROWS_UNREADABLE}] declared permission sets left untouched — ` + - `their records could not be read. ${input.unreadable} of ${input.total} declared set(s) ` + - `were neither seeded nor reconciled: a genuinely new set has no sys_permission_set row, so ` + - `the Setup admin surface cannot see it and uninstall has nothing to reap (ADR-0086 D5), and ` + - `a set whose stored grants drifted from the shipped declaration keeps the stale row. The ` + - `evaluator still resolves the DECLARED sets through the metadata registry, so no grant is ` + - `denied today; the next boot with a readable database materializes them.`, - { - event: PERMISSION_SET_ROWS_UNREADABLE, - unreadable: input.unreadable, - total: input.total, - ...(input.organizationId ? { organization: input.organizationId } : {}), - }, - ); -} diff --git a/packages/plugins/plugin-security/src/seed-write-refusal.test.ts b/packages/plugins/plugin-security/src/seed-write-refusal.test.ts index 47338c864b6..f29e4b80c68 100644 --- a/packages/plugins/plugin-security/src/seed-write-refusal.test.ts +++ b/packages/plugins/plugin-security/src/seed-write-refusal.test.ts @@ -28,7 +28,7 @@ * is the ask, fatal is not: a rethrow would turn a silent degradation into a * boot failure on every deployment carrying the legacy index; * 2. the warning is AGGREGATED — N refusals in one pass produce one line, not - * N, the same discipline `warnOrganizationLessRows` is built on (a catalog + * N, the same discipline the retired organization-less row guard was built on (a catalog * that refuses 400 rows must not print 400 warnings and bury the remedy); * 3. a NON-unique-violation refusal is not silently reclassified as one — it * gets its own line, because the migrate remedy does not apply to it and diff --git a/packages/plugins/plugin-security/src/translations/en.objects.generated.ts b/packages/plugins/plugin-security/src/translations/en.objects.generated.ts index cfdc7def891..e2cbd494c0b 100644 --- a/packages/plugins/plugin-security/src/translations/en.objects.generated.ts +++ b/packages/plugins/plugin-security/src/translations/en.objects.generated.ts @@ -226,11 +226,11 @@ export const enObjects: NonNullable = { }, package_id: { label: "Owning Package", - help: "Package that ships this permission set (absent = environment-authored). Populated by bootstrapDeclaredPermissions; makes package uninstall/upgrade well-defined." + help: "Package that ships this permission set (absent = environment-authored). Set on rows an earlier release seeded for a package's set; no current writer sets it." }, managed_by: { label: "Managed By", - help: "Record provenance, on the one platform / package / admin vocabulary that capabilities, permission sets and positions all share: 'platform' = shipped by the platform; 'package' = versioned package metadata (re-seeded on upgrade, read-mostly for admins); 'admin' = created/owned in this environment by an administrator (live-edited, never touched by package seeding). Legacy rows may carry 'user' (== admin).", + help: "Record provenance, on the one platform / package / admin vocabulary that capabilities, permission sets and positions all share: 'platform' = shipped by the platform; 'package' = a row an earlier release seeded for a package's set (read-mostly for admins); 'admin' = created/owned in this environment by an administrator (live-edited). Legacy rows may carry 'user' (== admin).", options: { platform: "Platform", package: "Package", @@ -241,19 +241,6 @@ export const enObjects: NonNullable = { label: "Customized", help: "This packaged permission set has an environment customization overlay (ADR-0094). Reset it (delete through the data door) to return to the shipped baseline." }, - drift_status: { - label: "Declaration Drift", - help: "Cause of a declared≠enforced mismatch for this package-declared set. 'overlay_shadow' = a Studio-authored environment overlay is shadowing the packaged declaration. 'provenance_skip' = this row predates package provenance tracking, so boot sync treats it as environment-authored and never reconciles it with the package (no automated fix — file an issue / see the ops runbook for the manual adoption recipe). Absent = enforced grants match the shipped artifact, or this set is not currently package-declared.", - options: { - overlay_shadow: "Overlay shadow", - provenance_skip: "Provenance skip", - other: "Drift (other)" - } - }, - drift_detail: { - label: "Drift Detail", - help: "Human-readable detail for `drift_status` — names the grant-count mismatch and its cause." - }, id: { label: "Permission Set ID" }, @@ -273,9 +260,6 @@ export const enObjects: NonNullable = { }, all_permsets: { label: "All" - }, - drifted: { - label: "Needs Attention" } }, _actions: { diff --git a/packages/plugins/plugin-security/src/translations/es-ES.objects.generated.ts b/packages/plugins/plugin-security/src/translations/es-ES.objects.generated.ts index 745b3549a79..9e7c04cca4e 100644 --- a/packages/plugins/plugin-security/src/translations/es-ES.objects.generated.ts +++ b/packages/plugins/plugin-security/src/translations/es-ES.objects.generated.ts @@ -241,19 +241,6 @@ export const esESObjects: NonNullable = { label: "Personalizado", help: "Este conjunto de permisos empaquetado tiene una superposición de personalización del entorno (ADR-0094). Reinícialo (elimínalo por la puerta de datos) para volver a la línea base distribuida." }, - drift_status: { - label: "Declaration Drift", - help: "Cause of a declared≠enforced mismatch for this package-declared set. 'overlay_shadow' = a Studio-authored environment overlay is shadowing the packaged declaration. 'provenance_skip' = this row predates package provenance tracking, so boot sync treats it as environment-authored and never reconciles it with the package (no automated fix — file an issue / see the ops runbook for the manual adoption recipe). Absent = enforced grants match the shipped artifact, or this set is not currently package-declared.", - options: { - overlay_shadow: "Overlay shadow", - provenance_skip: "Provenance skip", - other: "Drift (other)" - } - }, - drift_detail: { - label: "Drift Detail", - help: "Human-readable detail for `drift_status` — names the grant-count mismatch and its cause." - }, id: { label: "ID de conjunto de permisos" }, @@ -273,9 +260,6 @@ export const esESObjects: NonNullable = { }, all_permsets: { label: "Todos" - }, - drifted: { - label: "Needs Attention" } }, _actions: { diff --git a/packages/plugins/plugin-security/src/translations/es-ES.source-hashes.generated.ts b/packages/plugins/plugin-security/src/translations/es-ES.source-hashes.generated.ts index e092e39af7d..608ee0175cd 100644 --- a/packages/plugins/plugin-security/src/translations/es-ES.source-hashes.generated.ts +++ b/packages/plugins/plugin-security/src/translations/es-ES.source-hashes.generated.ts @@ -18,14 +18,6 @@ */ export const esESGeneratedSourceHashes: Readonly> = { - "objects.sys_permission_set._views.drifted.label": "f7b9325c31edc552", - "objects.sys_permission_set.fields.drift_detail.help": "3572261f6d3bc8ab", - "objects.sys_permission_set.fields.drift_detail.label": "a84ebd221c37d10b", - "objects.sys_permission_set.fields.drift_status.help": "168e382c7568b1f3", - "objects.sys_permission_set.fields.drift_status.label": "7946a54138690b86", - "objects.sys_permission_set.fields.drift_status.options.other": "3ddcfb750014f293", - "objects.sys_permission_set.fields.drift_status.options.overlay_shadow": "7371472481b55b52", - "objects.sys_permission_set.fields.drift_status.options.provenance_skip": "fdd5f01e69fa0245", "objects.sys_position._validations.reserved_identity_name.message": "a23aa7c06745cc95", "objects.sys_position_permission_set.fields.display_title.help": "b99cbdc42ee68f06", "objects.sys_position_permission_set.fields.display_title.label": "70f7aadecce647a5", diff --git a/packages/plugins/plugin-security/src/translations/ja-JP.objects.generated.ts b/packages/plugins/plugin-security/src/translations/ja-JP.objects.generated.ts index e8d0fda6fd1..d540be40ba6 100644 --- a/packages/plugins/plugin-security/src/translations/ja-JP.objects.generated.ts +++ b/packages/plugins/plugin-security/src/translations/ja-JP.objects.generated.ts @@ -241,19 +241,6 @@ export const jaJPObjects: NonNullable = { label: "カスタマイズ済み", help: "このパッケージ権限セットには環境カスタマイズのオーバーレイがあります(ADR-0094)。リセット(データドア経由で削除)すると出荷時のベースラインに戻ります。" }, - drift_status: { - label: "Declaration Drift", - help: "Cause of a declared≠enforced mismatch for this package-declared set. 'overlay_shadow' = a Studio-authored environment overlay is shadowing the packaged declaration. 'provenance_skip' = this row predates package provenance tracking, so boot sync treats it as environment-authored and never reconciles it with the package (no automated fix — file an issue / see the ops runbook for the manual adoption recipe). Absent = enforced grants match the shipped artifact, or this set is not currently package-declared.", - options: { - overlay_shadow: "Overlay shadow", - provenance_skip: "Provenance skip", - other: "Drift (other)" - } - }, - drift_detail: { - label: "Drift Detail", - help: "Human-readable detail for `drift_status` — names the grant-count mismatch and its cause." - }, id: { label: "権限セット ID" }, @@ -273,9 +260,6 @@ export const jaJPObjects: NonNullable = { }, all_permsets: { label: "すべて" - }, - drifted: { - label: "Needs Attention" } }, _actions: { diff --git a/packages/plugins/plugin-security/src/translations/ja-JP.source-hashes.generated.ts b/packages/plugins/plugin-security/src/translations/ja-JP.source-hashes.generated.ts index e60a8172406..51efd73cf9b 100644 --- a/packages/plugins/plugin-security/src/translations/ja-JP.source-hashes.generated.ts +++ b/packages/plugins/plugin-security/src/translations/ja-JP.source-hashes.generated.ts @@ -18,14 +18,6 @@ */ export const jaJPGeneratedSourceHashes: Readonly> = { - "objects.sys_permission_set._views.drifted.label": "f7b9325c31edc552", - "objects.sys_permission_set.fields.drift_detail.help": "3572261f6d3bc8ab", - "objects.sys_permission_set.fields.drift_detail.label": "a84ebd221c37d10b", - "objects.sys_permission_set.fields.drift_status.help": "168e382c7568b1f3", - "objects.sys_permission_set.fields.drift_status.label": "7946a54138690b86", - "objects.sys_permission_set.fields.drift_status.options.other": "3ddcfb750014f293", - "objects.sys_permission_set.fields.drift_status.options.overlay_shadow": "7371472481b55b52", - "objects.sys_permission_set.fields.drift_status.options.provenance_skip": "fdd5f01e69fa0245", "objects.sys_position._validations.reserved_identity_name.message": "a23aa7c06745cc95", "objects.sys_position_permission_set.fields.display_title.help": "b99cbdc42ee68f06", "objects.sys_position_permission_set.fields.display_title.label": "70f7aadecce647a5", diff --git a/packages/plugins/plugin-security/src/translations/zh-CN.objects.generated.ts b/packages/plugins/plugin-security/src/translations/zh-CN.objects.generated.ts index 093d7714a18..4859358e9c9 100644 --- a/packages/plugins/plugin-security/src/translations/zh-CN.objects.generated.ts +++ b/packages/plugins/plugin-security/src/translations/zh-CN.objects.generated.ts @@ -241,19 +241,6 @@ export const zhCNObjects: NonNullable = { label: "已定制", help: "该打包权限集存在环境定制覆盖层(ADR-0094)。重置它(通过数据入口删除)即可回到随包发布的基线。" }, - drift_status: { - label: "Declaration Drift", - help: "Cause of a declared≠enforced mismatch for this package-declared set. 'overlay_shadow' = a Studio-authored environment overlay is shadowing the packaged declaration. 'provenance_skip' = this row predates package provenance tracking, so boot sync treats it as environment-authored and never reconciles it with the package (no automated fix — file an issue / see the ops runbook for the manual adoption recipe). Absent = enforced grants match the shipped artifact, or this set is not currently package-declared.", - options: { - overlay_shadow: "Overlay shadow", - provenance_skip: "Provenance skip", - other: "Drift (other)" - } - }, - drift_detail: { - label: "Drift Detail", - help: "Human-readable detail for `drift_status` — names the grant-count mismatch and its cause." - }, id: { label: "权限集 ID" }, @@ -273,9 +260,6 @@ export const zhCNObjects: NonNullable = { }, all_permsets: { label: "全部" - }, - drifted: { - label: "Needs Attention" } }, _actions: { diff --git a/packages/plugins/plugin-security/src/translations/zh-CN.source-hashes.generated.ts b/packages/plugins/plugin-security/src/translations/zh-CN.source-hashes.generated.ts index 64766ab31ac..f97c9467f5f 100644 --- a/packages/plugins/plugin-security/src/translations/zh-CN.source-hashes.generated.ts +++ b/packages/plugins/plugin-security/src/translations/zh-CN.source-hashes.generated.ts @@ -18,14 +18,6 @@ */ export const zhCNGeneratedSourceHashes: Readonly> = { - "objects.sys_permission_set._views.drifted.label": "f7b9325c31edc552", - "objects.sys_permission_set.fields.drift_detail.help": "3572261f6d3bc8ab", - "objects.sys_permission_set.fields.drift_detail.label": "a84ebd221c37d10b", - "objects.sys_permission_set.fields.drift_status.help": "168e382c7568b1f3", - "objects.sys_permission_set.fields.drift_status.label": "7946a54138690b86", - "objects.sys_permission_set.fields.drift_status.options.other": "3ddcfb750014f293", - "objects.sys_permission_set.fields.drift_status.options.overlay_shadow": "7371472481b55b52", - "objects.sys_permission_set.fields.drift_status.options.provenance_skip": "fdd5f01e69fa0245", "objects.sys_position._validations.reserved_identity_name.message": "a23aa7c06745cc95", "objects.sys_position_permission_set.fields.display_title.help": "b99cbdc42ee68f06", "objects.sys_position_permission_set.fields.display_title.label": "70f7aadecce647a5", diff --git a/packages/plugins/plugin-security/src/write-refusals.ts b/packages/plugins/plugin-security/src/write-refusals.ts index 090703c15ed..2430dfda0e6 100644 --- a/packages/plugins/plugin-security/src/write-refusals.ts +++ b/packages/plugins/plugin-security/src/write-refusals.ts @@ -135,8 +135,8 @@ export interface SeedWriteRefusalReport { /** * The refusals ONE seeding pass accumulated, so the pass can report them once. * - * Aggregate-then-warn, for the same reason {@link warnOrganizationLessRows} - * aggregates: the catalog seeds every declared position, permission set and + * Aggregate-then-warn, for the same reason the retired organization-less row guard + * aggregated: the catalog seeds every declared position, permission set and * capability for every organization, and the failure this exists to surface * refuses ALL of them. A line per refused row would print hundreds of entries * into the boot log and bury the one sentence naming the remedy. One diff --git a/packages/qa/dogfood/test/authz-conformance.matrix.ts b/packages/qa/dogfood/test/authz-conformance.matrix.ts index c7a6a734ff6..fc349611546 100644 --- a/packages/qa/dogfood/test/authz-conformance.matrix.ts +++ b/packages/qa/dogfood/test/authz-conformance.matrix.ts @@ -406,9 +406,9 @@ export const AUTHZ_CONFORMANCE: AuthzPrimitive[] = [ enforcement: 'plugin-security getEffectiveScope (stash) + plugin-sharing delegates HIERARCHY scopes to a pluggable IHierarchyScopeResolver (open: fail-closed to own; enterprise @objectstack/security-enterprise; reference resolver in this proof) — ADR-0057 D1', proof: 'showcase-scope-depth.dogfood.test.ts' }, { id: 'declarative-rbac-seeding', summary: 'stack-declared positions served by the security catalog (no row) + sharingRules seeded at boot', state: 'enforced', enforcement: 'core createSecurityCatalogReader (positions from the registry, ADR-0131 D2) + plugin-sharing bootstrapDeclaredSharingRules — ADR-0057 D6', proof: 'showcase-declarative-rbac-seeding.dogfood.test.ts' }, - { id: 'declarative-permission-seeding', summary: 'stack-declared permission sets seeded into sys_permission_set with package provenance (packageId + managed_by)', state: 'enforced', - enforcement: 'plugin-security bootstrapDeclaredPermissions — ADR-0086 D5 (managed_by:package re-seeded on boot/upgrade; env-authored platform/user/legacy rows never clobbered); provenance fields ADR-0086 D3 (spec PermissionSetSchema.packageId/managedBy + sys_permission_set.package_id/managed_by)', proof: 'showcase-permission-seeding.dogfood.test.ts', - note: 'Closes the ADR-0078 inert-metadata violation for stack.permissions — declared sets were runtime-enforced via the registry but never materialized as records (invisible to the admin surface, uninstall undefined). This row pins the seeding so it cannot silently regress to inert.' }, + { id: 'declarative-permission-seeding', summary: 'stack-declared permission sets served by the security catalog with their owning package (no row)', state: 'enforced', + enforcement: 'core createSecurityCatalogReader (permission sets from the registry, `_packageId` the owner, ADR-0131 D3); the boot writes no sys_permission_set row (C3 stage 6b-2 retired ADR-0086 D5\'s seeding)', proof: 'showcase-permission-seeding.dogfood.test.ts', + note: 'Was the ADR-0086 D5 seeding pin — declared sets materialized as managed_by:package records. ADR-0131 D3 gives the catalog one home, the registry, and the proof now pins that a declared set is served from it with its package while no row is written.' }, { id: 'rbac-role-assignment', summary: 'platform-owned RBAC assignment (sys_user_position, decoupled from better-auth membership)', state: 'enforced', enforcement: 'runtime/resolve-execution-context.ts reads sys_user_position (union sys_member.role) — ADR-0057 D4' }, diff --git a/packages/qa/dogfood/test/package-uninstall-revokes-named-grants.dogfood.test.ts b/packages/qa/dogfood/test/package-uninstall-revokes-named-grants.dogfood.test.ts new file mode 100644 index 00000000000..196ea52b24c --- /dev/null +++ b/packages/qa/dogfood/test/package-uninstall-revokes-named-grants.dogfood.test.ts @@ -0,0 +1,91 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// ADR-0090 D5 ("no ghost grants") on the protocol's own uninstall door, +// `deletePackage` — after ADR-0131 C3 stage 6b-2, where no seeder or +// materializer writes a `sys_permission_set` row: a package's set is served by +// the security catalog from the registry, and a grant names it. (Until stage 7 +// retires the ADR-0094 projector, the publish below still has it write an +// `admin` projection row that no authorization path reads; the uninstall +// removes it.) +// +// The withdrawal (`registry.uninstallPackage`) runs before the uninstall +// cleanups and releases the package's names, so the door captures the +// permission-set names the package held just before it, and plugin-security's +// cleanup revokes the grants naming them. Without that hand-off the grant +// survives the uninstall, inert while nothing serves the name, and is live +// again the moment the package publishes the set again. The install-local door +// is pinned end to end by the CLI suite +// `package-install-local-uninstall-cleanups.integration.test.ts`. +// +// Real stack: the CRM example over the real SecurityPlugin; the package is a +// writable base that publishes its own set (ADR-0070), and the uninstall is +// `deletePackage` on it. + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import crmStack from '@objectstack/example-crm'; +import { bootStack, type VerifyStack } from '@objectstack/verify'; +import { catalogPermissionSet } from './catalog-permission-set.js'; + +const SYS = { isSystem: true }; +const BASE = 'app.dogfood_uninstall_grants'; +const SET = 'dfu_task_user'; +const USER = 'usr_dfu_grantee'; + +describe('dogfood: deletePackage revokes the grants naming the package\'s permission sets (ADR-0090 D5)', () => { + let stack: VerifyStack; + let protocol: any; + let ql: any; + + const publishSet = () => protocol.saveMetaItem({ + type: 'permission', + name: SET, + item: { name: SET, label: 'DFU Task User', objects: { crm_lead: { allowRead: true } } }, + packageId: BASE, + mode: 'publish', + }); + const grants = async () => + ((await ql.find('sys_user_permission_set', { where: { permission_set: SET }, context: SYS })) ?? []) as any[]; + + let granted: any; + let uninstall: any; + let afterUninstall: { served: unknown; grants: any[] }; + let afterRepublish: { served: unknown; grants: any[] }; + + beforeAll(async () => { + stack = await bootStack(crmStack); + protocol = await stack.kernel.getServiceAsync('protocol'); + ql = await stack.kernel.getServiceAsync('objectql'); + + await ql.insert('sys_user', { id: USER, email: 'dfu@example.test', name: 'DFU', email_verified: true }, { context: SYS }); + await publishSet(); + granted = await ql.insert('sys_user_permission_set', { user_id: USER, permission_set: SET }, { context: SYS }); + + uninstall = await protocol.deletePackage({ packageId: BASE }); + afterUninstall = { served: await catalogPermissionSet(ql, SET), grants: await grants() }; + + // The same package publishes the same set again. + await publishSet(); + afterRepublish = { served: await catalogPermissionSet(ql, SET), grants: await grants() }; + }, 120_000); + afterAll(async () => { + await stack?.stop?.(); + }); + + it('precondition: the package\'s set was served and the user held a grant naming it', () => { + expect(granted?.id).toBeTruthy(); + expect(granted?.permission_set).toBe(SET); + }); + + it('the uninstall reports the security cleanup, and the grant is gone with the set', () => { + const security = (uninstall?.cleanups ?? []).find((o: any) => o?.name === 'security.package-permissions'); + expect(security, JSON.stringify(uninstall)).toMatchObject({ success: true }); + expect(security.removed).toBeGreaterThanOrEqual(1); + expect(afterUninstall.served).toBeUndefined(); + expect(afterUninstall.grants).toEqual([]); + }); + + it('the set published again is served again — and the revoked grant does not come back with it', () => { + expect((afterRepublish.served as any)?.name).toBe(SET); + expect(afterRepublish.grants).toEqual([]); + }); +}); diff --git a/packages/qa/dogfood/test/showcase-permission-seeding.dogfood.test.ts b/packages/qa/dogfood/test/showcase-permission-seeding.dogfood.test.ts index 251657ca92e..0df4427537c 100644 --- a/packages/qa/dogfood/test/showcase-permission-seeding.dogfood.test.ts +++ b/packages/qa/dogfood/test/showcase-permission-seeding.dogfood.test.ts @@ -1,12 +1,12 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. // -// ADR-0086 D5 — stack-declared `permissions` are seeded into -// `sys_permission_set` at boot with package provenance -// (`managed_by:'package'` + `package_id`), closing the ADR-0078 -// inert-metadata violation for this surface: the admin table finally sees a -// package's sets, and uninstall/upgrade have a well-defined owner axis. -// Proven on the real showcase stack, which declares `showcase_contributor` -// and `showcase_member_default` in `src/security/`. +// ADR-0131 D3 (C3 stage 6b-2) — stack-declared `permissions` are served by the +// security catalog from the registry, with their owning package, and the boot +// writes NO `sys_permission_set` row: not for a package's sets, not for the +// platform defaults. (ADR-0086 D5's boot seeding, which this file used to pin, +// retired with the declared-permission seeder.) Proven on the real showcase +// stack, which declares `showcase_contributor` and `showcase_member_default` +// in `src/security/`. // // ADR-0056 D10 — the authz-conformance matrix row this file is the cited proof // for; `authz-conformance.test.ts` asserts the pairing is mutual (#7976). @@ -15,8 +15,9 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { type VerifyStack } from '@objectstack/verify'; import { bootShowcase } from './showcase-boot.js'; +import { catalogPermissionSet } from './catalog-permission-set.js'; -describe('showcase: declared permission-set seeding (ADR-0086 D5)', () => { +describe('showcase: declared permission sets are served by the security catalog (ADR-0131 D3)', () => { let stack: VerifyStack; let ql: any; @@ -27,27 +28,26 @@ describe('showcase: declared permission-set seeding (ADR-0086 D5)', () => { }, 60_000); afterAll(async () => { await stack?.stop(); }); - it('declared sets land in sys_permission_set with package provenance', async () => { - const rows = await ql.find('sys_permission_set', { where: {} }, { context: { isSystem: true } }); - const contributor = (rows ?? []).find((r: any) => r.name === 'showcase_contributor'); - expect(contributor, 'declared set must be materialized as a record').toBeTruthy(); - expect(contributor.managed_by).toBe('package'); - expect(contributor.package_id).toBe('com.example.showcase'); - // the record carries the actual declared grants, not an empty husk - const objectPerms = JSON.parse(contributor.object_permissions || '{}'); - expect(Object.keys(objectPerms).length).toBeGreaterThan(0); + it('a declared set is served with its owning package and its declared grants', async () => { + const contributor: any = await catalogPermissionSet(ql, 'showcase_contributor'); + expect(contributor, 'declared set served by the security catalog').toBeTruthy(); + expect(contributor._packageId).toBe('com.example.showcase'); + // the definition carries the actual declared grants, not an empty husk + expect(Object.keys(contributor.objects ?? {}).length).toBeGreaterThan(0); }); - it('platform defaults stay env-owned (no package provenance stamped)', async () => { - const rows = await ql.find('sys_permission_set', { where: { name: 'member_default' } }, { context: { isSystem: true } }); - const memberDefault = (rows ?? [])[0]; - expect(memberDefault, 'bootstrapPlatformAdmin default exists').toBeTruthy(); - // bootstrapDeclaredPermissions must not adopt/clobber the insert-once default - expect(memberDefault.managed_by ?? null).not.toBe('package'); + it('a platform default is served too, with no package of the app stamped on it', async () => { + const memberDefault: any = await catalogPermissionSet(ql, 'member_default'); + expect(memberDefault, 'platform default served by the security catalog').toBeTruthy(); + expect(memberDefault._packageId ?? null).not.toBe('com.example.showcase'); }); - it('seeding is idempotent (exactly one row per declared set)', async () => { - const rows = await ql.find('sys_permission_set', { where: { name: 'showcase_contributor' } }, { context: { isSystem: true } }); - expect((rows ?? []).length).toBe(1); + it('the boot writes no sys_permission_set row — and the census can see one', async () => { + const SYS = { context: { isSystem: true } }; + expect(await ql.find('sys_permission_set', { where: {} }, SYS)).toEqual([]); + // Positive control: the same read sees a row once one exists. + await ql.insert('sys_permission_set', { id: 'ps_census_control', name: 'zz_census_control', label: 'Control' }, SYS); + expect(((await ql.find('sys_permission_set', { where: {} }, SYS)) ?? []).map((r: any) => r.name)).toEqual(['zz_census_control']); + await ql.delete('sys_permission_set', { where: { id: 'ps_census_control' }, ...SYS }); }); }); diff --git a/packages/runtime/src/standalone-stack-seeder-declaration-copy.test.ts b/packages/runtime/src/standalone-stack-seeder-declaration-copy.test.ts index 4912ea96e7a..d50d18afb94 100644 --- a/packages/runtime/src/standalone-stack-seeder-declaration-copy.test.ts +++ b/packages/runtime/src/standalone-stack-seeder-declaration-copy.test.ts @@ -13,9 +13,9 @@ // copies, and the plugin-security / plugin-sharing seeders read the registry // one FIRST: // -// bootstrap-declared-permissions.ts readDeclared(ql, 'permission') then metadataService.list -// (bootstrap-declared-capabilities.ts did the same for 'capability' until -// ADR-0131 D3 retired it: a declared capability now has no row at all) +// (bootstrap-declared-permissions.ts did the same for 'permission', and +// bootstrap-declared-capabilities.ts for 'capability', until ADR-0131 D3 +// retired them: a declared permission set or capability now has no row at all) // bootstrap-declared-sharing-rules.ts readDeclared(engine,'sharing_rule') then metadataService.list // // Triage (14491#issuecomment-5507909226) ruled the card down to four questions @@ -422,44 +422,8 @@ describe('#14491 Q1 + Q4 — which copy the seeders consume, and which copy wins expect(door).toMatchObject({ isDefault: false, _packageVersion: '3.0.0' }); }); - it('⚠️ NOT ABSORBED (parse axis only) — `sys_permission_set` persists the registry copy byte for byte: forward-converted since #22994, still UN-PARSED', () => { - const reg = byName(registryCopy.permission, 'probe_agent'); - const door = byName(doorCopy.permission, 'probe_agent'); - const row = byName(rows.sys_permission_set, 'probe_agent'); - - // The decisive read: the stored authorization map is deep-equal to the - // registry copy's and NOT to the door's. Nothing between `readDeclared` and - // `tryInsert` re-parses it — `permissionSetRowFields` serialises what it - // was handed. What still separates it from the door's is the parse axis - // alone (the three defaulted grant bits). - const storedObjects = JSON.parse(row.object_permissions); - expect(storedObjects).toEqual(reg.objects); - expect(storedObjects).not.toEqual(door.objects); - - // [#22994] CONVERGED: the RLS entries carry nothing the parse adds, so once - // the conversion strips `priority` from the registry copy the stored RLS IS - // the door's. (Measured raw before #22994: `priority: 10` stored, and the - // two unequal.) - const storedRls = JSON.parse(row.row_level_security); - expect(storedRls).toEqual(reg.rowLevelSecurity); - expect(storedRls).toEqual(door.rowLevelSecurity); - - // Spelled out key by key, so a future change to either side names itself. - expect(storedObjects.crm_ticket).toEqual({ - allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true, - }); - expect(storedObjects.crm_ticket).not.toHaveProperty('allowRestore'); - expect(storedObjects.crm_ticket).not.toHaveProperty('allowPurge'); - expect(storedObjects.crm_ticket).not.toHaveProperty('allowTransfer'); - expect(storedObjects.crm_ticket).not.toHaveProperty('viewAllRecords'); - expect(storedObjects.crm_ticket).not.toHaveProperty('modifyAllRecords'); - expect(storedRls).toEqual([ - { name: 'own_tasks', object: 'crm_task', operation: 'select', using: 'assignee == current_user.email', enabled: true }, - ]); - - expect(row).toMatchObject({ - managed_by: 'package', package_id: 'com.test.issue-14491', active: true, customized: false, - }); + it('no row: a declared permission set is served by the registry and no `sys_permission_set` row is written for it (ADR-0131 D3)', () => { + expect(rows.sys_permission_set.filter((r: any) => r.name === 'probe_agent')).toEqual([]); }); it('`sharing_rule`: the registry copy carries the forward-converted `sharedWith.type` and `accessLevel` the door carries', () => { diff --git a/packages/spec/liveness/permission.json b/packages/spec/liveness/permission.json index 2906221a2b9..f5aa8eb1fda 100644 --- a/packages/spec/liveness/permission.json +++ b/packages/spec/liveness/permission.json @@ -22,15 +22,14 @@ }, "packageId": { "status": "live", - "verifiedAt": "2026-07-30", - "evidence": "packages/plugins/plugin-security/src/bootstrap-declared-permissions.ts (author-declared fallback for the registry `_packageId` provenance; persisted to sys_permission_set.package_id)", - "note": "ADR-0086 D3 — owning package for a package-shipped set; proven by packages/qa/dogfood/test/showcase-permission-seeding.dogfood.test.ts." + "verifiedAt": "2026-10-11", + "evidence": "packages/cli/src/utils/permission-set-name-collisions.ts (`asId(ps.packageId)` — the author-declared owner a declared set is judged by at the compile-time name-collision door)", + "note": "ADR-0086 D3 — owning package for a package-shipped set. REPOINTED 2026-10-11 (#15204 stage 6b-2): the declared-permission seeder that read it as the `_packageId` fallback and persisted it to sys_permission_set.package_id is retired (ADR-0131 D3: the boot writes no catalog row); the compile-time collision door is the reader that remains." }, "managedBy": { - "status": "live", - "verifiedAt": "2026-07-30", - "evidence": "packages/plugins/plugin-security/src/bootstrap-declared-permissions.ts (persisted per record as sys_permission_set.managed_by; 'package' rows re-seeded, platform/user rows never clobbered)", - "note": "ADR-0086 D3 — per-record provenance on the metadata-persistence axis (package | platform | user)." + "status": "dead", + "verifiedAt": "2026-10-11", + "note": "ADR-0086 D3 — per-record provenance on the metadata-persistence axis (package | platform | user). VERDICT CORRECTED 2026-10-11 (#15204 stage 6b-2): no code reads the AUTHORED key. The cited consumer, bootstrapDeclaredPermissions, stamped `managed_by: 'package'` on every row it wrote and never read `ps.managedBy`, so this row's `live` verdict pointed at a writer of the COLUMN, not a reader of the key; that seeder is now retired (ADR-0131 D3: the boot writes no catalog row). The sys_permission_set.managed_by column is unaffected. Retirement of the authored key is an enforce-or-remove item for the spec seat." }, "adminScope": { "status": "live", diff --git a/packages/spec/liveness/state-counts/permission.md b/packages/spec/liveness/state-counts/permission.md index 729ecb82e76..346eb180cfe 100644 --- a/packages/spec/liveness/state-counts/permission.md +++ b/packages/spec/liveness/state-counts/permission.md @@ -12,4 +12,4 @@ committed anywhere: `check:liveness` sums the shards when it reads them. | Type | live | exp | elsewhere | dead | planned | classified | |---|---|---|---|---|---|---| -| `permission` | 38 | 0 | 0 | 4 | 0 | 42 | +| `permission` | 37 | 0 | 0 | 5 | 0 | 42 | diff --git a/scripts/engine-double-contract.pinned.json b/scripts/engine-double-contract.pinned.json index 5eb11eee029..5bad2e7abff 100644 --- a/scripts/engine-double-contract.pinned.json +++ b/scripts/engine-double-contract.pinned.json @@ -3191,6 +3191,16 @@ "verb": "update", "pinned": 1 }, + { + "file": "packages/plugins/plugin-security/src/everyone-rederivation-on-permission-mutation.test.ts", + "verb": "findOne", + "pinned": 1 + }, + { + "file": "packages/plugins/plugin-security/src/everyone-rederivation-on-permission-mutation.test.ts", + "verb": "update", + "pinned": 1 + }, { "file": "packages/plugins/plugin-security/src/explain-dependency-fault.test.ts", "verb": "findOne",