diff --git a/.changeset/15204-8post-catalog-gates-retired.md b/.changeset/15204-8post-catalog-gates-retired.md new file mode 100644 index 00000000000..451c8435616 --- /dev/null +++ b/.changeset/15204-8post-catalog-gates-retired.md @@ -0,0 +1,24 @@ +--- +'@objectstack/plugin-security': patch +'@objectstack/platform-objects': patch +'@objectstack/spec': patch +'@objectstack/core': patch +'@objectstack/metadata-protocol': patch +--- + +fix(plugin-security): the data-door write gates of the retired catalog row objects are removed, the boot report names positions that bind a permission set nobody serves, and the texts that still named the retired objects and modules are corrected (ADR-0131 D3/D4, C3 stage 8-post) + +Clause-②: no + +Stage 8 retired the `sys_position`, `sys_permission_set`, `sys_position_permission_set` and `sys_capability` row objects, so no write to them reaches the engine. This change removes what only guarded those writes. No export of any package's entry changes, and no request that reaches the server is answered differently. + +- **`@objectstack/plugin-security`.** + - Three of the security middleware's gates on those objects are gone: the package-provenance gate on `sys_permission_set`, the platform/package row gate on `sys_position` and `sys_capability`, and the curated capability-name gate on `sys_capability`. The delegated-administration gate no longer judges `sys_position_permission_set` bindings or `sys_permission_set` authoring; it still governs `sys_user_position` assignments, `sys_user_permission_set` grants and `sys_member`. The rules those gates carried still hold where the catalog now lives: `everyone` and `guest` are code-declared positions an environment cannot overlay, and a capability cannot be created or overlaid through the metadata door at all. + - The walled data-door refusal for those objects is deleted. Stage 8 already left it unwired, and no composition registers the objects it judged; the metadata door's walled refusal is unchanged. + - The internal duplicate-name refusal class that only the retired permission-set data door raised is removed. + - The packaged-permission-set lock's refusal no longer has a "choose a different name" variant: only the retired data door's create raised it. The metadata door's refusal text is unchanged, and the published signatures keep their `operation` parameter. + - The boot's catalog reference report lists one more finding, per position: a position definition whose `permissionSets` names a set the security catalog does not resolve, usually because the package that declared the set is uninstalled or disabled. A holder of such a position is granted nothing through that name. The line clears by itself at the next boot after the package is installed again. It is logged at `error` once per boot, like the report's other lines. +- **`@objectstack/platform-objects`** and **`@objectstack/plugin-security`** object metadata: the descriptions of `sys_invitation.positions`, `sys_user_position` (object and `position`), and `sys_audience_binding_suggestion`'s permission-set name no longer name the retired objects. The translated texts follow. +- **`@objectstack/spec`**: the `projectionApplied` and `materializeApplied` descriptions on the metadata save, publish and batch-publish responses give a current example (`datasource`, `flow`) instead of `permission` → `sys_permission_set`. `EvalUser.positions` describes the positions assigned through `sys_user_position`. `PermissionSet.description`'s doc comment no longer names the deleted projection. The `name` describes of `PositionSchema` and `PermissionSetSchema` say a name is unique within the security catalog the environment serves (ADR-0131 D3: there is no organization-level catalog), replacing "unique per organization", which read the retired rows' index. The error-code ledger drops `@objectstack/plugin-security`'s `UNIQUE_VIOLATION` row, which named the removed refusal; `UNIQUE_VIOLATION` stays registered by its other emitters. +- **`@objectstack/core`**: the doc comments of `positionDefinitionFromRow` and `permissionSetDefinitionFromRow` no longer describe the deleted boot backfills as current. +- **`@objectstack/metadata-protocol`**: the doc comments of the publish materializer, mutation projector and uninstall cleanup registries give current examples (`flow`, `datasource`, the grant revocation) instead of `permission` → `sys_permission_set`. diff --git a/content/docs/permissions/system-context.mdx b/content/docs/permissions/system-context.mdx index 3e42dd3a5e1..431411d1e59 100644 --- a/content/docs/permissions/system-context.mdx +++ b/content/docs/permissions/system-context.mdx @@ -184,7 +184,7 @@ One of the largest consumers of the flag; | 54 | Package REST route capability gate bypassed | rest | Get: a marketplace publish over REST (`POST /packages/publish`, the one route the REST registrar mounts since #14503) without `manage_metadata`; the package read cohort (`studio.access` / `setup.access`) is enforced by the dispatcher `/packages` domain's own read gate, where the reads are served | `packages/rest/src/package-routes.ts#refusePackageRequest` | | 55 | Package domain capability gates bypassed | runtime | Get: package management and package-inventory reads without the capability | `packages/runtime/src/domains/packages.ts#requireManageMetadata`, `#requireReadCapability` | | 56 | Activation write / authoring refusals do not fire | runtime | Get: activation artifacts writable and authorable without the activation-authoring capability | `packages/runtime/src/domains/activation-gate.ts#refuseUngrantedActivationWrite`, `#refuseUngrantedActivationAuthoring` | -| 55b | Walled environment-metadata refusal does not fire | metadata-core, rest, plugin-security, runtime | Get: under `isolated` / `group`, a write of the environment's metadata, of any type, at the `/meta` write doors (save, reset, publish, rollback) and `POST /meta/_migrate-stored` on both transports, at every `/packages` write door (create, the manifest `PATCH`, enable, disable, publish, `publish-drafts`, `discard-drafts`, `revert`, `rollback`, `commits/:commitId/revert`, `duplicate`, `adopt-orphans`, delete), at the `/automation` definition writes (create, update, delete, clone), at the permission-set clone door, and at the data door on `sys_position`, `sys_permission_set` and `sys_capability` while those objects are registered. Every other caller below the platform-operator rung is refused `403` there (ADR-0131 D3, D12 items 7–8, D11), because one organization's write would change what every organization resolves. The seeders, the package door and migrations write the environment's metadata this way. Under `single` the refusal is inert for every caller | `packages/metadata-core/src/meta-write-capability.ts#walledCatalogWriteVerdict`, `packages/rest/src/rest-server.ts#walledCatalogRefusal`, `packages/plugins/plugin-security/src/walled-catalog-data-door.ts#createWalledCatalogDataDoorRefusal`, `packages/runtime/src/domains/packages.ts#refuseWalledCatalogPackageWrite`, `packages/runtime/src/domains/automation.ts#refuseWalledFlowDefinitionWrite` | +| 55b | Walled environment-metadata refusal does not fire | metadata-core, rest, runtime | Get: under `isolated` / `group`, a write of the environment's metadata, of any type, at the `/meta` write doors (save, reset, publish, rollback) and `POST /meta/_migrate-stored` on both transports, at every `/packages` write door (create, the manifest `PATCH`, enable, disable, publish, `publish-drafts`, `discard-drafts`, `revert`, `rollback`, `commits/:commitId/revert`, `duplicate`, `adopt-orphans`, delete), at the `/automation` definition writes (create, update, delete, clone), and at the permission-set clone door. Every other caller below the platform-operator rung is refused `403` there (ADR-0131 D3, D12 items 7–8, D11), because one organization's write would change what every organization resolves. The seeders, the package door and migrations write the environment's metadata this way. Under `single` the refusal is inert for every caller | `packages/metadata-core/src/meta-write-capability.ts#walledCatalogWriteVerdict`, `packages/rest/src/rest-server.ts#walledCatalogRefusal`, `packages/runtime/src/domains/packages.ts#refuseWalledCatalogPackageWrite`, `packages/runtime/src/domains/automation.ts#refuseWalledFlowDefinitionWrite` | | 57 | Automation run-state read, flow-authoring write, the paused-run caller gate (screen read and resume), the two operator run-lifecycle writes and the elevated self-triggered start at every door that starts a flow by name all pass | runtime | Get: run state, flow writes, a paused run's screen and its resume with no grant and without being the run's starter — and cancelling or restoring a suspension without the platform-operator rung. The screen read and the resume ask one predicate, so this is one bypass for both. The lifecycle bypass is the in-process owner's door: plugin-approvals' revise-window recall (ADR-0044) cancels on behalf of a decision it already authorized and recorded. And starting a flow declared `runAs: 'system'` whose type is self-triggered (`autolaunched`, `record_change`, `schedule`) through any door that starts a flow by name — the trigger route, an action of `type: 'flow'` (REST `/actions` and the MCP `run_action` bridge) and a declared endpoint of `type: 'flow'` — which every other caller, the guest principal included, is refused `403 PERMISSION_DENIED` (the maintainer's rulings, letters B and A). The three doors ask one predicate, so this is one bypass for all of them, and it is the in-process caller's, such as a job: inbound HTTP never carries the flag | `packages/runtime/src/domains/automation.ts#mayReadRunState`, `#refuseUngrantedFlowWrite`, `#isRunStarterOrRunStateReader`, `#refuseUngrantedRunLifecycleWrite`, `packages/runtime/src/flow-start-admission.ts#refusesElevatedSelfTriggeredStart` | | 58 | Audience-binding suggestion authority gate passes | plugin-security | Get: a system caller lists, accepts and dismisses suggestions without `manage_metadata` and, under a wall, without the platform-operator rung | `packages/plugins/plugin-security/src/suggested-audience-bindings.ts#assertDeploymentAuthority` | | 59 | Email-template organization door passes; webhook provenance stamp skipped | plugin-email, plugin-webhooks | Get (`sys_email_template`): a create or update of a template row. The door refuses every other write that names a caller, `403 PERMISSION_DENIED` (ADR-0131 D6); the plugin itself no longer writes the table (templates resolve from the registry), so the v18 migration ceremony's promotion is the one remaining writer. Lose (`sys_webhook`): the row is not marked as an admin customization | `packages/plugins/plugin-email/src/email-template-door.ts#isOrganizationWrite`, `packages/plugins/plugin-webhooks/src/webhook-provenance.ts#bindWebhookProvenanceStamp` | diff --git a/content/docs/references/api/protocol.mdx b/content/docs/references/api/protocol.mdx index c83fd2fc8bb..a4b0f7dae08 100644 --- a/content/docs/references/api/protocol.mdx +++ b/content/docs/references/api/protocol.mdx @@ -2458,7 +2458,7 @@ Installed package with runtime lifecycle state | **version** | `string` | ✅ | Version token of the row this promotion left, and the token the ADR-0008 optimistic-concurrency chain runs on: send it back as the `If-Match` request header on the next write to that item and a concurrent edit is refused 409 `METADATA_CONFLICT` instead of silently overwritten. A keyed digest of the stored content hash, never the hash itself — the same form `SaveMetaItemResponseSchema.version` and the item read's `version` serve. Opaque to callers — echo it verbatim, never parse it. Currently emitted as `hmac-sha256:` plus 64 hex characters, but the format is not part of this contract. | | **seq** | `integer` | ✅ | Monotonic sequence number of the `op='publish'` metadata event this promotion appended to the item history (sys_metadata_history.event_seq). Orders writes; unlike `version` it is not an OCC token. | | **seedApplied** | `{ success: boolean; inserted: integer; updated: integer; error?: string; … }` | optional | Outcome of materializing a published `seed` body into data rows. Present ONLY when the published type is `seed` — publishing a seed is what makes its rows live, so the load rides along with the metadata promotion. Best-effort: a seed-load problem is surfaced here, never thrown, so a caller must check `seedApplied.success` instead of assuming the 200 covered the data. Absent on the batch path, which suppresses the per-item apply and loads every seed body in one later pass. | -| **materializeApplied** | `{ success: boolean; inserted: integer; updated: integer; error?: string }` | optional | Outcome of the ADR-0086 P2 publish-time materializer — the step that projects the published body into its data-plane row (e.g. `permission` → `sys_permission_set`, under the owning package). Present ONLY when a materializer is registered for this metadata type, which is why it is optional: its absence means "no materializer ran", never "it failed". Best-effort, same contract as `seedApplied`. | +| **materializeApplied** | `{ success: boolean; inserted: integer; updated: integer; error?: string }` | optional | Outcome of the ADR-0086 P2 publish-time materializer — the step that applies the published body to its data-plane state (e.g. `flow`, whose draft credentials become the live ones). Present ONLY when a materializer is registered for this metadata type, which is why it is optional: its absence means "no materializer ran", never "it failed". Best-effort, same contract as `seedApplied`. | | **projectionApplied** | `{ success: boolean; error?: string }` | optional | Outcome of the awaited ADR-0094 mutation projector — the post-persist step that materializes this metadata into its derived data-plane read model. The same receipt `{@link SaveMetaItemResponseSchema}` carries, because the projector runs on BOTH write doors: a direct active save and this draft→active promotion. Present ONLY when a projector is registered for this metadata type. Best-effort — a projector failure is reported here and logged, never thrown. | | **advisories** | `{ rule: string; path: string; where: string; message: string; … }[]` | optional | Non-gating findings from the runtime authoring gate — the same shared author-time rules `os validate` / `os build` / `os lint` run, applied to the DRAFT body this promotion carried to `active` (the same key `SaveMetaItemResponseSchema` carries, because the gate runs on both write doors, D1). The promotion SUCCEEDED; these are what the gate has to say about it anyway. Present ONLY when at least one advisory was raised — an empty array is never emitted, so a clean publish's response bytes are unchanged and absence means "nothing to report", never "the gate did not run". Advisory by construction: every entry has `severity` `warning` or `info`, because an `error` finding refuses the promotion and arrives as the 422 `invalid_metadata` envelope instead of here. A caller that ignores this key behaves exactly as before. This door is the one Studio's designer takes on every edit (draft save, then publish), and a Studio / MCP / AI author has no CLI at all — which is the gap this key exists to close. | | **message** | `string` | optional | Human-readable receipt, e.g. `Published draft — type=view, name=cases [seq=3]`. The producer sets it on every publish today; it stays optional to match the producer's own signature and its `SaveMetaItemResponse` twin, and because an absent human-readable string strips no data — the failure mode this key exists to prevent. | @@ -2516,7 +2516,7 @@ Installed package with runtime lifecycle state | **published** | `{ type: string; name: string; version: string; advisories?: object[] }[]` | ✅ | Every draft promoted to active, in publish order. Empty on every refusal path. | | **failed** | `{ type: string; name: string; error: string; code?: string; … }[]` | ✅ | Items that did not publish. Because the batch is all-or-nothing (ADR-0067 D2), a non-empty list means NOTHING landed: `published: []`, `publishedCount: 0`. | | **seedApplied** | `{ success: boolean; inserted?: integer; updated?: integer; error?: string; … }` | optional | Aggregate outcome of materializing EVERY published `seed` body in one multi-pass loader run (cross-seed references need the whole set). Present ONLY when the batch published at least one seed. Two producers, one key: the batch itself self-applies (`applySeedBodies`), and the REST door back-fills the same key for custom protocols that do not — never both (an externalId-less seed would double-insert). Best-effort: a seed problem is surfaced here, never thrown. | -| **materializeApplied** | `{ success: boolean; inserted: integer; updated: integer; failures: object[] }` | optional | ADR-0086 P2 — aggregate result of publish-time materializers across the batch (e.g. `permission` → `sys_permission_set`), including side-effect failures surfaced by the per-item effects loop. Present ONLY when at least one published item had a registered materializer or a side-effect failure. Best-effort, same contract as `seedApplied`. | +| **materializeApplied** | `{ success: boolean; inserted: integer; updated: integer; failures: object[] }` | optional | ADR-0086 P2 — aggregate result of publish-time materializers across the batch (e.g. `flow`, whose draft credentials become the live ones), including side-effect failures surfaced by the per-item effects loop. Present ONLY when at least one published item had a registered materializer or a side-effect failure. Best-effort, same contract as `seedApplied`. | | **probes** | `any` | optional | ADR-0038 L3 post-publish runtime probe report — one real read per published artifact (seeded objects have rows, views are readable, widget dataset selections execute). DELIBERATELY OPAQUE in this contract: the key is declared and carried through verbatim, but its inner shape is intentionally not modeled until a consumer needs a field of it. Present only when something was publishable; probes never fail the publish. | | **commitId** | `string` | optional | ADR-0067 — id of the commit this publish recorded. Absent when nothing published. | | **unhiddenApps** | `string[]` | optional | ADR-0045 §3 — names of the apps whose `_unpublished` gate this publish cleared (publish = live AND visible; a materialized additive build has no drafts, only this flip). Attached by the REST door, not the protocol helper. Present ONLY when at least one app flipped — on a mid-loop failure it names the apps that DID persist, beside `unhideError` ('s split report). Spelling is a permanent wire contract (see the door's note). | @@ -2758,7 +2758,7 @@ Installed package with runtime lifecycle state | **version** | `string` | ✅ | Version token of the row this write left, and the token the ADR-0008 optimistic-concurrency chain runs on: send it back as the `If-Match` request header on the next write to that item and a concurrent edit is refused 409 `METADATA_CONFLICT` instead of silently overwritten. The item read serves the same token for the same row (`GetMetaItemResponseSchema.version`). A keyed digest of the stored content hash, never the hash itself (under the registered crypto provider's key, or a process-scoped key while none is registered). Opaque to callers — echo it verbatim, never parse it. Currently emitted as `hmac-sha256:` plus 64 hex characters, but the format is not part of this contract. | | **seq** | `integer` | ✅ | Monotonic sequence number of the metadata event this write appended to the item history (sys_metadata_history.event_seq). Orders writes; unlike `version` it is not an OCC token. | | **state** | `Enum<'draft' \| 'active'>` | ✅ | Lifecycle the body was written into: "draft" when the request asked for draft mode (`?mode=draft`), otherwise "active" (published and live). A draft is staged only — it is not served to the runtime until published. | -| **projectionApplied** | `{ success: boolean; error?: string }` | optional | Outcome of the awaited ADR-0094 mutation projector — the post-persist step that materializes this metadata into its derived data-plane read model (e.g. `permission` → `sys_permission_set`). Present ONLY when a projector is registered for this metadata type, which is why it is optional: its absence means "no projector ran", never "the projection failed". Best-effort by design — a projector failure is reported here and logged, never thrown, so a caller that needs the read model to be live must check `projectionApplied.success` rather than rely on the 200. | +| **projectionApplied** | `{ success: boolean; error?: string }` | optional | Outcome of the awaited ADR-0094 mutation projector — the post-persist step that materializes this metadata into its derived data-plane read model (e.g. `datasource` → its live connection pool). Present ONLY when a projector is registered for this metadata type, which is why it is optional: its absence means "no projector ran", never "the projection failed". Best-effort by design — a projector failure is reported here and logged, never thrown, so a caller that needs the read model to be live must check `projectionApplied.success` rather than rely on the 200. | | **advisories** | `{ rule: string; path: string; where: string; message: string; … }[]` | optional | Non-gating findings from the runtime authoring gate — the same shared author-time rules `os validate` / `os build` / `os lint` run, applied to this body on its way to `active`. The write SUCCEEDED; these are what the gate has to say about it anyway (closing D3). Present ONLY when at least one advisory was raised — an empty array is never emitted, so a clean save's response bytes are unchanged and absence means "nothing to report", never "the gate did not run". Advisory by construction: every entry has `severity` `warning` or `info`, because an `error` finding refuses the write and arrives as the 422 `invalid_metadata` envelope instead of here. A caller that ignores this key behaves exactly as before. Runtime-only: the CLI surfaces the same findings on its own stdout, and a Studio / MCP / AI author has no CLI at all, which is the gap this key exists to close. The gate runs on both write doors (D1), and both report: `POST /meta/:type/:name/publish` carries the same key on `PublishMetaItemResponseSchema`. | | **message** | `string` | optional | | diff --git a/content/docs/references/identity/eval-user.mdx b/content/docs/references/identity/eval-user.mdx index 83751f4e494..976a1f7a147 100644 --- a/content/docs/references/identity/eval-user.mdx +++ b/content/docs/references/identity/eval-user.mdx @@ -22,7 +22,7 @@ behavior is the footgun this eliminates. ## Which axis `positions` is — and which it is not (#15136) It is the **security** axis: the built-in identity names plus the -`sys_position` names the principal actually holds through `sys_user_position` +position names the principal actually holds through `sys_user_position` (ADR-0057 D4) and their active membership, plus the `everyone` anchor (ADR-0090 D5) — exactly the set `/auth/me/permissions` reports and `resolveUserAuthzGrants` resolves. Every surface derives it from that one @@ -69,7 +69,7 @@ const result = EvalUserSchema.parse(data); | **id** | `string` | ✅ | User ID | | **name** | `string` | optional | Display name | | **email** | `string` | optional | Email address | -| **positions** | `string[]` | optional (default: `[]`) | Canonical position/identity names the user holds — built-in identity names plus sys_position assignments, scope-resolved (ADR-0068 D3). The security axis, the same set /auth/me/permissions reports; NOT the better-auth user.role scalar, which remains published as user.role | +| **positions** | `string[]` | optional (default: `[]`) | Canonical position/identity names the user holds — built-in identity names plus the positions assigned through sys_user_position, scope-resolved (ADR-0068 D3). The security axis, the same set /auth/me/permissions reports; NOT the better-auth user.role scalar, which remains published as user.role | | **isPlatformAdmin** | `boolean` | optional | The PLATFORM_ADMIN standing of ADR-0095 D3: true when the platform resolves the user, per request, to the PLATFORM_ADMIN posture rung — from the deployment's declared administrator list (OS_PLATFORM_OWNER_EMAIL) under every tenancy posture, or from an unscoped admin_full_access grant under the single posture. The predicate platform-operator gates read (current_user.isPlatformAdmin == true, ADR-0068 D4). The resolver projects 'platform_admin' into positions from the same grant, so the two agree for every genuine administrator; gate on this key, never on the positions array. | | **organizationId** | `string \| null` | optional | Active organization ID (null = platform/unscoped) | diff --git a/content/docs/references/identity/position.mdx b/content/docs/references/identity/position.mdx index ff1dc3a4d65..19c45a578ad 100644 --- a/content/docs/references/identity/position.mdx +++ b/content/docs/references/identity/position.mdx @@ -28,7 +28,7 @@ const result = PositionSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **name** | `string` | ✅ | Position name, unique per organization (lowercase snake_case) | +| **name** | `string` | ✅ | Position name, unique within the security catalog the environment serves (lowercase snake_case) | | **label** | `string` | ✅ | Display label (e.g. VP of Sales) | | **description** | `string` | optional | | | **permissionSets** | `string[]` | optional | ADR-0131 D3/D4: the permission sets this position distributes, by name (resolved in the environment catalog). | diff --git a/content/docs/references/security/permission.mdx b/content/docs/references/security/permission.mdx index b68bfa9e3ef..729e76591c1 100644 --- a/content/docs/references/security/permission.mdx +++ b/content/docs/references/security/permission.mdx @@ -114,7 +114,7 @@ const result = AdminScopeSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **name** | `string` | ✅ | Permission set name, unique per organization (lowercase snake_case) | +| **name** | `string` | ✅ | Permission set name, unique within the security catalog the environment serves (lowercase snake_case) | | **label** | `string` | optional | Display label | | **description** | `string` | optional | Human-readable description shown in Setup | | **packageId** | `string` | optional | [ADR-0086 D3] Owning package id for a package-shipped set (absent = env-authored); a value that disagrees with the package that ships the set, or that it is saved into, is refused by the stack schema (defineStack, os validate, os build), the package registration, and the metadata save, restore and publish doors | diff --git a/docs/qa/platform-checklist/areas/access-security.json b/docs/qa/platform-checklist/areas/access-security.json index 6a7df5b47d5..fb03493eb90 100644 --- a/docs/qa/platform-checklist/areas/access-security.json +++ b/docs/qa/platform-checklist/areas/access-security.json @@ -2549,7 +2549,7 @@ }, { "id": "access-security.packaged-permission-set-lifecycle", - "title": "Packaged permission-set lifecycle (#11513) survives ADR-0126 unchanged: locked base at the data door, clone-to-customize with every facet carried, `active` row-state flip — and NONE of it writes a sys_metadata_activation row", + "title": "Packaged permission-set lifecycle (#11513) at the retired sys_permission_set data door: locked base, clone-to-customize with every facet carried, `active` row-state flip — and NONE of it wrote a sys_metadata_activation row", "since": "v16", "status": "retired", "retiredReason": "C3 stage 8 (ADR-0131 D3) retired the sys_permission_set row object: its data door, its Activate / Deactivate / Clone row actions and its `active` column are gone. A packaged set's base stays locked at the metadata door, a copy is made with POST /api/v1/security/permission-sets/:name/clone, and a set is switched off in the activation ledger (sys_metadata_activation).", @@ -2590,7 +2590,7 @@ "evidence": "the PATCH trace with message quoted + the before/after reads" }, { - "clause": "the active flip is ROW STATE, not a definition write: a bare {active} PATCH passes the lock (the #4669 carve-out runs BEFORE the lock — permission-set-projection.ts) and lands on the driver, and enforcement follows it — M's granted verb is refused while active=false and restored on re-activation, with M's assignment row intact throughout", + "clause": "the active flip is ROW STATE, not a definition write: a bare {active} PATCH passes the lock (the #4669 carve-out ran BEFORE the lock in permission-set-projection.ts, retired with C3 stages 7a and 8) and lands on the driver, and enforcement follows it — M's granted verb is refused while active=false and restored on re-activation, with M's assignment row intact throughout", "oracle": "api", "verify": "deactivate 2xx (a 403 here is the lock swallowing the carve-out — a FAIL that breaks the shipped Deactivate action); M's probe verb >=400 while inactive, 2xx after reactivation; sys_user_permission_set row for M present at every step", "evidence": "the lifecycle traces + M's before/during/after probes + the assignment-row reads" @@ -2621,7 +2621,7 @@ } ], "negative": [ - "a 403 on the BARE {active} PATCH is a FAIL even though it looks like the lock working — the row-state carve-out is load-bearing for the shipped Deactivate/Activate actions (permission-set-projection.ts says so in as many words)", + "a 403 on the BARE {active} PATCH is a FAIL even though it looks like the lock working — the row-state carve-out is load-bearing for the shipped Deactivate/Activate actions (the retired permission-set-projection.ts said so in as many words)", "a clone missing system_permissions, row_level_security or tab_permissions is the exact #11703 silent-grant-loss regression this item guards — diff every facet, not just the two that were never dropped", "the two 403 sentences (packaged-locked vs provenance-unknown) must not be conflated: both are NOT_OVERRIDABLE, and only the message says whether the lock decided or failed closed", "cross-refs, to keep one defect one count: the /meta-door package lock on objects is access-security.readonly-package-locks-studio; the WRITABLE-set edit loop through /meta/permission is access-security.permission-matrix-edit-loop (its fixture note explicitly excludes locked package sets — this item is that excluded case, at the DATA door); grant enforcement itself is access-security.crud-permission-matrix" @@ -2633,11 +2633,11 @@ ], "automated": { "kind": "unit", - "ref": "packages/plugins/plugin-security/src/packaged-permission-set-lock.test.ts (provenance classifier, fail-closed verdicts, the clone action's payload pinned facet-by-facet against the params list — pin 6) + packaged-permission-set-restore-leg.test.ts; the live enforcement flip and the no-ledger-row wall stay manual" + "ref": "packages/plugins/plugin-security/src/packaged-permission-set-lock.test.ts and packaged-permission-set-lock-gate.test.ts (provenance classifier, fail-closed verdicts, the metadata-door lock that survives the data door's retirement); the data-door legs this item described, the clone action's pin 6 and the restore-leg suite, retired with C3 stages 7a and 8" }, "source": [ "packages/plugins/plugin-security/src/packaged-permission-set-lock.ts (classifier + both refusals; the SchemaRegistry-not-managed_by decision)", - "packages/plugins/plugin-security/src/permission-set-projection.ts (insert-path lock + duplicate 409; the #4669 row-state carve-out ordered BEFORE the update-path lock)", + "packages/plugins/plugin-security/src/packaged-permission-set-lock-gate.ts (the lock's one enforcement point since the sys_permission_set data door retired with C3 stages 7a and 8, taking its insert-path lock, duplicate 409 and #4669 row-state carve-out with it)", "packages/runtime/src/domains/permission-set-clone.ts (the clone door that replaced the retired object's clone_permission_set action)", "docs/adr/0126-packaged-metadata-customization-model.md §1.1 (the #11513 machinery, named as the regime's first instance), §8 item 3 (permission sets converge later; the landed shape stays valid meanwhile)", "#11513 (lock the base, clone to customize — the 2026-08-24 ruling), #11703 (the three dropped clone facets), #12159 (ruling 2026-08-26: no ledger rows for permission sets pending the convergence card), #12438 (the sweep)" diff --git a/examples/app-showcase/src/security/permission-sets.ts b/examples/app-showcase/src/security/permission-sets.ts index 4af023b5427..8ed7733a60e 100644 --- a/examples/app-showcase/src/security/permission-sets.ts +++ b/examples/app-showcase/src/security/permission-sets.ts @@ -356,9 +356,7 @@ export const MemberDefaultPermissionSet = definePermissionSet({ * may read announcements and file an inquiry (public form intake) — nothing * else. Guest bindings face the STRICTEST lint tier: named objects only (no * wildcard), read-only by default (create case-by-case), never `allowEdit`, - * never VAMA/system permissions. The runtime anchor gate - * (`assertAudienceAnchorBindingGate`) enforces the same rules on the binding - * write itself. + * never VAMA/system permissions. * * Binding is an ADMIN action (Setup → Access Control → bind `guest` → * `showcase_guest_portal`); the app only ships the capability. diff --git a/packages/core/src/security/permission-set-row-definition.ts b/packages/core/src/security/permission-set-row-definition.ts index 987eabe8d00..bd421425cc8 100644 --- a/packages/core/src/security/permission-set-row-definition.ts +++ b/packages/core/src/security/permission-set-row-definition.ts @@ -12,12 +12,12 @@ * the dual source ADR-0131 D10 retires. * * The function is pure: one row in, one body out — no engine, no I/O. It is - * the logic the security plugin's boot backfill applies today - * (`permissionSetBodyFromRow`), held here so the ceremony and that plugin read - * one mapping; the plugin's copy is deleted when its boot module is. + * the logic the security plugin's boot backfill applied + * (`permissionSetBodyFromRow`) until C3 stage 7a deleted it, held here as the + * one mapping the ceremony reads. * - * The mapping is the inverse of the row shape the security plugin projects a - * definition into: the snake_case facet columns, stored as JSON text, become + * The mapping is the inverse of the row shape the security plugin projected a + * definition into until then: the snake_case facet columns, stored as JSON text, become * the camelCase body keys — * * - `name` → `name`; `label` → `label`, falling back to the name when the diff --git a/packages/core/src/security/position-row-definition.ts b/packages/core/src/security/position-row-definition.ts index 2f23f68e0a4..0964e74003d 100644 --- a/packages/core/src/security/position-row-definition.ts +++ b/packages/core/src/security/position-row-definition.ts @@ -12,10 +12,9 @@ * the dual source ADR-0131 D10 retires. * * The function is pure: one row in, one body out — no engine, no I/O. It is - * the mapping the security plugin's row-only position backfill applies today - * (`positionBodyFromRow`, beside its position write-through), held here so the - * ceremony and that plugin read one mapping; the plugin's copy is deleted when - * its boot module is. + * the mapping the security plugin's row-only position backfill applied until + * C3 stage 8 retired that boot module, held here as the one mapping the + * ceremony reads. * * The definition is the four `PositionSchema` keys the row has columns for, in * this key order: diff --git a/packages/metadata-protocol/src/protocol.adr0005-org-override-rollback.test.ts b/packages/metadata-protocol/src/protocol.adr0005-org-override-rollback.test.ts index 10b2f347e0a..8bdd486b627 100644 --- a/packages/metadata-protocol/src/protocol.adr0005-org-override-rollback.test.ts +++ b/packages/metadata-protocol/src/protocol.adr0005-org-override-rollback.test.ts @@ -65,7 +65,7 @@ * `assertAllowed` allow-list are both DERIVED from the registry (Prime * Directive #8) — no hardcoded copy to align. * • plugin-security's ADR-0094 write-through - * (`permission-set-projection.ts`) redirects data-door edits of + * (`permission-set-projection.ts`, since retired) redirected data-door edits of * permission sets into `saveMetaItem({type:'permission'})`. For * RUNTIME-CREATED sets that write rides `allowRuntimeCreate` and keeps * working. For PACKAGE-OWNED sets it now refuses with 403 — which is @@ -301,11 +301,10 @@ describe('#6483 — the nine ADR-0005 divergences: allowEnvironmentOverlay rolle // two production paths named above rather than assumed: // // • ADR-0094's permission write-through - // (`plugin-security/src/permission-set-projection.ts`) passes NO - // `organizationId` on any of its four `saveMetaItem` call sites — - // it is env-scoped by design ("its update/delete translate into - // env-scope overlay operations"). Unaffected, and this case is its - // accurate pin. + // (`plugin-security/src/permission-set-projection.ts`, retired with + // C3 stage 7a) passed NO `organizationId` on any of its four + // `saveMetaItem` call sites — it was env-scoped by design. + // Unaffected, and this case was its accurate pin. // • ADR-0045's visibility flip (`runtime/src/domains/packages.ts`) // DOES thread the session's active org into // `saveMetaItem({ type: 'app' })`. That path is the open question diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index 51cef72d056..78968c37532 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -5708,8 +5708,8 @@ export type PublishMaterializer = (args: { /** * Uninstall-time data-plane cleanup (ADR-0086 D3, #2747). The exact mirror of * {@link PublishMaterializer}: domain plugins own data-plane tables the - * protocol layer must not know the shape of (e.g. plugin-security's - * `sys_permission_set` and its binding tables), so they register a named + * protocol layer must not know the shape of (e.g. plugin-security's grant + * table, `sys_user_permission_set`), so they register a named * cleanup here and {@link ObjectStackProtocolImplementation.deletePackage} * invokes every cleanup with the uninstalled package id. Cleanups run * best-effort — a failure is REPORTED on the uninstall response (`cleanups`), @@ -5944,8 +5944,8 @@ export interface MetaItemPublishedEvent { * Awaited per-type mutation projector (ADR-0094). Invoked AFTER a metadata * mutation persists — `saveMetaItem` (draft AND active saves), * `publishMetaItem`, `deleteMetaItem` — and AWAITED before the write returns, - * so a data-plane read-model derived from the metadata (e.g. `permission` → - * `sys_permission_set`) is already consistent when the caller's next read + * so a data-plane read-model derived from the metadata (e.g. the + * `datasource` projector's) is already consistent when the caller's next read * lands. This is what makes such a read-model a PURE projection: the * projector is its only writer, and it runs in the same awaited operation as * every metadata write, instead of a fire-and-forget subscriber a new write @@ -6317,11 +6317,10 @@ export class ObjectStackProtocolImplementation implements /** * Publish-time materializers keyed by singular metadata type (ADR-0086 P2). * When a draft of a registered type is published, its body is projected - * into a data-plane representation the admin surface reads — e.g. a - * `permission` set is upserted into `sys_permission_set` with - * `managed_by:'package'`. Domain plugins own the projection (the generic - * protocol layer must not know `sys_permission_set`'s field shape), so they - * register here at init. Best-effort — a materializer failure is surfaced on + * into a data-plane representation — e.g. service-automation's `flow` + * materializer, which promotes the draft's credentials on publish. Domain + * plugins own the projection (the generic protocol layer must not know the + * data-plane shape), so they register here at init. Best-effort — a materializer failure is surfaced on * the publish response, never thrown (publishing metadata always succeeds * independently; the same contract as `seed` apply). */ @@ -6601,8 +6600,8 @@ export class ObjectStackProtocolImplementation implements /** * Register a publish-time materializer for a metadata type (ADR-0086 P2). - * Called by domain plugins at init (e.g. plugin-security registers the - * `permission` → `sys_permission_set` projection). The singular type name is + * Called by domain plugins at init (e.g. service-automation registers the + * `flow` materializer). The singular type name is * used — `permissions` and `permission` both resolve here. One materializer * per type; a second registration replaces the first (idempotent re-init). */ @@ -6614,9 +6613,9 @@ export class ObjectStackProtocolImplementation implements /** * Register a named uninstall-time data-plane cleanup (ADR-0086 D3, #2747). * Called by domain plugins at init — e.g. plugin-security registers the - * cleanup that removes its package-owned `sys_permission_set` rows and - * their bindings when the owning package is uninstalled, so grants are - * revoked everywhere at once (no ghost grants). One cleanup per name; a + * cleanup that revokes every grant naming a permission set the package + * declared when the owning package is uninstalled, so grants are revoked + * everywhere at once (no ghost grants). One cleanup per name; a * second registration replaces the first (idempotent re-init). */ registerUninstallCleanup(name: string, cleanup: UninstallCleanup): void { @@ -6664,8 +6663,8 @@ export class ObjectStackProtocolImplementation implements }); } catch (e: any) { // [#8136] A cleanup is arbitrary plugin code that goes straight - // at the engine (plugin-security deletes `sys_permission_set` - // rows and their bindings), so a driver failure lands here + // at the engine (plugin-security deletes the grant rows that + // name the package's sets), so a driver failure lands here // verbatim — and this outcome rides on the RESPONSE by design, // inside `details`, where no boundary's message withhold can // reach it. Quoted only when the cleanup declared a refusal. @@ -6693,8 +6692,8 @@ export class ObjectStackProtocolImplementation implements /** * Register the awaited mutation projector for a metadata type (ADR-0094). * Called by the domain plugin that owns the derived read-model (e.g. - * plugin-security registers the `permission` → `sys_permission_set` - * projector). Singular or plural type names both resolve. + * service-datasource registers the `datasource` projector). Singular or + * plural type names both resolve. */ registerMutationProjector(type: string, projector: MetadataMutationProjector): void { const singular = PLURAL_TO_SINGULAR[type] ?? type; @@ -14607,11 +14606,12 @@ export class ObjectStackProtocolImplementation implements // The same measurement, one outcome over: `success` was still a // LITERAL for every answer that was not the contract's `false`, so a // row that MATCHED and was deliberately NOT removed reported a - // deletion. `sys_permission_set` is the shipped case — a - // package-declared set's delete is an ADR-0005 RESET, the record - // re-projects to the declared body instead of vanishing — and the - // envelope was byte-identical to a real delete, so a UI fired a - // success toast and showed the row again on refresh. + // deletion. `sys_permission_set` was the shipped case until C3 stage + // 8 retired that object — a package-declared set's delete was an + // ADR-0005 RESET, the record re-projected to the declared body instead + // of vanishing — and the envelope was byte-identical to a real + // delete, so a UI fired a success toast and showed the row again on + // refresh. // // `success` is declared "Whether deletion succeeded", and this is the // only key on `DeleteDataResponseSchema` that can carry the @@ -15808,11 +15808,12 @@ export class ObjectStackProtocolImplementation implements // LITERAL for every result that was not the contract's // `false`, so that row was reported as a deletion too. // - // `sys_permission_set` is the shipped shape of it: deleting - // a package-declared set is an ADR-0005 RESET — the overlay - // tombstones and the record re-projects to the declared body - // instead of vanishing — so the row MATCHED, the write ran, - // and the record is still there. On a security-configuration + // `sys_permission_set` was the shipped shape of it until C3 + // stage 8 retired that object: deleting a package-declared + // set was an ADR-0005 RESET — the overlay tombstoned and the + // record re-projected to the declared body instead of + // vanishing — so the row MATCHED, the write ran, and the + // record was still there. On a security-configuration // write this envelope told an operator a permission set was // gone while it was still being enforced. // @@ -16468,11 +16469,12 @@ export class ObjectStackProtocolImplementation implements // every answer that was not the contract's `false`, so that row // was reported as a deletion too. // - // `sys_permission_set` is the shipped case: a package-declared - // set's delete is an ADR-0005 RESET — plugin-security's - // write-through tombstones the overlay and the record - // re-projects to the declared body instead of vanishing — so the - // row MATCHED, the write ran, and the record is still there. The + // `sys_permission_set` was the shipped case until C3 stage 8 + // retired that object: a package-declared set's delete was an + // ADR-0005 RESET — plugin-security's write-through tombstoned the + // overlay and the record re-projected to the declared body + // instead of vanishing — so the row MATCHED, the write ran, and + // the record was still there. The // batch envelope said `success: true` and counted it in // `succeeded`, which on a security-configuration write tells an // operator a permission set is gone while it is still enforced. @@ -22759,8 +22761,8 @@ export class ObjectStackProtocolImplementation implements note: mode === 'draft' ? 'draft' : 'active', }); // [ADR-0094] Awaited projection BEFORE the fire-and-forget - // listeners: a derived read-model (e.g. sys_permission_set) - // is already consistent when this save returns. + // listeners: a derived read-model (e.g. the `datasource` + // projector's) is already consistent when this save returns. const projectionApplied = await this.runMutationProjector({ type: singularTypeForRepo, name: request.name, @@ -23594,8 +23596,8 @@ export class ObjectStackProtocolImplementation implements }; /** * Present when a publish-time materializer is registered for this type - * (ADR-0086 P2 — e.g. `permission` → `sys_permission_set`): the result - * of projecting the published body into its data-plane row. Best-effort, + * (ADR-0086 P2 — e.g. `flow`): the result of projecting the published + * body into its data-plane representation. Best-effort, * same contract as `seedApplied` — surfaced, never thrown. */ materializeApplied?: PublishMaterializeResult; @@ -24228,10 +24230,9 @@ export class ObjectStackProtocolImplementation implements out.seedApplied = await this.applySeedBodies([args.body]); } // Publish-time materializer (ADR-0086 P2): project the published body - // into its data-plane row (e.g. `permission` → `sys_permission_set` - // with `managed_by:'package'`). Unlike seeds this needs no batch - // ordering — permission sets carry no cross-item references — so it - // runs on every publish path, package-draft batch included. The + // into its data-plane representation (e.g. `flow`). Unlike seeds this + // needs no batch ordering, so it runs on every publish path, + // package-draft batch included. The // owning `package_id` rides on the draft's binding, so a package-door // set materializes under the right owner. const materializer = this.publishMaterializers.get(args.singularType); @@ -24245,8 +24246,7 @@ export class ObjectStackProtocolImplementation implements }); } catch (e: any) { // [#8333 · P8] A materializer is arbitrary PLUGIN code going - // straight at the engine (plugin-security upserts - // `sys_permission_set`), so a driver failure lands here + // straight at the engine, so a driver failure lands here // verbatim — and this string is copied onto // `materializeApplied.failures[].error` of a **200** publish // response, where no boundary withhold can reach it. @@ -24670,11 +24670,10 @@ export class ObjectStackProtocolImplementation implements seedApplied?: { success: boolean; inserted: number; updated: number; error?: string; errors?: unknown[] }; /** * ADR-0086 P2 — aggregate result of publish-time materializers across the - * batch (e.g. `permission` → `sys_permission_set`). Absent when no - * published item had a registered materializer. `failures` names each - * item whose projection did NOT land (e.g. a permission-set name owned by - * the env door or another package) so the caller surfaces it instead of - * reporting a clean publish over a set that never went live. + * batch (e.g. `flow`). Absent when no published item had a registered + * materializer. `failures` names each item whose projection did NOT + * land, so the caller surfaces it instead of reporting a clean publish + * over an item that never went live. */ materializeApplied?: { success: boolean; @@ -25971,8 +25970,8 @@ export class ObjectStackProtocolImplementation implements // [#2747] Data-plane cleanups registered by domain plugins (mirror of // the publish materializers): revoke what the package's metadata - // granted — e.g. plugin-security removes its package-owned - // sys_permission_set rows and their bindings. Best-effort per cleanup; + // granted — e.g. plugin-security revokes every grant naming a set the + // package declared. Best-effort per cleanup; // outcomes ride on the response so a failed revocation (ghost grants — // a security condition) is visible to the caller, never silent. // [#21490] Through the registry's one runner, which install-local's @@ -27955,8 +27954,8 @@ export class ObjectStackProtocolImplementation implements // [ADR-0094] Awaited projection BEFORE the fire-and-forget // listener below — the order `saveMetaItem`'s comment // establishes. A rollback restores the row and the in-memory - // registry to the target version; a derived read-model (e.g. - // `permission` -> `sys_permission_set`) must be just as current + // registry to the target version; a derived read-model (e.g. the + // `datasource` projector's) must be just as current // when this call returns, not only after the next unrelated // write or boot reconciliation (ADR-0094 D4) eventually heals it. // `body` is the restored version's own — the same value the diff --git a/packages/metadata-protocol/src/protocol.uninstall-cleanups-runner.test.ts b/packages/metadata-protocol/src/protocol.uninstall-cleanups-runner.test.ts index c4e0707d7dc..aaed2892eac 100644 --- a/packages/metadata-protocol/src/protocol.uninstall-cleanups-runner.test.ts +++ b/packages/metadata-protocol/src/protocol.uninstall-cleanups-runner.test.ts @@ -99,9 +99,9 @@ describe('#21490: runUninstallCleanups — the uninstall-cleanup registry\'s one const res = await protocol.deletePackage(request); - // Its own request — plus, when the package declared permission sets, their - // names (`declared`, ADR-0131 D4), read before its withdrawal. - expect(runner.mock.calls).toEqual([[expect.objectContaining(request)]]); + // Its own request, exactly: this package declared no permission set, so + // no `declared` names (ADR-0131 D4) ride along. + expect(runner.mock.calls).toEqual([[request]]); expect(res.cleanups).toEqual(await runner.mock.results[0]!.value); expect(res.cleanups).toEqual([{ name: 'security.package-permissions', success: true, removed: 4 }]); }); diff --git a/packages/metadata-protocol/src/sys-metadata-repository.package-writability.test.ts b/packages/metadata-protocol/src/sys-metadata-repository.package-writability.test.ts index f2b6445ee31..e92e5daefa9 100644 --- a/packages/metadata-protocol/src/sys-metadata-repository.package-writability.test.ts +++ b/packages/metadata-protocol/src/sys-metadata-repository.package-writability.test.ts @@ -574,11 +574,12 @@ describe('#7682 — the refusal discriminates on package writability', () => { `Cannot overlay 'permission' in package '${READ_ONLY_PKG}': that package is read-only, and its packaged base ` + 'is locked against in-place edits. ', )).toBe(true); - // The clone route's spelling moved with C3 stage 8 (the data route of the - // retired `sys_permission_set` object → the catalog's clone door); the - // sentence around it did not. - expect(message).toContain('Clone it under a new name to customize it (the "Clone" action on the permission set, or POST /api/v1/'); - expect(message).toContain(' with a new name).'); + // The clone route is the catalog's clone door: the data route of the + // `sys_permission_set` object retired with it (C3 stage 8). + expect(message).toContain( + 'Clone it under a new name to customize it (the "Clone" action on the permission set, ' + + 'or POST /api/v1/security/permission-sets/:name/clone with a new name).', + ); expect(message).not.toContain('OS_METADATA_WRITABLE'); expect(message).not.toContain('redeploy'); expect(message.endsWith('See docs/adr/0126-packaged-metadata-customization-model.md.')).toBe(true); @@ -1254,17 +1255,10 @@ describe('[#20910] the repository doors name a Regime C type\'s sanctioned path, const ADR_0126 = 'See docs/adr/0126-packaged-metadata-customization-model.md.'; const ACTION_SWITCH = 'Switch it off (POST /api/v1/actions/_activation/:object/:action, body {enabled: false}, ' + ':object = global for an object-less action; operator-only where one install serves several organizations).'; - // `{ROUTE}`: the clone route, whose spelling moved with C3 stage 8 (the - // retired object's data route → the catalog's clone door); see `sentenceMatches`. + // The clone route is the catalog's clone door: the data route of the + // `sys_permission_set` object retired with it (C3 stage 8). const PERMISSION_CLONE = 'Clone it under a new name to customize it (the "Clone" action on the permission set, ' - + 'or POST {ROUTE} with a new name).'; - /** Exact equality, with a `{ROUTE}` placeholder standing for one `/api/v1/…` path. */ - const sentenceMatches = (actual: string, expected: string): boolean => { - const [head, tail] = expected.split('{ROUTE}'); - if (tail === undefined) return actual === expected; - return actual.startsWith(head) && actual.endsWith(tail) - && /^\/api\/v1\/\S+$/.test(actual.slice(head.length, actual.length - tail.length)); - }; + + 'or POST /api/v1/security/permission-sets/:name/clone with a new name).'; const FLOW_PATHS = 'Clone it under a new name to customize it (POST /api/v1/automation/:name/clone, body {name, label}), ' + 'or switch it off (POST /api/v1/automation/:name/toggle, body {enabled: false}; ' + 'operator-only where one install serves several organizations).'; @@ -1296,9 +1290,10 @@ describe('[#20910] the repository doors name a Regime C type\'s sanctioned path, it(`type door, save: a packaged ${type} is refused NOT_OVERRIDABLE with its row's path, no hatch`, async () => { const err = await putWith(repo, { type, name: `pkg_${type}`, intent: 'override-artifact' }) as any; expect(err).toMatchObject({ code: 'NOT_OVERRIDABLE', status: 403 }); - const expected = `Metadata item '${type}/pkg_${type}' is provided by a managed package and is sealed ` - + `against in-place edits. ${paths} ${ADR_0126}`; - expect(sentenceMatches(String(err.message), expected), `${String(err.message)}\n≠ ${expected}`).toBe(true); + expect(String(err.message)).toBe( + `Metadata item '${type}/pkg_${type}' is provided by a managed package and is sealed ` + + `against in-place edits. ${paths} ${ADR_0126}`, + ); expect(Array.from(engine.rows.values())).toEqual([]); }); } @@ -1368,9 +1363,10 @@ describe('[#20910] the repository doors name a Regime C type\'s sanctioned path, code: 'ITEM_LOCKED', status: 403, lockSource: 'package', packageId: READ_ONLY_PKG, docs: 'docs/adr/0010-metadata-protection-model.md', }); - const expected = `Cannot overlay '${type}' in package '${READ_ONLY_PKG}': that package is read-only, and its packaged base ` - + `is locked against in-place edits. ${paths} ${ADR_0126}`; - expect(sentenceMatches(String(err.message), expected), `${String(err.message)}\n≠ ${expected}`).toBe(true); + expect(String(err.message)).toBe( + `Cannot overlay '${type}' in package '${READ_ONLY_PKG}': that package is read-only, and its packaged base ` + + `is locked against in-place edits. ${paths} ${ADR_0126}`, + ); }); } diff --git a/packages/objectql/src/tenancy-by-object-classification.test.ts b/packages/objectql/src/tenancy-by-object-classification.test.ts index 65195991ab1..90c610a20d7 100644 --- a/packages/objectql/src/tenancy-by-object-classification.test.ts +++ b/packages/objectql/src/tenancy-by-object-classification.test.ts @@ -137,11 +137,14 @@ describe('#13491 the inventory — a verdict per object, never a namespace', () it('#8672 inherits PER OBJECT — the same namespace holds both verdicts', () => { expect(classifyPlatformObjectTenancy('sys_file')).toBe('tenant-scoped'); + // The inventory's one `global` entry, `sys_permission_set`, retired with + // its object (C3 stage 8): no `global` verdict is recorded today. A new one + // brings its own discriminating pair here. + expect(Object.entries(PLATFORM_OBJECT_TENANCY).filter(([, e]) => e.tenancy === 'global')).toEqual([]); + expect(classifyPlatformObjectTenancy('sys_permission_set')).toBe('unclassified'); // The discriminating half: the old blanket predicate answered the SAME - // thing for every platform object, which is the exemption the ruling - // withdrew. Each verdict the inventory records is honoured as recorded — - // read off the inventory, since its one `global` entry - // (`sys_permission_set`) retired with its object (C3 stage 8). + // thing for both of these, which is the exemption the ruling withdrew. + expect(isPlatformObjectOutOfTenantAuditScope('sys_permission_set')).toBe(true); expect(isPlatformObjectOutOfTenantAuditScope('sys_file')).toBe(false); for (const [name, entry] of Object.entries(PLATFORM_OBJECT_TENANCY)) { expect(classifyPlatformObjectTenancy(name), name).toBe(entry.tenancy); diff --git a/packages/platform-objects/src/apps/translations/en.objects.generated.ts b/packages/platform-objects/src/apps/translations/en.objects.generated.ts index c31c78cafda..961f14c05c0 100644 --- a/packages/platform-objects/src/apps/translations/en.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/en.objects.generated.ts @@ -710,7 +710,7 @@ export const enObjects: NonNullable = { }, positions: { label: "Placement Positions", - help: "sys_position names assigned on acceptance (ADR-0105 D8). Every position's permission sets must be allowlisted by the issuer's adminScope." + help: "Position names assigned on acceptance (ADR-0105 D8). Every position's permission sets must be allowlisted by the issuer's adminScope." } }, _views: { diff --git a/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts b/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts index e23bb888a9b..496c0cd45a2 100644 --- a/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts @@ -710,7 +710,7 @@ export const esESObjects: NonNullable = { }, positions: { label: "Puestos de asignación", - help: "Nombres de sys_position asignados al aceptar (ADR-0105 D8). Los conjuntos de permisos de cada puesto deben estar en la lista de permitidos del adminScope de quien emite la invitación." + help: "Nombres de puestos asignados al aceptar (ADR-0105 D8). Los conjuntos de permisos de cada puesto deben estar en la lista de permitidos del adminScope de quien emite la invitación." } }, _views: { diff --git a/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts b/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts index b08af1a8416..406d6be27b2 100644 --- a/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts @@ -710,7 +710,7 @@ export const jaJPObjects: NonNullable = { }, positions: { label: "配属先ポジション", - help: "承認時に割り当てられる sys_position 名(ADR-0105 D8)。各ポジションの権限セットは、発行者の adminScope で許可されている必要があります。" + help: "承認時に割り当てられるポジション名(ADR-0105 D8)。各ポジションの権限セットは、発行者の adminScope で許可されている必要があります。" } }, _views: { diff --git a/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts b/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts index dafbd498dbd..10c6e3ee3c3 100644 --- a/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts @@ -710,7 +710,7 @@ export const zhCNObjects: NonNullable = { }, positions: { label: "分配岗位", - help: "接受邀请时分配的 sys_position 名称(ADR-0105 D8)。每个岗位的权限集都必须在邀请发起人的 adminScope 允许列表内。" + help: "接受邀请时分配的岗位名称(ADR-0105 D8)。每个岗位的权限集都必须在邀请发起人的 adminScope 允许列表内。" } }, _views: { diff --git a/packages/platform-objects/src/identity/sys-invitation.object.ts b/packages/platform-objects/src/identity/sys-invitation.object.ts index a3af6685409..5f9d2ecf5a7 100644 --- a/packages/platform-objects/src/identity/sys-invitation.object.ts +++ b/packages/platform-objects/src/identity/sys-invitation.object.ts @@ -302,7 +302,7 @@ export const SysInvitation = ObjectSchema.create({ label: 'Placement Positions', required: false, description: - 'sys_position names assigned on acceptance (ADR-0105 D8). Every position\'s permission sets must be allowlisted by the issuer\'s adminScope.', + 'Position names assigned on acceptance (ADR-0105 D8). Every position\'s permission sets must be allowlisted by the issuer\'s adminScope.', }), }, diff --git a/packages/plugins/plugin-auth/src/last-admin-guard.test.ts b/packages/plugins/plugin-auth/src/last-admin-guard.test.ts index ae203cc67b1..0af2d833801 100644 --- a/packages/plugins/plugin-auth/src/last-admin-guard.test.ts +++ b/packages/plugins/plugin-auth/src/last-admin-guard.test.ts @@ -103,9 +103,9 @@ const sysPermissionSet = { id: { name: 'id', type: 'text' as const, primaryKey: true }, name: { name: 'name', type: 'text' as const }, // [#6084] Not read by the guard — that is its job here. `label` is what - // every projection pass and every Setup edit writes - // (`permissionSetRowFields`), so it is the column the "costs no reads" - // pin drives. + // every projection pass and every Setup edit wrote + // (`permissionSetRowFields`, retired with C3 stage 7a), so it is the column + // the "costs no reads" pin drives. label: { name: 'label', type: 'text' as const }, // [#8613 / ADR-0049] The Deactivate switch. Declared `boolean`, so on this // real sqlite database it stores as 0/1 and the guard's flag handling is 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 454884d61ad..9e03a3b8d47 100644 --- a/packages/plugins/plugin-security/src/builtin-positions.boot.test.ts +++ b/packages/plugins/plugin-security/src/builtin-positions.boot.test.ts @@ -403,27 +403,15 @@ async function grantsByPrincipal(engine: ObjectQL, posture: 'single' | 'isolated * that show the refusals are not blanket: a write through the data door by the * organization administrator (superuser wildcard, not a system context). */ -async function tenantPositionWrites(engine: ObjectQL, existingRows: boolean, judgeCreate: boolean): Promise> { +async function tenantPositionWrites(engine: ObjectQL): Promise> { const tenant = { userId: 'usr_orgadmin', positions: [], permissions: ['qa_admin'], tenantId: ORG }; - const own = async (name: string) => { - const rows = (await engine.find('sys_position', { where: { name }, limit: 5, context: { isSystem: true, tenantId: ORG } })) as any[]; - // The organization's own row when it has one (walled), else the organization-less one (`single`). - return rows.find((r) => r.organization_id === ORG) ?? rows.find((r) => r.organization_id == null); - }; - const everyone = await own('everyone'); - const platformAdmin = await own('platform_admin'); - const dangling = await outcome(engine.insert( - 'sys_user_position', { user_id: 'usr_member', position: 'zz_dangling' }, { context: tenant } as any, - )); - const created = await outcome(engine.insert( - 'sys_position', { name: 'zz_tenant_post', label: 'Tenant Post' }, { context: tenant } as any, - )); return { - 'assign a position the security catalog does not hold': dangling, - // Judged wherever the plugin declares the `sys_position` row object (under a - // wall, the walled data-door refusal's: `walled-catalog-data-door.ts`); left - // out once C3 stage 8 retires the object and that door together. - ...(judgeCreate ? { 'control: create a position row': created } : {}), + 'assign a position the security catalog does not hold': await outcome(engine.insert( + 'sys_user_position', { user_id: 'usr_member', position: 'zz_dangling' }, { context: tenant } as any, + )), + 'control: create a position row': await outcome(engine.insert( + 'sys_position', { name: 'zz_tenant_post', label: 'Tenant Post' }, { context: tenant } as any, + )), // [ADR-0131 D3/D4] This harness mounts no metadata door, so the row gets // no definition: a position only a row carries grants nothing, and an // assignment naming it is refused like a dangling name. @@ -433,15 +421,6 @@ async function tenantPositionWrites(engine: ObjectQL, existingRows: boolean, jud 'control: assign a stack-declared position (no row needed)': await outcome(engine.insert( 'sys_user_position', { user_id: 'usr_member', position: DECLARED_POSITIONS[0].name }, { context: tenant } as any, )), - // Only an existing database holds a built-in row to refuse (stage 6a). - ...(existingRows - ? { - 'delete the everyone row': await outcome(engine.delete('sys_position', { where: { id: everyone?.id }, context: tenant } as any)), - 'relabel the platform_admin row': await outcome(engine.update( - 'sys_position', { id: platformAdmin?.id, label: 'Repurposed' }, { context: tenant } as any, - )), - } - : {}), }; } @@ -530,12 +509,9 @@ for (const name of Object.keys(SCENARIOS) as ScenarioName[]) { expect(await grantsByPrincipal(engine, scenario.posture)).toEqual(grantGoldenFor(scenario)); }); - it('still refuses a tenant a dangling position name, and deleting or relabelling a built-in row', async () => { - // A fresh database holds no built-in row to delete or relabel; the two - // refusals are read where the rows are, on an existing database. + it('still refuses a tenant a dangling position name', async () => { const { engine } = booted.get(name)!; - expect(await tenantPositionWrites(engine, scenario.existingRows, judgesCreate(scenario))) - .toEqual(refusalGoldenFor(scenario)); + expect(await tenantPositionWrites(engine)).toEqual(REFUSAL_GOLDEN); }); } }); @@ -545,10 +521,10 @@ for (const name of Object.keys(SCENARIOS) as ScenarioName[]) { * The tenant position writes. A name the security * catalog does not hold, a dangling one or a row with no definition alike, is * the position catalog refusal's envelope (`reference_not_found` at - * `position`); a stack-declared position is assigned with no row; the two - * built-in rows an existing database holds are refused on their `platform` - * provenance. Under a wall the tenant's position create is refused as well, - * while the plugin declares the row object ({@link refusalGoldenFor}). + * `position`); a stack-declared position is assigned with no row. The plugin + * no longer declares the `sys_position` row object (C3 stage 8), so the row + * create runs against the legacy test kit's table in every posture, and no + * catalog gate judges it: the catalog is written through the metadata door. */ const REFUSAL_GOLDEN: Record = { 'assign a position the security catalog does not hold': { @@ -567,49 +543,8 @@ const REFUSAL_GOLDEN: Record = { 'control: assign a stack-declared position (no row needed)': { ok: true, }, - 'delete the everyone row': { - ok: false, - code: 'PERMISSION_DENIED', - status: 403, - }, - 'relabel the platform_admin row': { - ok: false, - code: 'PERMISSION_DENIED', - status: 403, - }, }; -/** Whether the plugin still declares the `sys_position` row object; C3 stage 8 retires it (ADR-0131 D3). */ -const PLUGIN_DECLARES_POSITION_ROWS = securityObjects.some((o) => o.name === 'sys_position'); - -/** Whether a scenario judges the tenant's position create ({@link refusalGoldenFor}). */ -function judgesCreate(scenario: Scenario): boolean { - return scenario.posture === 'single' || PLUGIN_DECLARES_POSITION_ROWS; -} - -/** - * {@link REFUSAL_GOLDEN} for a scenario: the built-in row cases need an existing - * database's rows. [ADR-0131 D3] Under a wall the tenant's position CREATE is - * refused by the walled data-door refusal (`walled-catalog-data-door.ts`, also - * pinned by `walled-catalog-data-door.test.ts` and by the data-door case of - * `wall-catalog-refusal.dogfood.test.ts`): the catalog is the platform - * operator's, and a tenant may only assign it. That holds while the plugin - * declares the `sys_position` row object; C3 stage 8 retires the object and that - * door together, and the create is then left out. The assignment cases do not - * move, and the built-in row refusals keep their `403 PERMISSION_DENIED`. - */ -function refusalGoldenFor(scenario: Scenario): Record { - const { 'control: create a position row': _create, ...withoutCreate } = REFUSAL_GOLDEN; - const golden: Record = scenario.posture === 'single' - ? REFUSAL_GOLDEN - : PLUGIN_DECLARES_POSITION_ROWS - ? { ...REFUSAL_GOLDEN, 'control: create a position row': { ok: false, code: 'PERMISSION_DENIED', status: 403 } } - : withoutCreate; - if (scenario.existingRows) return golden; - const { 'delete the everyone row': _d, 'relabel the platform_admin row': _r, ...fresh } = golden; - return fresh; -} - /** * The rows the retired seeders wrote — the six built-ins (`platform`) and the * stack's declared `field_rep` — as an existing database holds them: one diff --git a/packages/plugins/plugin-security/src/catalog-reference-report.test.ts b/packages/plugins/plugin-security/src/catalog-reference-report.test.ts index 26a94c5d461..5e672dc30dd 100644 --- a/packages/plugins/plugin-security/src/catalog-reference-report.test.ts +++ b/packages/plugins/plugin-security/src/catalog-reference-report.test.ts @@ -389,6 +389,102 @@ describe('[ADR-0131 D4] a removed declaration is reported; re-adding it clears t }); }); +describe('[ADR-0131 D4] a position definition naming a permission set the catalog does not resolve — listed per position', () => { + /** A package that declares one permission set, and a package position binding it beside the auditor set. */ + const SET_PKG = 'com.example.s3.sets'; + const PKG_SET = { name: 's3_pkg_set', label: 'Package set', objects: {} }; + const BINDING_POSITION = 's3_bound_lead'; + + const installSet = (b: Booted) => + (b.engine as any).registry.registerItem('permission', structuredClone(PKG_SET), 'name', SET_PKG); + /** The set leaves the registry the way an uninstall of its package removes it. */ + const uninstallSet = (b: Booted) => (b.engine as any).registry.unregisterItem('permission', PKG_SET.name); + const bindPositions = (b: Booted) => { + (b.engine as any).registry.registerItem( + 'position', + { name: BINDING_POSITION, label: 'Bound lead', permissionSets: [PKG_SET.name, AUDITOR.name, PKG_SET.name] }, + 'name', + PKG, + ); + }; + + it('a fresh deployment reports no position: every built-in, declared and packaged position binds a set the catalog resolves', async () => { + const b = await boot(); + installSet(b); + bindPositions(b); + // Anti-vacuity: the catalog really serves positions that name sets. + const named = (await b.catalog.list('position')).filter((e) => Array.isArray(e.definition.permissionSets) + && (e.definition.permissionSets as unknown[]).length > 0).map((e) => e.name); + expect(named).toEqual(expect.arrayContaining([FIELD_LEAD.name, BINDING_POSITION])); + expect((await b.report()).positions).toEqual([]); + }); + + it('lists each position once, with only the names that resolve nowhere, its owner, and position order', async () => { + const b = await boot(); + installSet(b); + bindPositions(b); + uninstallSet(b); + b.declared.permission.delete(AUDITOR.name); + + const report = await b.report(); + expect(report.stopped).toBeUndefined(); + expect(report.positions).toEqual([ + { position: BINDING_POSITION, packageId: PKG, permissionSets: [PKG_SET.name, AUDITOR.name] }, + { position: FIELD_LEAD.name, packageId: null, permissionSets: [AUDITOR.name] }, + ]); + // A definition belongs to no organization: nothing is filed under one. + expect(findings(report)).toEqual([]); + }); + + it('clears by itself once the package is installed again: the next report lists nothing', async () => { + const b = await boot(); + installSet(b); + bindPositions(b); + uninstallSet(b); + expect((await b.report()).positions).toEqual([ + { position: BINDING_POSITION, packageId: PKG, permissionSets: [PKG_SET.name] }, + ]); + installSet(b); + expect((await b.report()).positions).toEqual([]); + }); + + it('logs one error line naming each position and its unresolved sets, and the clean line only once nothing is left', async () => { + const b = await boot(); + installSet(b); + bindPositions(b); + uninstallSet(b); + const logger = { info: vi.fn(), warn: vi.fn(), error: vi.fn() }; + await reportCatalogReferences(b.engine as any, { catalog: b.catalog, logger }); + expect(logger.error.mock.calls.map(([, , meta]: any[]) => meta)).toEqual([{ + count: 1, + permissionSets: [PKG_SET.name], + positions: { items: [{ position: BINDING_POSITION, packageId: PKG, permissionSets: [PKG_SET.name] }] }, + }]); + expect(logger.info).not.toHaveBeenCalled(); + + installSet(b); + logger.error.mockClear(); + await reportCatalogReferences(b.engine as any, { catalog: b.catalog, logger }); + expect(logger.error).not.toHaveBeenCalled(); + expect(logger.info).toHaveBeenCalledTimes(1); + }); + + it('an unreadable position list stops the report: no finding, and the stop is said', async () => { + const b = await boot(); + await insertRows(b, 'sys_user_position', [ + { id: 'up_ghost', user_id: 'usr_a', position: 'ghost_lead', organization_id: ORG }, + ]); + for (const list of [vi.fn().mockRejectedValue(new Error('registry offline')), vi.fn().mockResolvedValue(undefined)]) { + const catalog = { resolve: b.catalog.resolve.bind(b.catalog), list }; + const report = await collectCatalogReferences(b.engine as any, { catalog }); + expect({ stopped: report.stopped, organizations: report.organizations, positions: report.positions }).toEqual({ + stopped: 'catalog-unreadable', organizations: [], positions: [], + }); + expect(list).toHaveBeenCalledWith('position'); + } + }); +}); + describe('[ADR-0131 D4] a read that did not happen is never "resolves nowhere"', () => { it('an unreadable catalog stops the report: no finding, and the stop is said', async () => { const b = await boot(); @@ -418,13 +514,14 @@ describe('[ADR-0131 D4] a read that did not happen is never "resolves nowhere"', .toEqual({ stopped: 'scan-unreadable', reading: 'sys_user_permission_set', organizations: [] }); }); - it('a composition without the objects reads nothing and names what it lacks', async () => { + it('a composition without the objects scans no table, still reads the positions, and names what it lacks', async () => { const engine: CatalogReferenceReportEngine = { getObject: () => undefined, find: vi.fn() }; const logger = { info: vi.fn(), warn: vi.fn(), error: vi.fn() }; - const catalog = { resolve: vi.fn(), list: vi.fn() }; + const catalog = { resolve: vi.fn(), list: vi.fn().mockResolvedValue([]) }; const report = await reportCatalogReferences(engine, { catalog, logger }); expect(report.absent).toEqual(['sys_user_position', 'sys_user_permission_set']); expect(engine.find).not.toHaveBeenCalled(); + expect(catalog.list.mock.calls).toEqual([['position']]); expect([logger.info, logger.warn, logger.error].map((f) => f.mock.calls.length)).toEqual([0, 0, 0]); }); }); @@ -444,7 +541,8 @@ describe('[ADR-0131 D3/D4] SecurityPlugin runs the report at kernel:bootstrapped ]); const handlers = b.hooks.get('kernel:bootstrapped') ?? []; - expect(handlers.length).toBeGreaterThanOrEqual(2); + // The grant-name backfill, then this report (ADR-0131 D4). + expect(handlers).toHaveLength(2); for (const handler of handlers.slice(0, -1)) await handler(); const writes = [ vi.spyOn(b.engine, 'insert'), vi.spyOn(b.engine, 'update'), vi.spyOn(b.engine, 'delete'), diff --git a/packages/plugins/plugin-security/src/catalog-reference-report.ts b/packages/plugins/plugin-security/src/catalog-reference-report.ts index 55b1d17119f..9923a24957b 100644 --- a/packages/plugins/plugin-security/src/catalog-reference-report.ts +++ b/packages/plugins/plugin-security/src/catalog-reference-report.ts @@ -27,7 +27,18 @@ * resolve (a row-only set) — stay unnamed, and an unnamed grant confers * nothing. * - * It reads no `sys_position` row. The catalog row objects are retired (C3 + * And it lists PER POSITION, since a definition belongs to the environment and + * to no organization: + * + * 4. **Positions naming a permission set the catalog does not resolve** — position definitions in the + * catalog whose `permissionSets` name a set the catalog does not resolve. + * The authorization resolver reads a position's sets from its definition, + * so a holder of the position is granted nothing through such a name. The + * usual cause is a package that declared the set and is now uninstalled or + * disabled while a position still binds it; reinstalling the package clears + * the line at the next boot, with nothing else to do. + * + * It reads no `sys_position` row: positions are read from the catalog. The catalog row objects are retired (C3 * stage 8), so no composition provisions that table, and a Setup-authored * position row with no definition — or a name several such rows carry — is * the upgrade ceremony's to convert or report (ADR-0131 D10, C7), never a boot @@ -49,7 +60,8 @@ * report: it says which store could not be read and reports no finding past * that point, because an unread answer is never taken for an absent name. A * composition without one of the two objects reads the other and names the - * absent one in the result. + * absent one in the result; the position definitions are read from the catalog + * in every composition. * * ## When it runs * @@ -63,7 +75,7 @@ * ADR anchors: ADR-0131 D3 (one catalog home), D4 (references by name). */ -import type { SecurityCatalogReader, SecurityCatalogType } from '@objectstack/core'; +import type { SecurityCatalogEntry, SecurityCatalogReader, SecurityCatalogType } from '@objectstack/core'; import { GRANT_OBJECT, GRANT_SET_ID_FIELD, GRANT_SET_NAME_FIELD } from './grant-permission-set-name.js'; /** The user ↔ position assignment object (ADR-0057 D4). */ @@ -122,6 +134,16 @@ export interface OrganizationCatalogReferences { unnamedGrants: UnnamedGrant[]; } +/** One position definition that names permission sets the catalog does not resolve. */ +export interface PositionNamingUnresolvedSets { + /** The position's name. */ + position: string; + /** The package the definition names as its owner, or `null` for a stack or environment definition. */ + packageId: string | null; + /** The names in its `permissionSets` the catalog does not resolve, in declared order. */ + permissionSets: string[]; +} + /** Why the report stopped before it judged every reference. */ export type CatalogReferenceReportStop = | 'scan-unreadable' @@ -130,6 +152,8 @@ export type CatalogReferenceReportStop = /** What one report found. Only organizations with a finding are listed, the organization-less bucket first. */ export interface CatalogReferenceReport { organizations: OrganizationCatalogReferences[]; + /** Position definitions naming a permission set the catalog does not resolve, by position name. */ + positions: PositionNamingUnresolvedSets[]; /** The objects this composition does not provision; their part of the report was not read. */ absent: string[]; /** Set when a read did not happen; no finding is reported past it. */ @@ -203,6 +227,20 @@ async function scanAll( return out; } +/** Every position definition in the catalog; a read that fails, or answers no list, stops the report. */ +async function listPositions(catalog: SecurityCatalogReader): Promise { + let entries: unknown; + try { + entries = await catalog.list('position'); + } catch (e) { + throw new ReportStop('catalog-unreadable', 'the security catalog', e); + } + if (!Array.isArray(entries)) { + throw new ReportStop('catalog-unreadable', 'the security catalog', new Error('the position list answered no list')); + } + return entries as SecurityCatalogEntry[]; +} + /** The catalog's answer per name, each name asked once. */ function catalogResolver(catalog: SecurityCatalogReader): (type: SecurityCatalogType, name: string) => Promise { const memo = new Map(); @@ -233,6 +271,7 @@ export async function collectCatalogReferences( ): Promise { const report: CatalogReferenceReport = { organizations: [], + positions: [], absent: [], }; const has = (object: string): boolean => { @@ -290,16 +329,30 @@ export async function collectCatalogReferences( } } + for (const entry of await listPositions(deps.catalog)) { + const declared = entry?.definition?.permissionSets; + if (!Array.isArray(declared)) continue; + const unresolved: string[] = []; + for (const value of declared) { + const name = nameOf(value); + if (name === undefined || unresolved.includes(name) || (await resolves('permission', name))) continue; + unresolved.push(name); + } + if (unresolved.length === 0) continue; + report.positions.push({ position: entry.name, packageId: entry.packageId ?? null, permissionSets: unresolved }); + } } catch (e) { if (!(e instanceof ReportStop)) throw e; report.stopped = e.stop; report.stoppedReading = e.reading; report.stoppedError = errorText(e.reason); report.organizations = []; + report.positions = []; return report; } report.organizations = [...byOrganization.values()].sort((a, b) => compareKeys(a.organizationId, b.organizationId)); + report.positions.sort((a, b) => compareKeys(a.position, b.position)); return report; } @@ -395,12 +448,31 @@ function logReport(report: CatalogReferenceReport, logger?: CatalogReferenceRepo ); } - const clean = report.organizations.length === 0; - // A composition with neither object has nothing to report on. + if (report.positions.length > 0) { + const sets = new Set(report.positions.flatMap((p) => p.permissionSets)); + logError( + logger, + `[security] ${report.positions.length} position definition(s) name a permission set the security catalog ` + + 'does not resolve at this boot (ADR-0131 D4), so a holder of the position is granted nothing through that ' + + 'name. Nothing was changed. Fix: install or enable again the package that declared the permission set (the ' + + 'next boot then clears this line by itself), declare the set again, or remove the name from the ' + + "position's permissionSets.", + { + count: report.positions.length, + permissionSets: [...sets].sort(), + positions: listed(report.positions.map((p) => ({ + position: p.position, packageId: p.packageId, permissionSets: p.permissionSets, + }))), + }, + ); + } + + const clean = report.organizations.length === 0 && report.positions.length === 0; + // A composition with neither object has no assignment or grant to report on. if (clean && report.absent.length < 2) { logger?.info?.( - '[security] catalog reference report: every position assignment and permission-set grant names an item ' + - 'the security catalog resolves at this boot', + '[security] catalog reference report: every position assignment, permission-set grant and position ' + + "definition's permission set names an item the security catalog resolves at this boot", { absent: report.absent }, ); } 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 3895d8f8053..265c1829a74 100644 --- a/packages/plugins/plugin-security/src/cleanup-package-permissions.test.ts +++ b/packages/plugins/plugin-security/src/cleanup-package-permissions.test.ts @@ -7,9 +7,9 @@ import { cleanupPackagePermissions } from './cleanup-package-permissions'; /** * The permission-set names the uninstalling door read off the package before - * its withdrawal (`UninstallDeclared`, ADR-0131 D4). Handed to the sweep, they - * revoke the same grants whether it also finds the package's sets by their - * catalog rows (before C3 stage 8) or by these names alone (after it). + * its withdrawal (`UninstallDeclared`, ADR-0131 D4). Since C3 stage 8 they are + * the only way the sweep reaches the package's sets: no catalog row stands for + * a set. */ const DECLARED = { permission: ['crm_readonly', 'crm_admin'] }; @@ -17,8 +17,8 @@ const DECLARED = { permission: ['crm_readonly', 'crm_admin'] }; * A store holding both shapes a grant has carried: every grant names its set * (ADR-0131 D4) and still carries the legacy id of the set's catalog row. The * catalog rows themselves are here too — what a release before the row objects - * were retired (C3 stage 8) wrote — so the sweep revokes the same grants - * whether it finds the package's sets by its rows or by its declared names. + * were retired (C3 stage 8) wrote — and the sweep reads and deletes none of + * them: it finds the package's sets by their declared names alone. */ function makeQl() { const tables: Record = { @@ -69,7 +69,7 @@ describe('cleanupPackagePermissions (#2747)', () => { const ql = makeQl(); const out = await cleanupPackagePermissions(ql, 'com.example.crm', undefined, DECLARED); - expect(out).toMatchObject({ userGrants: 2, suggestions: 2, refused: 0 }); + expect(out).toEqual({ ...NOTHING, userGrants: 2, suggestions: 2 }); // no ghost grants: no grant naming the package's sets survives expect(ql.tables.sys_user_permission_set.map((r: any) => r.id)).toEqual(['ups_env', 'ups_other']); @@ -79,10 +79,8 @@ describe('cleanupPackagePermissions (#2747)', () => { it('never touches env-authored or foreign-package sets or their grants (ADR-0086 D4 provenance)', async () => { const ql = makeQl(); await cleanupPackagePermissions(ql, 'com.example.crm', undefined, DECLARED); - const names = ql.tables.sys_permission_set.map((r: any) => r.name); - expect(names).toContain('other_set'); - expect(names).toContain('member_default'); - expect(names).toContain('admin_full_access'); + // No catalog row is touched, the package's own legacy rows included. + expect(ql.tables.sys_permission_set.map((r: any) => r.id)).toEqual(['ps_crm_ro', 'ps_crm_admin', 'ps_other', 'ps_env', 'ps_legacy']); const grants = ql.tables.sys_user_permission_set.map((r: any) => r.permission_set); expect(grants).toEqual(['member_default', 'other_set']); }); @@ -91,7 +89,7 @@ describe('cleanupPackagePermissions (#2747)', () => { const ql = makeQl(); await cleanupPackagePermissions(ql, 'com.example.crm', undefined, DECLARED); const second = await cleanupPackagePermissions(ql, 'com.example.crm', undefined, DECLARED); - expect(second).toMatchObject({ userGrants: 0, suggestions: 0, refused: 0 }); + expect(second).toEqual(NOTHING); }); it('no-ops safely on a missing package id or a non-engine handle', async () => { @@ -122,8 +120,8 @@ describe('cleanupPackagePermissions (#2747)', () => { expect(nothingToDo.sets + nothingToDo.positionBindings + nothingToDo.userGrants + nothingToDo.suggestions).toBe(0); expect(refused).not.toEqual(nothingToDo); - // At least the 2 grants and the 2 suggestion rows. - expect(refused.refused).toBeGreaterThanOrEqual(4); + // The 2 grants and the 2 suggestion rows. + expect(refused.refused).toBe(4); expect(nothingToDo.refused).toBe(0); }); @@ -160,7 +158,7 @@ describe('cleanupPackagePermissions (#2747)', () => { // The `?.` on `warn` is the backstop for hosts the TYPE cannot reach. // A refusal report must never become the thing that breaks an uninstall. const out = await cleanupPackagePermissions(refusingQl(), 'com.example.crm', {}, DECLARED); - expect(out.refused).toBeGreaterThanOrEqual(4); + expect(out.refused).toBe(4); }); }); }); @@ -184,21 +182,21 @@ describe('[ADR-0131 D4, ADR-0090 D5] the grants naming a set the package declare permission: [...DECLARED.permission, 'crm_catalog_user'], }); - // The two grants naming the package's row-backed sets and the two naming - // its registry-only set. + // The two grants naming the sets the fixture also holds legacy rows for, + // and the two naming the set that never had one. expect(out.userGrants).toBe(4); expect(ql.tables.sys_user_permission_set.map((r: any) => r.id)).toEqual(['ups_env', 'ups_other', 'ups_named_other']); }); - it('control — handed no names, a grant naming a registry-only set survives', async () => { + it('control — handed no names, the sweep revokes no grant', async () => { const ql = withNamedGrants(); const out = await cleanupPackagePermissions(ql, 'com.example.crm'); - expect(out).toMatchObject({ suggestions: 2, refused: 0 }); - // Whatever the row legs reach while the catalog rows still exist, no grant - // naming a set the sweep was not handed is revoked. + expect(out).toEqual({ ...NOTHING, suggestions: 2 }); + // No grant is revoked: the legacy rows the fixture holds for the package's + // sets are not a way in. expect(ql.tables.sys_user_permission_set.map((r: any) => r.id)).toEqual( - expect.arrayContaining(['ups_env', 'ups_other', 'ups_named_1', 'ups_named_2', 'ups_named_other']), + ['ups_1', 'ups_2', 'ups_env', 'ups_other', 'ups_named_1', 'ups_named_2', 'ups_named_other'], ); }); }); diff --git a/packages/plugins/plugin-security/src/delegated-admin-gate-holding-organization.test.ts b/packages/plugins/plugin-security/src/delegated-admin-gate-holding-organization.test.ts index ea1fd91b215..22493f8a1e3 100644 --- a/packages/plugins/plugin-security/src/delegated-admin-gate-holding-organization.test.ts +++ b/packages/plugins/plugin-security/src/delegated-admin-gate-holding-organization.test.ts @@ -6,22 +6,19 @@ * * ## The defect this exists to keep closed * - * `sys_user_position.position` is a position NAME, and `sys_position.name` is - * unique per organization only, so in a single-database multi-org posture + * `sys_user_position.position` is a position NAME, the same string in every + * organization that holds it (ADR-0131 D3: one environment catalog), so in a + * single-database multi-org posture * (ADR-0105 D1 `group` / `isolated`) two organizations' holdings of the same * name are the same string. Two gate reads selected holdings by that name under - * a bare `{ isSystem: true }` context with no organization condition: + * a bare `{ isSystem: true }` context with no organization condition. The one + * that remains is `activeHoldings` — does the delegator CURRENTLY and + * DIRECTLY hold the position (ADR-0091 D3 rule 4)? A holding stamped for + * ANOTHER organization answered yes, so a user self-delegated a position in an + * organization where they held nothing. (The other, the binding blast radius, + * retired with the `sys_position_permission_set` binding rows, ADR-0131 D3.) * - * - `activeHoldings` — does the delegator CURRENTLY and DIRECTLY hold the - * position (ADR-0091 D3 rule 4)? A holding stamped for ANOTHER - * organization answered yes, so a user self-delegated a position in an - * organization where they held nothing. - * - `assignmentAnchorsOfPosition` — whose capability does re-binding the - * position re-compose (the ADR-0090 D12 blast radius)? Another - * organization's same-named assignments entered it, so a delegate was - * refused over holders their binding never reaches. - * - * ## The rule both reads now follow — the runtime's own + * ## The rule the read now follows — the runtime's own * * `resolveAuthzContext` (step 4, `@objectstack/core`) keeps a holding when its * `organization_id` is empty OR equals the active organization, and drops it @@ -34,7 +31,6 @@ import { describe, it, expect, afterEach } from 'vitest'; import { ObjectQL } from '@objectstack/objectql'; import { SqlDriver } from '@objectstack/driver-sql'; -import type { PermissionSet } from '@objectstack/spec/security'; import { DelegatedAdminGate } from './delegated-admin-gate.js'; import { bindTestSecurityCatalog } from './__tests__/security-catalog.testkit.js'; @@ -57,7 +53,6 @@ const OBJECTS = [ object('sys_business_unit', ['name', 'parent_business_unit_id']), object('sys_position', ['name', 'delegatable']), object('sys_permission_set', ['name', 'admin_scope']), - object('sys_position_permission_set', ['position_id', 'permission_set_id']), object('sys_user_position', [ 'user_id', 'position', 'business_unit_id', 'granted_by', 'delegated_from', 'valid_until', 'reason', ]), @@ -65,24 +60,6 @@ const OBJECTS = [ object('sys_user', ['primary_business_unit_id']), ]; -/** A binding delegate's scope: the unit NAMED `sales`, may bind `sales_user` only. */ -const BIND_SCOPE = { - businessUnit: 'sales', - includeSubtree: true, - manageAssignments: false, - manageBindings: true, - authorEnvironmentSets: false, - assignablePermissionSets: ['sales_user'], -}; - -const BINDER_SETS = [ - { - name: 'sales_binder', - objects: { sys_position_permission_set: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true } }, - adminScope: BIND_SCOPE, - }, -] as unknown as PermissionSet[]; - const engines: ObjectQL[] = []; afterEach(async () => { while (engines.length) { @@ -164,7 +141,7 @@ function gateOn(engine: ObjectQL) { }); return new DelegatedAdminGate({ ql: engine, - resolveSets: async (context: any) => (context?.principal === 'binder' ? BINDER_SETS : []), + resolveSets: async () => [], }); } @@ -190,15 +167,6 @@ const selfDelegate = (gate: DelegatedAdminGate, organizationId: string | undefin : { principal: 'holder', userId: USER }, }); -/** A binding delegate in `organizationId` binds `sales_user` to the position row `positionId`. */ -const bind = (gate: DelegatedAdminGate, organizationId: string, positionId: string, permissionSetId: string) => - gate.assert({ - object: 'sys_position_permission_set', - operation: 'insert', - data: { position_id: positionId, permission_set_id: permissionSetId }, - context: { principal: 'binder', userId: 'usr_binder', tenantId: organizationId }, - }); - /** A gate refusal: the ADR-0112 envelope, then the first sentence that names why. */ async function expectRefused(p: Promise, why: RegExp) { const err: any = await p.then(() => null, (e) => e); @@ -285,71 +253,3 @@ describe('DelegatedAdminGate — rule 4 counts only holdings the caller\'s organ await expect(selfDelegate(gateOn(engine), undefined)).resolves.toBeUndefined(); }); }); - -// ── assignmentAnchorsOfPosition — the binding blast radius ──────────────── - -describe('DelegatedAdminGate — a binding\'s blast radius is the holders the bound position reaches', () => { - it('org B\'s same-named assignments do not refuse org A\'s binding (outside-subtree anchor)', async () => { - const engine = await bootEngine(); - const b = await seedOrganization(engine, ORG_B, '0'); - const a = await seedOrganization(engine, ORG_A, 'a'); - await hold(engine, ORG_A, [{ id: 'up_a', user_id: 'usr_a1', business_unit_id: a.salesEast }]); - await hold(engine, ORG_B, [{ id: 'up_b', user_id: 'usr_b1', business_unit_id: b.salesEast }]); - - await expect(bind(gateOn(engine), ORG_A, a.fieldLead, a.salesUser)).resolves.toBeUndefined(); - }); - - it('org B\'s same-named assignments do not push org A\'s binding over the blast-radius cap', async () => { - const engine = await bootEngine(); - const b = await seedOrganization(engine, ORG_B, '0'); - const a = await seedOrganization(engine, ORG_A, 'a'); - await hold(engine, ORG_A, [{ id: 'up_a', user_id: 'usr_a1', business_unit_id: a.salesEast }]); - await hold(engine, ORG_B, Array.from({ length: 501 }, (_, i) => ({ - id: `up_b_${String(i).padStart(3, '0')}`, - user_id: `usr_b_${i}`, - business_unit_id: b.salesEast, - }))); - - await expect(bind(gateOn(engine), ORG_A, a.fieldLead, a.salesUser)).resolves.toBeUndefined(); - }); - - it('an organization-less holding IS in org A\'s blast radius — the runtime grants it org A\'s binding', async () => { - const engine = await bootEngine(); - await seedOrganization(engine, ORG_B, '0'); - const a = await seedOrganization(engine, ORG_A, 'a'); - await hold(engine, ORG_A, [{ id: 'up_a', user_id: 'usr_a1', business_unit_id: a.salesEast }]); - await hold(engine, undefined, [{ id: 'up_none', user_id: 'usr_x' }]); - - await expectRefused( - bind(gateOn(engine), ORG_A, a.fieldLead, a.salesUser), - /position 'field_lead' has 1 unanchored assignment\(s\)/, - ); - }); - - it('the radius follows the BOUND row\'s organization — org B\'s holders still guard org B\'s row', async () => { - const engine = await bootEngine(); - const b = await seedOrganization(engine, ORG_B, '0'); - const a = await seedOrganization(engine, ORG_A, 'a'); - await hold(engine, ORG_A, [{ id: 'up_a', user_id: 'usr_a1', business_unit_id: a.salesEast }]); - await hold(engine, ORG_B, [{ id: 'up_b', user_id: 'usr_b1', business_unit_id: b.salesEast }]); - - await expectRefused( - bind(gateOn(engine), ORG_A, b.fieldLead, a.salesUser), - /position 'field_lead' is held in business unit 'bu_0_sales_east', outside the delegated subtree/, - ); - }); - - it('control (dark) — org A\'s own holder outside the subtree still refuses', async () => { - const engine = await bootEngine(); - const a = await seedOrganization(engine, ORG_A, 'a'); - await engine.insert('sys_business_unit', [ - { id: 'bu_a_ops', name: 'ops', parent_business_unit_id: null }, - ], { context: sysCtx(ORG_A) } as any); - await hold(engine, ORG_A, [{ id: 'up_a', user_id: 'usr_a1', business_unit_id: 'bu_a_ops' }]); - - await expectRefused( - bind(gateOn(engine), ORG_A, a.fieldLead, a.salesUser), - /position 'field_lead' is held in business unit 'bu_a_ops', outside the delegated subtree/, - ); - }); -}); diff --git a/packages/plugins/plugin-security/src/delegated-admin-gate.test.ts b/packages/plugins/plugin-security/src/delegated-admin-gate.test.ts index 3cec75ea246..ecba9982a9e 100644 --- a/packages/plugins/plugin-security/src/delegated-admin-gate.test.ts +++ b/packages/plugins/plugin-security/src/delegated-admin-gate.test.ts @@ -283,41 +283,6 @@ describe('DelegatedAdminGate — assignments (sys_user_position)', () => { }); }); -describe('DelegatedAdminGate — bindings (sys_position_permission_set)', () => { - it('delegate binds an allowlisted set to a position held only inside the subtree', async () => { - await expect(h.gate.assert({ - object: 'sys_position_permission_set', operation: 'insert', - data: { position_id: 'pos_sales', permission_set_id: 'ps_sales' }, - context: h.ctxOf('delegate'), - })).resolves.toBeUndefined(); - }); - - it('denies binding a non-allowlisted set', async () => { - await expect(h.gate.assert({ - object: 'sys_position_permission_set', operation: 'insert', - data: { position_id: 'pos_sales', permission_set_id: 'ps_fin' }, - context: h.ctxOf('delegate'), - })).rejects.toThrow(/not in the scope's allowlist/); - }); - - it('denies re-composing a position held outside the subtree (blast radius)', async () => { - h.tables.sys_user_position.push({ id: 'a_w', user_id: 'u_west_1', position: 'sales_rep', business_unit_id: 'bu_west' }); - await expect(h.gate.assert({ - object: 'sys_position_permission_set', operation: 'insert', - data: { position_id: 'pos_sales', permission_set_id: 'ps_sales' }, - context: h.ctxOf('delegate'), - })).rejects.toThrow(/outside the delegated subtree/); - }); - - it('audience-anchor bindings are tenant-level only for delegates', async () => { - await expect(h.gate.assert({ - object: 'sys_position_permission_set', operation: 'insert', - data: { position_id: 'pos_everyone', permission_set_id: 'ps_sales' }, - context: h.ctxOf('delegate'), - })).rejects.toThrow(/tenant-level only/); - }); -}); - describe('DelegatedAdminGate — direct grants (sys_user_permission_set)', () => { it('delegate grants an allowlisted set to a user inside the subtree; granted_by stamped', async () => { const row: any = { user_id: 'u_east_1', permission_set: 'sales_user' }; @@ -399,56 +364,6 @@ describe('DelegatedAdminGate — direct grants (sys_user_permission_set)', () => }); }); -describe('DelegatedAdminGate — env-set authoring (sys_permission_set)', () => { - it('delegate with authorEnvironmentSets may insert an inert env set', async () => { - await expect(h.gate.assert({ - object: 'sys_permission_set', operation: 'insert', - data: { name: 'east_helper', object_permissions: '{}' }, - context: h.ctxOf('delegate'), - })).resolves.toBeUndefined(); - }); - - it('authoring a set that mints a NARROWER adminScope is allowed (strict containment)', async () => { - await expect(h.gate.assert({ - object: 'sys_permission_set', operation: 'insert', - data: { - name: 'east_sales_admin', - admin_scope: JSON.stringify({ - businessUnit: 'east_sales', - manageAssignments: true, - assignablePermissionSets: ['sales_user'], - }), - }, - context: h.ctxOf('delegate'), - })).resolves.toBeUndefined(); - }); - - it('authoring a scope equal to or broader than your own is denied', async () => { - await expect(h.gate.assert({ - object: 'sys_permission_set', operation: 'insert', - data: { name: 'clone_of_mine', admin_scope: JSON.stringify(EAST_SCOPE) }, - context: h.ctxOf('delegate'), - })).rejects.toThrow(/strictly contains it/); - - await expect(h.gate.assert({ - object: 'sys_permission_set', operation: 'insert', - data: { - name: 'hq_takeover', - admin_scope: JSON.stringify({ ...EAST_SCOPE, businessUnit: 'hq' }), - }, - context: h.ctxOf('delegate'), - })).rejects.toThrow(/strictly contains it/); - }); - - it('delegates cannot set the tenant-wide isDefault suggestion', async () => { - await expect(h.gate.assert({ - object: 'sys_permission_set', operation: 'insert', - data: { name: 'east_default', isDefault: true }, - context: h.ctxOf('delegate'), - })).rejects.toThrow(/tenant-level/); - }); -}); - // ── [ADR-0091 D3] Self-service delegation of duty ────────────────────────── const T0 = Date.parse('2026-07-01T00:00:00Z'); @@ -1104,9 +1019,6 @@ describe('DelegatedAdminGate — granted_by is the writer on every admitted inse it('a governed object with no granted_by column is never given one', async () => { const member: any = { user_id: 'u_x', organization_id: 'org_1', role: 'admin' }; await expect(h.gate.assert({ object: 'sys_member', operation: 'insert', data: member, context: h.ctxOf('tenant_admin') })).resolves.toBeUndefined(); - const binding: any = { position_id: 'pos_sales', permission_set_id: 'ps_sales' }; - await expect(h.gate.assert({ object: 'sys_position_permission_set', operation: 'insert', data: binding, context: h.ctxOf('tenant_admin') })).resolves.toBeUndefined(); expect('granted_by' in member).toBe(false); - expect('granted_by' in binding).toBe(false); }); }); diff --git a/packages/plugins/plugin-security/src/delegated-admin-gate.ts b/packages/plugins/plugin-security/src/delegated-admin-gate.ts index 45b91996163..141920cea03 100644 --- a/packages/plugins/plugin-security/src/delegated-admin-gate.ts +++ b/packages/plugins/plugin-security/src/delegated-admin-gate.ts @@ -14,16 +14,19 @@ * its explicit per-table `allowEdit:false` overrides — unchanged). * - DELEGATES (non-tenant-admins holding ≥1 `adminScope`) may write ONLY * what one of their scopes fully approves: the right kind of action - * (manageAssignments / manageBindings / authorEnvironmentSets), inside - * their BU subtree, handing out only allowlisted sets, with STRICT - * containment whenever the touched set itself carries an adminScope. + * (manageAssignments), inside their BU subtree, handing out only + * allowlisted sets, with STRICT containment whenever the granted set itself + * carries an adminScope. The rows it governs are the two assignment tables + * (`sys_user_position`, `sys_user_permission_set`); the binding and set + * authoring rows retired with the catalog row objects (ADR-0131 D3), so a + * position's sets and a set's definition are written through the metadata + * door alone. * - EVERYONE ELSE is denied — holding plain CRUD on `sys_user_position` * no longer makes you a permission administrator (fail closed, D12). * - * The `everyone` / `guest` audience anchors stay tenant-level only: no - * delegated scope can touch their bindings, and DIRECT position assignments - * to an anchor are rejected for every caller (anchors are implicit — a - * stored assignment row is a modeling error). + * DIRECT position assignments to the `everyone` / `guest` audience anchors + * are rejected for every caller (anchors are implicit — a stored assignment + * row is a modeling error). * * `sys_member` (organization membership) is likewise tenant-level only: no * `adminScope` axis expresses membership, and an admin-grade membership row is @@ -63,8 +66,6 @@ const SYSTEM_CTX = { isSystem: true } as const; const ANCHOR_CANDIDATE_LIMIT = 50; /** Max BU-tree depth walked when expanding a scope subtree (safety bound). */ const MAX_TREE_DEPTH = 32; -/** Max existing assignments examined for a binding blast-radius check. */ -const BLAST_RADIUS_CAP = 500; /** [ADR-0091 D3] Default self-delegation ceiling: 30 days. A "temporary" * grant that can be rolled forever is a permanent grant with extra steps. */ const DEFAULT_DELEGATION_CEILING_MS = 30 * 24 * 60 * 60 * 1000; @@ -106,9 +107,10 @@ function callerOrganizationId(context: any): string | undefined { * `organizationId` undefined (a context carrying no organization) drops nothing. * * Every holding the gate reasons about goes through this, because - * `sys_user_position.position` is a position NAME and `sys_position.name` is - * unique per organization only — so under a walled posture another - * organization's holding of a same-named position is the same string. The + * `sys_user_position.position` is a position NAME, the same string in every + * organization that holds it (ADR-0131 D3: one environment catalog) — so under + * a walled posture another organization's holding of the position is the same + * string. The * gate must count exactly the holdings the runtime grants: counting another * organization's lets a user self-delegate a position they do not hold there; * dropping organization-less ones refuses holdings the runtime honours. @@ -130,9 +132,7 @@ function toEpochMs(value: unknown): number | undefined { const GOVERNED_OBJECTS = new Set([ 'sys_user_position', - 'sys_position_permission_set', 'sys_user_permission_set', - 'sys_permission_set', // [#3697 follow-up] Organization MEMBERSHIP is an authority surface too: a // row whose `role` is admin-grade is auto-elevated to `organization_admin` // by `auto-org-admin-grant.ts`, whose wildcard `modifyAllRecords` IS @@ -399,10 +399,6 @@ export class DelegatedAdminGate { return this.assertAssignmentWrite(opCtx, held); case 'sys_user_permission_set': return this.assertDirectGrantWrite(opCtx, held); - case 'sys_position_permission_set': - return this.assertBindingWrite(opCtx, held); - case 'sys_permission_set': - return this.assertSetAuthoring(opCtx, held, organizationId); } } @@ -840,111 +836,6 @@ export class DelegatedAdminGate { } } - // ── sys_position_permission_set: position ↔ set bindings ──────────── - - private async assertBindingWrite(opCtx: any, held: HeldScope[]): Promise { - const targets = await this.materializeTargets(opCtx, 'sys_position_permission_set'); - for (const t of targets) { - const row = t.next ?? t.prev ?? {}; - const position = await this.positionById(row.position_id); - const positionName = position.name; - if (ANCHOR_POSITIONS.has(positionName)) { - throw new PermissionDeniedError( - `[Security] Access denied: bindings of the '${positionName}' audience anchor are ` + - `tenant-level only — no delegated scope can touch them (ADR-0090 D12).`, - { operation: opCtx.operation, object: opCtx.object, position: positionName }, - ); - } - const setRow = await this.loadSetRowById(row.permission_set_id); - const setName = String(setRow?.name ?? row.permission_set_id ?? ''); - const radius = positionName - ? await this.assignmentAnchorsOfPosition(positionName, position.organizationId) - : { anchors: new Set(), overCap: false, unanchored: 0 }; - - const failure = this.firstApprovalFailure(held, (s) => { - if (!s.scope.manageBindings) return 'the scope does not grant manageBindings'; - if (!s.scope.assignablePermissionSets.includes(setName)) { - return `permission set '${setName}' is not in the scope's allowlist`; - } - // Blast radius: re-composing a position re-composes EVERY holder's - // capability. A delegate may only do that when all current holders - // sit inside their subtree (fail closed on unanchored/over-cap). - if (radius.overCap) return `position '${positionName}' has more than ${BLAST_RADIUS_CAP} assignments — only a tenant admin may re-compose it`; - if (radius.unanchored > 0) return `position '${positionName}' has ${radius.unanchored} unanchored assignment(s) (no business_unit_id) — only a tenant admin may re-compose it`; - for (const bu of radius.anchors) { - if (!s.subtree.has(bu)) return `position '${positionName}' is held in business unit '${bu}', outside the delegated subtree`; - } - if (setRow) { - const contained = this.assertScopeGrantContainment(setRow, held, true); - if (contained) return contained; - } - return null; - }); - if (failure) { - throw new PermissionDeniedError( - `[Security] Access denied: delegated '${opCtx.operation}' on sys_position_permission_set rejected — ${failure}.`, - { operation: opCtx.operation, object: opCtx.object, position: positionName, permissionSet: setName }, - ); - } - } - } - - // ── sys_permission_set: environment-set authoring ──────────────────── - - private async assertSetAuthoring(opCtx: any, held: HeldScope[], organizationId?: string): Promise { - // Package-managed rows were already rejected by the two-doors gate; what - // reaches here is environment-owned authoring. - const targets = await this.materializeTargets(opCtx, 'sys_permission_set'); - for (const t of targets) { - const payload = t.next ?? {}; - const existing = t.prev ?? null; - const setName = String(payload.name ?? existing?.name ?? ''); - const authoredScope: AdminScope | undefined = - parseMaybeJson(payload.admin_scope ?? payload.adminScope) ?? undefined; - - const failure = this.firstApprovalFailure(held, (s) => { - if (!s.scope.authorEnvironmentSets) return 'the scope does not grant authorEnvironmentSets'; - // Mutating/deleting an existing env set is only in-domain when the - // delegate distributes it (allowlist); fresh inserts are inert until - // distributed, so they pass this check. - if (existing && !s.scope.assignablePermissionSets.includes(setName)) { - return `environment set '${setName}' is outside the scope's allowlist — only its tenant-level owner may change it`; - } - return null; - }); - if (failure) { - throw new PermissionDeniedError( - `[Security] Access denied: delegated '${opCtx.operation}' on sys_permission_set rejected — ${failure}.`, - { operation: opCtx.operation, object: opCtx.object, permissionSet: setName }, - ); - } - - // A delegate may suggest nothing tenant-wide: isDefault is the D5 - // install-suggestion to bind to `everyone` — tenant-level only. - if (t.next && (t.next.isDefault === true || t.next.is_default === true)) { - throw new PermissionDeniedError( - `[Security] Access denied: 'isDefault' (the everyone-binding suggestion) is tenant-level ` + - `only — a delegated administrator cannot set it (ADR-0090 D12).`, - { operation: opCtx.operation, object: opCtx.object, permissionSet: setName }, - ); - } - - // Authoring a set that CARRIES an adminScope = minting administration: - // requires a held scope that STRICTLY contains the minted one. - if (authoredScope) { - const containment = await this.checkStrictContainment(authoredScope, held, organizationId); - if (containment) { - throw new PermissionDeniedError( - `[Security] Access denied: the authored adminScope is not strictly contained by your ` + - `own — ${containment} (ADR-0090 D12: granting an admin scope requires holding a scope ` + - `that strictly contains it).`, - { operation: opCtx.operation, object: opCtx.object, permissionSet: setName }, - ); - } - } - } - } - // ── Shared helpers ──────────────────────────────────────────────────── /** First scope that fully approves wins; otherwise the FIRST failure reason @@ -972,9 +863,8 @@ export class DelegatedAdminGate { parseMaybeJson(setRowOrDef.admin_scope ?? setRowOrDef.adminScope) ?? undefined; if (!carried) return null; // NOTE: containment needs resolved subtrees — checked synchronously against - // the pre-resolved held scopes; the carried scope's subtree is resolved in - // checkStrictContainment for the authoring path. For grant paths we compare - // conservatively by definition (name + flags + allowlist). + // the pre-resolved held scopes. Grant paths compare conservatively by + // definition (name + flags + allowlist). for (const s of held) { if (this.definitionContainsStrictly(s.scope, carried)) return null; } @@ -1018,43 +908,6 @@ export class DelegatedAdminGate { return !(equalRights && equalTree && equalAllow); } - /** Authoring-path strict containment with resolved subtrees. Returns a - * failure description, or null when some held scope strictly contains. */ - private async checkStrictContainment( - minted: AdminScope, - held: HeldScope[], - organizationId?: string, - ): Promise { - const mintedSubtree = await this.resolveSubtree( - minted.businessUnit, - minted.includeSubtree !== false, - organizationId, - ); - if (mintedSubtree.size === 0) return `its business unit '${minted.businessUnit}' does not resolve`; - for (const s of held) { - let treeContained = true; - for (const bu of mintedSubtree) if (!s.subtree.has(bu)) { treeContained = false; break; } - if (!treeContained) continue; - if ((minted.manageAssignments === true) && !s.scope.manageAssignments) continue; - if ((minted.manageBindings === true) && !s.scope.manageBindings) continue; - if ((minted.authorEnvironmentSets === true) && !s.scope.authorEnvironmentSets) continue; - const mintedAllow = minted.assignablePermissionSets ?? []; - if (!mintedAllow.every((n) => s.scope.assignablePermissionSets.includes(n))) continue; - // Strictness on the resolved tuple. - const equalTree = mintedSubtree.size === s.subtree.size; - const equalRights = - (minted.manageAssignments === true) === (s.scope.manageAssignments === true) && - (minted.manageBindings === true) === (s.scope.manageBindings === true) && - (minted.authorEnvironmentSets === true) === (s.scope.authorEnvironmentSets === true); - const equalAllow = - mintedAllow.length === s.scope.assignablePermissionSets.length && - mintedAllow.every((n) => s.scope.assignablePermissionSets.includes(n)); - if (equalTree && equalRights && equalAllow) continue; // identical — not STRICT - return null; - } - return 'no held scope covers its subtree, rights and allowlist with room to spare'; - } - /** Resolve every adminScope carried by the actor's resolved sets, inside the * caller's own organization ({@link callerOrganizationId}). */ private async resolveHeldScopes( @@ -1311,81 +1164,6 @@ export class DelegatedAdminGate { } } - private async loadSetRowById(id: unknown): Promise { - if (id == null || !this.deps.ql?.find) return null; - try { - const rows = await this.deps.ql.find('sys_permission_set', { where: { id }, limit: 1, context: SYSTEM_CTX }); - return Array.isArray(rows) && rows[0] ? rows[0] : null; - } catch { - return null; - } - } - - /** A `sys_position` row by id: its name, and the organization that owns it - * (undefined = an organization-less row). */ - private async positionById(id: unknown): Promise<{ name: string; organizationId: string | undefined }> { - const none = { name: '', organizationId: undefined }; - if (id == null || !this.deps.ql?.find) return none; - try { - const rows = await this.deps.ql.find('sys_position', { where: { id }, limit: 1, context: SYSTEM_CTX }); - const row = Array.isArray(rows) && rows[0] ? (rows[0] as any) : null; - if (!row) return none; - return { name: String(row.name ?? ''), organizationId: rowOrganizationId(row) || undefined }; - } catch { - return none; - } - } - - /** - * BU anchors of every assignment a re-binding of this position row reaches - * (blast radius). - * - * An assignment names the position by NAME, and a binding hangs off one - * `sys_position` ROW, which the runtime reaches only from the organization - * that owns it. So the holders a binding re-composes are the ones whose - * holding takes effect in the bound row's organization - * ({@link holdingTakesEffectIn}) — that organization's own holdings plus the - * organization-less ones — never another organization's same-named - * assignments, which would otherwise refuse the write as outside the - * subtree or push it over {@link BLAST_RADIUS_CAP}. Keyed on the bound - * row's organization rather than the caller's, so a binding written against - * another organization's row is still judged by THAT organization's holders. - * An organization-less row reaches every organization: no holding is dropped. - * - * The organization rides the read's context, so the driver's tenant scope - * (`organization_id = :org OR organization_id IS NULL`, the governed spelling - * of exactly this rule) applies BEFORE the cap — another organization's rows - * can neither fill the page nor trip {@link BLAST_RADIUS_CAP}. The same rule - * is re-applied to what comes back, for a driver that does not scope. - */ - private async assignmentAnchorsOfPosition( - positionName: string, - positionOrganizationId?: string, - ): Promise<{ anchors: Set; overCap: boolean; unanchored: number }> { - const anchors = new Set(); - let unanchored = 0; - const ql = this.deps.ql; - if (!ql?.find) return { anchors, overCap: false, unanchored }; - let rows: any[] = []; - try { - rows = await ql.find('sys_user_position', { - where: { position: positionName }, - limit: BLAST_RADIUS_CAP + 1, - context: organizationScopedCtx(positionOrganizationId), - }); - } catch { - rows = []; - } - const list = Array.isArray(rows) ? rows : []; - if (list.length > BLAST_RADIUS_CAP) return { anchors, overCap: true, unanchored }; - for (const r of list.filter((row) => holdingTakesEffectIn(row, positionOrganizationId))) { - const bu = (r as any)?.business_unit_id; - if (bu == null || bu === '') unanchored += 1; - else anchors.add(String(bu)); - } - return { anchors, overCap: false, unanchored }; - } - /** Target user's BU memberships (sys_business_unit_member ∪ primary projection). */ private async businessUnitsOfUser(userId: string): Promise> { const out = new Set(); diff --git a/packages/plugins/plugin-security/src/errors.test.ts b/packages/plugins/plugin-security/src/errors.test.ts index 2f5acfb507b..e5cdf185cf1 100644 --- a/packages/plugins/plugin-security/src/errors.test.ts +++ b/packages/plugins/plugin-security/src/errors.test.ts @@ -41,7 +41,6 @@ const FLOOR = [ 'MasterReferenceMissingError', 'MaskedValueWriteError', 'ExplainObjectNotFoundError', - 'PermissionSetNameConflictError', ]; /** diff --git a/packages/plugins/plugin-security/src/errors.ts b/packages/plugins/plugin-security/src/errors.ts index eaf10992655..06a09b29805 100644 --- a/packages/plugins/plugin-security/src/errors.ts +++ b/packages/plugins/plugin-security/src/errors.ts @@ -334,89 +334,6 @@ export class ExplainObjectNotFoundError extends Error { } } -/** - * The ADR-0112 code {@link PermissionSetNameConflictError} stamps — see that - * class for why this collision is `UNIQUE_VIOLATION` and why the value is a - * named constant rather than a class-field literal. - */ -export const PERMISSION_SET_NAME_CONFLICT_CODE = 'UNIQUE_VIOLATION'; - -/** The HTTP status {@link PermissionSetNameConflictError} declares. */ -export const PERMISSION_SET_NAME_CONFLICT_STATUS = 409; - -/** - * [commit 8f6d83147] The data door's duplicate-name refusal on `sys_permission_set`: - * a set with this machine name already exists in the caller's organization, so - * the insert is refused. - * - * ## Why this is a CLASS and not a bare `Error` with `.status = 409` - * - * It was the bare form until now, and the bare form has no `code`. The flat - * `{ error, code }` responder in `packages/rest` puts a thrown `code` on the - * wire and invents nothing when the producer declared none, so the refusal - * reached the client as prose alone — against ADR-0112's 2026-08-17 amendment - * (#9232), under which the flat door carries the closed member too. Measured - * before the fix: `409 {"error":"[Security] permission set 'showcase_manager' - * already exists","object":"sys_permission_set"}`, with no `code` key at all, - * while an unauthenticated write on the same resource answered - * `401 UNAUTHENTICATED` — so the absence was this producer's, never the door's. - * A dialog that has to branch on the refusal was pushed to string-matching. - * - * ## Why `UNIQUE_VIOLATION` and not a newly minted code - * - * It is the wire identity this platform ALREADY answers for this exact - * condition on this exact column. `sys_permission_set` declares - * `{ fields: ['name'], unique: 'organization' }`, and a collision that reaches - * the storage layer comes back as `409 UNIQUE_VIOLATION` — the reading - * recorded on that index's own comment (#8554) is `org_yi 409 - * UNIQUE_VIOLATION`. This middleware refuses the same collision one layer - * earlier, so a second spelling here would make ONE condition answer two - * envelopes depending only on whether the projection's pre-check or the index - * caught it — the drift `@objectstack/rest` and `@objectstack/driver-memory` - * already registered the SAME code to avoid ("the wire identity is - * deliberately the SAME"). #5240's one-condition-one-wording, on the code axis. - * - * ⛔ Not `RESOURCE_CONFLICT` (the standard member 409 derives from): that is - * what the door would supply for a producer that named no condition, and it - * would be the second spelling described above. - * - * ## Why BOTH `status` and `statusCode` - * - * The same reason every class above records: the doors read different property - * names (see "Why each carries BOTH `status` and `statusCode`" above), and this - * throws on the DATA path, which reaches them all. - * - * The message is byte-identical to the bare `Error`'s — the wording was never - * the defect, and the flat door's 4xx arm ships it verbatim. - * - * ## Why the code is a NAMED CONSTANT and not a bare class-field literal - * - * Same spelling `@objectstack/driver-memory` uses for its own registration of - * this code ("via the package's exported `UNIQUE_VIOLATION_CODE` / - * `UNIQUE_VIOLATION_STATUS`"), and the reason is mechanical rather than - * stylistic: `check:error-code-provenance` recognises `objlit`, `assign` and - * `*_CODE` `constdef` stamp sites and is blind to class fields by its own - * declared bounds. Written as a class-field literal this package would have - * become an unlisted EMITTER of a registered code with every gate in the repo - * green — the exact invisibility the ledger header names ("no admission rule - * checks WHO emits, so an unlisted emitter is invisible to every gate the repo - * has", three hand sweeps, #7504 / #13254 / #13353). The constant puts this - * emitter inside the gate's field of view, so the provenance row under - * `@objectstack/plugin-security` is enforced and not merely intended. - */ -export class PermissionSetNameConflictError extends Error { - readonly code = PERMISSION_SET_NAME_CONFLICT_CODE; - readonly status = PERMISSION_SET_NAME_CONFLICT_STATUS; - readonly statusCode = PERMISSION_SET_NAME_CONFLICT_STATUS; - /** The permission-set machine name that was already taken. */ - readonly setName: string; - constructor(setName: string) { - super(`[Security] permission set '${setName}' already exists`); - this.name = 'PermissionSetNameConflictError'; - this.setName = setName; - } -} - export function isPermissionDeniedError(e: unknown): e is PermissionDeniedError { if (!e || typeof e !== 'object') return false; const anyE = e as any; diff --git a/packages/plugins/plugin-security/src/grant-permission-set-name-backfill.test.ts b/packages/plugins/plugin-security/src/grant-permission-set-name-backfill.test.ts index a6f8427dc4a..db54796705f 100644 --- a/packages/plugins/plugin-security/src/grant-permission-set-name-backfill.test.ts +++ b/packages/plugins/plugin-security/src/grant-permission-set-name-backfill.test.ts @@ -675,12 +675,11 @@ describe('[ADR-0131 D4] grant name backfill — boot wiring', () => { await seedPrincipals(world, 'single'); await clearAllNames(world.engine); - // The `kernel:bootstrapped` handlers, in registration order: this backfill - // first, the catalog reference report (`catalog-reference-report.test.ts`) - // last — and between them the row-only position backfill until C3 stage 8 - // unwires it. Only the first runs here. + // Two `kernel:bootstrapped` handlers, in registration order: this + // backfill, then the catalog reference report, which + // `catalog-reference-report.test.ts` drives. Only the first runs here. const handlers = world.hooks.get('kernel:bootstrapped') ?? []; - expect(handlers.length).toBeGreaterThanOrEqual(2); + expect(handlers).toHaveLength(2); const errorsBefore = world.pluginLogger.error.mock.calls.length; const warningsBefore = world.pluginLogger.warn.mock.calls.length; await handlers[0](); diff --git a/packages/plugins/plugin-security/src/index.ts b/packages/plugins/plugin-security/src/index.ts index 9d7ca1fc7fa..5db58b89387 100644 --- a/packages/plugins/plugin-security/src/index.ts +++ b/packages/plugins/plugin-security/src/index.ts @@ -122,7 +122,8 @@ export type { } from './packaged-permission-set-lock.js'; // [commit 5619aace3 — maintainer ruling 2026-08-25, option B] The lock's METADATA-door // registration: the pre-persistence authoring-gate seam consults the SAME -// classifier and throws the SAME error classes as the data door above. +// classifier and throws the SAME error classes exported above. Since C3 stage 8 +// retired the `sys_permission_set` data door, it is the lock's one door. export { registerPackagedPermissionSetLockGate } from './packaged-permission-set-lock-gate.js'; export type { PermissionSetLockGateContext } from './packaged-permission-set-lock-gate.js'; export { diff --git a/packages/plugins/plugin-security/src/object-posture-gate.ts b/packages/plugins/plugin-security/src/object-posture-gate.ts index c60d278634f..5755a5cfa14 100644 --- a/packages/plugins/plugin-security/src/object-posture-gate.ts +++ b/packages/plugins/plugin-security/src/object-posture-gate.ts @@ -133,8 +133,7 @@ export function objectPostureGate(ctx: ObjectPostureGateContext): void { } /** - * Wire the gate onto the protocol. Feature-detected like - * `registerPermissionSetProjection` — a protocol that predates + * Wire the gate onto the protocol. Feature-detected — a protocol that predates * `registerAuthoringGate` (older embeddings, unit-test stubs) simply keeps * the legacy behavior (CLI lint remains the only guard there). Returns * `true` when wired. diff --git a/packages/plugins/plugin-security/src/objects/sys-audience-binding-suggestion.object.ts b/packages/plugins/plugin-security/src/objects/sys-audience-binding-suggestion.object.ts index 366998cec83..c61d0bdc52c 100644 --- a/packages/plugins/plugin-security/src/objects/sys-audience-binding-suggestion.object.ts +++ b/packages/plugins/plugin-security/src/objects/sys-audience-binding-suggestion.object.ts @@ -116,7 +116,7 @@ export const SysAudienceBindingSuggestion = ObjectSchema.create({ // `sys_permission_set.name` is ever widened, the pin beside this file is // where this bound is re-derived rather than rediscovered on MySQL. maxLength: 100, - description: 'Name of the suggested permission set (resolved against sys_permission_set at confirm time).', + description: 'Name of the suggested permission set (resolved in the security catalog at confirm time).', }), anchor: Field.select({ diff --git a/packages/plugins/plugin-security/src/objects/sys-user-position.object.ts b/packages/plugins/plugin-security/src/objects/sys-user-position.object.ts index ab2b0c0380a..7d2cb6169a6 100644 --- a/packages/plugins/plugin-security/src/objects/sys-user-position.object.ts +++ b/packages/plugins/plugin-security/src/objects/sys-user-position.object.ts @@ -41,7 +41,7 @@ export const SysUserPosition = ObjectSchema.create({ // default is full CRUD, so no `userActions` block is needed — the affordance is // a declaration only; the DelegatedAdminGate is the authz. managedBy: 'system-data', - description: 'Assigns a position (sys_position.name) to a user. Platform-owned (ADR-0057 D4, ADR-0090 D3).', + description: 'Assigns a position, by name, to a user. Platform-owned (ADR-0057 D4, ADR-0090 D3).', // [ADR-0079] The record title is `display_title`, a text formula over the // same two columns `titleFormat` names. With no pointer declared, the // registry's designate-only pass stamped `nameField: 'id'` (the first @@ -89,7 +89,7 @@ export const SysUserPosition = ObjectSchema.create({ // positions the security catalog serves and stores the chosen NAME. // Falls back to a text input where the widget is not registered. widget: 'position-ref', - description: 'Position machine name (references sys_position.name).', + description: 'Position machine name, resolved in the security catalog.', }), business_unit_id: Field.lookup('sys_business_unit', { diff --git a/packages/plugins/plugin-security/src/packaged-permission-set-lock-gate.ts b/packages/plugins/plugin-security/src/packaged-permission-set-lock-gate.ts index a6de3ffce58..63a7f37f968 100644 --- a/packages/plugins/plugin-security/src/packaged-permission-set-lock-gate.ts +++ b/packages/plugins/plugin-security/src/packaged-permission-set-lock-gate.ts @@ -7,8 +7,9 @@ * * `packaged-permission-set-lock.ts` refuses a write that targets a * package-declared permission set. Until this file existed it had exactly one - * enforcement point: the `sys_permission_set` DATA door - * (`createPermissionSetWriteThrough`, `permission-set-projection.ts`). The + * enforcement point: the `sys_permission_set` DATA door's write-through + * (retired with that object in C3 stages 7a and 8; this gate is now the lock's + * only enforcement point). The * metadata protocol's pre-persistence authoring-gate seam (ADR-0094 addendum; * `registerAuthoringGate`) carried an `'object'` registration only — so a * metadata-door write targeting the same package-declared set reached @@ -20,7 +21,7 @@ * * - ⭐ ONE SPELLING (Prime Directive #8). The gate calls * {@link assertPermissionSetNotPackageDeclared} — the same assertion the - * data door calls, consulting the same {@link classifyPackagedPermissionSet} + * retired data door called, consulting the same {@link classifyPackagedPermissionSet} * classifier and throwing the same error classes * (`PackagedPermissionSetLockedError`, and the fail-closed * `PackagedPermissionSetProvenanceUnknownError` when no provenance source @@ -85,10 +86,9 @@ export function registerPackagedPermissionSetLockGate(protocol: any, ql: any): b probe = { status: 'failed', reason: String((e as Error)?.message ?? e) }; } } - // `'update'` on purpose: a metadata-door save that targets a - // package-declared name is an override of the shipped base whether or not - // an overlay row exists yet, so the remedy the refusal teaches is the - // clone path, not "choose a different name". + // A metadata-door save that targets a package-declared name is an + // override of the shipped base whether or not an overlay row exists yet, + // so the remedy the refusal teaches is the clone path. assertPermissionSetNotPackageDeclared(ctx.name, ql, 'update', probe); }); return true; 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 e31b226f69d..059dd1c5d3d 100644 --- a/packages/plugins/plugin-security/src/packaged-permission-set-lock.ts +++ b/packages/plugins/plugin-security/src/packaged-permission-set-lock.ts @@ -32,16 +32,16 @@ * by the code written to stop silent forks. * * ⛔ So the answer is NOT taken from a name-keyed page over - * `sys_permission_set`. The batched existence oracle - * (`seed-name-lookup.ts`'s `buildExistingByName`) capped its UNSCOPED page at - * `limit: names.length`, which truncates the moment one name can carry more - * than one row — and a truncated page read as `absent`. Commit e1d773eb7 has since - * repaired that: the page budget is measured (one row more than it will hold is - * requested, so overflow is DETECTED) and an overflowing page degrades to the - * per-item read instead of answering. ⚠️ That does not make this oracle safe to - * ask HERE, and the reason is worth stating rather than re-deriving: unscoped, - * `buildExistingByName` answers with the FIRST row by id for a name, so on a - * name several organizations hold it can answer with somebody else's row — a + * `sys_permission_set`. The batched existence oracle the catalog seeders read + * (`buildExistingByName`, retired with them in C3 stage 7a) capped its UNSCOPED page at + * `limit: names.length`, which truncated the moment one name could carry more + * than one row — and a truncated page read as `absent`. Commit e1d773eb7 + * repaired that: the page budget was measured (one row more than it would hold + * was requested, so overflow was DETECTED) and an overflowing page degraded to + * the per-item read instead of answering. ⚠️ That never made such an oracle + * safe to ask HERE, and the reason is worth stating rather than re-deriving: + * unscoped, it answered with the FIRST row by id for a name, so on a name + * several organizations held it could answer with somebody else's row — a * different wrong answer to the same question. This lock's read must have no * page in it at all. * @@ -89,11 +89,10 @@ import { isTenantAuthored } from '@objectstack/metadata-core'; /** * ⛔ This module imports NOTHING from the rest of the plugin, and that is - * structural rather than stylistic: `permission-set-projection.ts` imports - * THIS module (its write door is the lock's only enforcement point), so any - * import back the other way — directly, or through - * `bootstrap-declared-permissions.ts`, which itself imports the projection - * module — would close a cycle around a write door. + * structural rather than stylistic: the write doors import THIS module (the + * metadata door's gate, `packaged-permission-set-lock-gate.ts`, and the + * catalog echo, `security-catalog-echo.ts`), so any import back the other way + * could close a cycle around a write door. * * ⚠️ In particular it does not reuse {@link readDeclared}, and that is a * deliberate strictness difference rather than a second spelling: `readDeclared` @@ -105,11 +104,11 @@ import { isTenantAuthored } from '@objectstack/metadata-core'; */ /** - * The marker `permission-set-projection.ts` stamps on bodies it syncs into the - * metadata manager's in-memory registry, so its own copy of an overlay body can - * never masquerade as a shipped artifact. + * The marker the catalog echo (`security-catalog-echo.ts`) stamps on bodies it + * syncs into the metadata manager's in-memory registry, so its own copy of an + * overlay body can never masquerade as a shipped artifact. * - * It lives HERE, and the projection module imports it from here, so the + * It lives HERE, and the echo imports it from here, so the * constant has exactly one home — a lock that decides "packaged" partly by the * absence of this marker must not be reading a second, drifting spelling of it. */ @@ -118,8 +117,8 @@ export const ENV_PROJECTION_MARKER = '_envProjection'; /** * The `_packageId` value the metadata layer stamps on a RUNTIME SHADOW — an * item hydrated into the registry from a `sys_metadata` overlay row rather - * than shipped as an artifact. `readDeclaredBody` in the projection module - * excludes it for the same reason this does: a shadow is the overlay, not the + * than shipped as an artifact. The retired projection module's + * `readDeclaredBody` excluded it for the same reason this does: a shadow is the overlay, not the * declaration it shadows, and reading it as "declared" would lock a set that * no package ships. */ @@ -191,8 +190,7 @@ function declaredPackageIdOf(item: any, name: string): string | null { * one source this repo already calls "package-declared". In-memory, so it * cannot truncate; * 2. the layered read's `code` layer, for kernels that expose no readable - * SchemaRegistry (minimal embeddings; the same fallback - * `projectPermissionMutation` already uses). One name, one envelope — no + * SchemaRegistry (minimal embeddings). One name, one envelope — no * page and no cap here either. */ export function classifyPackagedPermissionSet( @@ -284,15 +282,11 @@ export function classifyPackagedPermissionSet( * English, like every platform refusal: there is no localization path for a * thrown `userMessage`, and these texts do not invent one. */ -const LOCKED_UPDATE_USER_MESSAGE = +const LOCKED_USER_MESSAGE = `This permission set is provided by an installed package and can't be edited here. Clone it instead ` + `with the Clone action, then edit the clone. The clone is your organization's own permission set, ` + `and package upgrades keep reaching the original.`; -const LOCKED_INSERT_USER_MESSAGE = - `This name belongs to a permission set provided by an installed package. Choose a different name for ` - + `your permission set, or clone the packaged one with the Clone action and edit the clone.`; - const PROVENANCE_UNKNOWN_USER_MESSAGE = `This permission set can't be saved right now, because the system couldn't confirm whether an installed ` + `package provides it. Try again in a moment. To customize a permission set that a package provides, ` @@ -321,10 +315,16 @@ const PROVENANCE_UNKNOWN_USER_MESSAGE = * `status` AND `statusCode`: the two transports read different property names * (`mapDataError` passes a domain error through on `.status`; the runtime * dispatcher's `errorFromThrown` reads `.status` then falls back to - * `.statusCode`), and this throws on the DATA path, which reaches both. + * `.statusCode`), and a door's refusal can reach either. + * + * `userMessage` carries the same guidance for the end user — see the note on + * the texts above. * - * `userMessage` carries the same guidance for the end user, per operation — - * see the note on the texts above. + * One remedy, the clone: the one door that throws it (the metadata door's + * authoring gate) judges an override of the shipped base. The "choose a + * different name" variant the retired data door's create raised is gone with + * that door (C3 stage 8). `operation` stays in the published signature and + * no longer selects the text. */ export class PackagedPermissionSetLockedError extends Error { readonly code = 'NOT_OVERRIDABLE'; @@ -332,20 +332,17 @@ export class PackagedPermissionSetLockedError extends Error { readonly statusCode = 403; /** The guidance addressed to the end user; `message` stays the diagnostic. */ readonly userMessage: string; - constructor(name: string, packageId: string, operation: 'insert' | 'update') { + constructor(name: string, packageId: string, _operation: 'insert' | 'update') { super( `[Security] Permission set '${name}' is declared by package '${packageId}' and is locked in this ` + `environment — editing it here would silently fork it from the package, and the fork would win over ` + `every future upgrade with no signal. ` - + (operation === 'insert' - ? `Choose a different name for your set, or clone '${name}' (the "Clone" action on the permission ` - + `set) and edit the clone.` - : `Clone it instead (the "Clone" action on the permission set, or POST ` - + `/api/v1/security/permission-sets/${name}/clone with a new name) and edit the clone — the clone is your organization's own set, and upgrades keep ` - + `flowing to '${name}' untouched.`), + + `Clone it instead (the "Clone" action on the permission set, or POST ` + + `/api/v1/security/permission-sets/${name}/clone with a new name) and edit the clone — the clone is your organization's own set, and upgrades keep ` + + `flowing to '${name}' untouched.`, ); this.name = 'PackagedPermissionSetLockedError'; - this.userMessage = operation === 'insert' ? LOCKED_INSERT_USER_MESSAGE : LOCKED_UPDATE_USER_MESSAGE; + this.userMessage = LOCKED_USER_MESSAGE; } } diff --git a/packages/plugins/plugin-security/src/packaged-permission-set-overlay-detection.test.ts b/packages/plugins/plugin-security/src/packaged-permission-set-overlay-detection.test.ts index deca1cefeeb..efcfa2a1b5f 100644 --- a/packages/plugins/plugin-security/src/packaged-permission-set-overlay-detection.test.ts +++ b/packages/plugins/plugin-security/src/packaged-permission-set-overlay-detection.test.ts @@ -142,8 +142,8 @@ describe('pin 4 — the detection reading NAMES the forked sets', () => { }); it('⭐ finds the CONFOUNDED row the card measured — managed_by wrong AND an overlay present', async () => { - // The field-reported shape. `customized` is forced false here by - // `upsertEnvPermissionSet`'s `existing.managed_by === 'package'` gate, so a + // The field-reported shape. `customized` was forced false here by the + // retired projector's `existing.managed_by === 'package'` gate, so a // reading that consulted the column would report zero forks on precisely // the environment that had one — for two weeks, measured. const a = declaredSet(); @@ -180,11 +180,8 @@ describe('pin 4 — the detection reading NAMES the forked sets', () => { }); it('an ORG-scoped overlay row is out of scope, the same as everywhere else in this family', async () => { - // `reconcilePermissionSetProjection` and `permission-set-drift.ts` both - // consider env-wide overlays only (#10103 residue, deliberately out of - // scope). A reading that answered a different question from the reconciler - // it is reporting on would send an operator to a row the reconciler never - // touches. + // Env-wide overlays only (#10103 residue, deliberately out of scope), the + // boundary the retired reconciler and drift diagnostic drew. const a = declaredSet(); const ql = makeQl([a]); ql.permRows.push(rowFor(a)); diff --git a/packages/plugins/plugin-security/src/packaged-permission-set-overlay-detection.ts b/packages/plugins/plugin-security/src/packaged-permission-set-overlay-detection.ts index f5229fbd0de..993aa5f936e 100644 --- a/packages/plugins/plugin-security/src/packaged-permission-set-overlay-detection.ts +++ b/packages/plugins/plugin-security/src/packaged-permission-set-overlay-detection.ts @@ -32,7 +32,7 @@ * * ## ⛔ It does not consult `customized`, and that is measured, not stylistic * - * `upsertEnvPermissionSet` computes the flag as + * The retired projector (`upsertEnvPermissionSet`, C3 stage 7a) computed the flag as * `existing.managed_by === 'package' ? !!customized : false`. On the exact * field-reported shape — a genuinely package-declared set whose row's * `managed_by` predates provenance tracking (`permission-set-drift.ts`'s @@ -49,10 +49,8 @@ * ## Scope, stated rather than assumed * * - ENV-WIDE overlays only (`sys_metadata.organization_id IS NULL`), the same - * boundary `reconcilePermissionSetProjection` and `permission-set-drift.ts` - * draw (#10103 residue, deliberately out of scope). A reading that answered - * a different question from the reconciler it reports on would send an - * operator to a row the reconciler never touches; + * boundary the retired reconciler (`reconcilePermissionSetProjection`) and + * drift diagnostic drew (#10103 residue, deliberately out of scope); * - "package-declared" is decided by {@link classifyPackagedPermissionSet} — * the engine SchemaRegistry, the same source the write-door lock uses, so * the reading and the lock can never disagree about which sets are locked; @@ -84,11 +82,9 @@ async function tryFind(ql: any, object: string, where: any, limit: number): Prom } /** - * Page cap for the `sys_metadata` overlay sweep — the same value - * `reconcilePermissionSetProjection` and `computePermissionSetDriftDiagnostics` - * use for the identical read. Shared deliberately: three readings of the same - * rows answering under three different caps would disagree about the same - * environment. + * Page cap for the `sys_metadata` overlay sweep — the value the retired + * reconciler and drift diagnostic used for the identical read, kept so this + * reading answers under the cap it always has. */ export const OVERLAY_PAGE_LIMIT = 1000; 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 10d20d61bcb..c83d662226d 100644 --- a/packages/plugins/plugin-security/src/permission-set-name-collision.ts +++ b/packages/plugins/plugin-security/src/permission-set-name-collision.ts @@ -56,10 +56,8 @@ /** * The minimal sink this module reports through. * - * Structurally satisfied by both `ProjectionLogger` and the catalog's - * `SeedLogger` — declared here rather than imported so this module stays a leaf - * and `permission-set-projection.ts` can name the diagnostic type without a - * cycle. `warn` is non-optional for the #9754 reason its two siblings give: a + * Structurally satisfied by the catalog's `SeedLogger` — declared here rather + * than imported so this module stays a leaf. `warn` is non-optional for the #9754 reason its two siblings give: a * fallback channel that may itself be absent is not a fallback. */ export interface CollisionReportSink { diff --git a/packages/plugins/plugin-security/src/security-plugin.test.ts b/packages/plugins/plugin-security/src/security-plugin.test.ts index 9640b9d67fb..6476c519dbc 100644 --- a/packages/plugins/plugin-security/src/security-plugin.test.ts +++ b/packages/plugins/plugin-security/src/security-plugin.test.ts @@ -2360,586 +2360,6 @@ describe('SecurityPlugin', () => { await expect(h.run(opCtx)).resolves.toBeDefined(); }); }); - - // ── ADR-0086 P2 (块2) — two-doors data-layer write gate ──────────────── - // A `managed_by:'package'` sys_permission_set row is owned by the package - // door; the admin data-plane write path must refuse to mutate it, even for a - // superuser (modifyAllRecords). System/boot writes carry isSystem and never - // reach the gate. - describe('two-doors write gate (sys_permission_set managed_by:package)', () => { - // Superuser set — proves the gate is NOT grant-gated (blocks even - // modifyAllRecords) and carries no RLS so the pre-image check is a no-op. - const adminSet: PermissionSet = { - name: 'admin_full_access', label: 'Admin', - objects: { '*': { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true, modifyAllRecords: true } }, - } as any; - const adminCtx = { userId: 'admin1', tenantId: 'org-1', positions: [], permissions: ['admin_full_access'] }; - - const runGate = async (opCtx: any, findOneImpl?: (q: any) => any) => { - const plugin = new SecurityPlugin({ fallbackPermissionSet: 'admin_full_access' }); - const harness = makeMiddlewareCtx({ - permissionSets: [adminSet], - objectFields: ['id', 'name', 'managed_by', 'package_id'], - ...(findOneImpl ? { findOneImpl } : {}), - }); - await plugin.init(harness.ctx); - await plugin.start(harness.ctx); - return harness.run(opCtx); - }; - - it('PASSES an admin update of a package-managed set at THIS gate (ADR-0094: the refusal is the write-through producer\'s, not this gate\'s)', async () => { - // update/delete on a package row are not refused at this gate — the - // ADR-0094 write-through downstream translates them into a metadata - // write, and that producer decides. Since ADR-0094 D5-R (commit - // ee58392e1) the answer for a CODE-DECLARED set is 403 NOT_OVERRIDABLE, - // so "the write-through turns it into an env overlay" is no longer why - // this passes; it passes because the gate's job is forging provenance, - // not overridability. The refusal is covered in - // permission-set-projection.test.ts. - const opCtx: any = { - object: 'sys_permission_set', operation: 'update', - data: { id: 'ps_pkg', label: 'customize' }, options: { where: { id: 'ps_pkg' } }, - context: adminCtx, - }; - await expect( - runGate(opCtx, () => ({ id: 'ps_pkg', name: 'crm_sales_rep', managed_by: 'package', package_id: 'com.example.crm' })), - ).resolves.toBeDefined(); - }); - - it('still DENIES lifecycle ops with no overlay translation (purge) on a package-managed set', async () => { - const opCtx: any = { - object: 'sys_permission_set', operation: 'purge', - options: { where: { id: 'ps_pkg' } }, - context: adminCtx, - }; - await expect( - runGate(opCtx, () => ({ id: 'ps_pkg', name: 'crm_sales_rep', managed_by: 'package', package_id: 'com.example.crm' })), - ).rejects.toMatchObject({ name: 'PermissionDeniedError' }); - }); - - it('ALLOWS an admin update of an env-authored set (managed_by:user)', async () => { - const opCtx: any = { - object: 'sys_permission_set', operation: 'update', - data: { id: 'ps_env', label: 'renamed' }, options: { where: { id: 'ps_env' } }, - context: adminCtx, - }; - await expect( - runGate(opCtx, () => ({ id: 'ps_env', name: 'my_custom', managed_by: 'user' })), - ).resolves.toBeDefined(); - }); - - it('DENIES an admin-door insert that forges package provenance', async () => { - const opCtx: any = { - object: 'sys_permission_set', operation: 'insert', - data: { name: 'forged', managed_by: 'package', package_id: 'com.example.crm' }, - context: adminCtx, - }; - await expect(runGate(opCtx)).rejects.toMatchObject({ name: 'PermissionDeniedError' }); - }); - - it('DENIES a bulk/ARRAY insert that forges package provenance on any element', async () => { - const opCtx: any = { - object: 'sys_permission_set', operation: 'insert', - data: [ - { name: 'ok_custom' }, - { name: 'forged', managed_by: 'package', package_id: 'com.example.crm' }, - ], - context: adminCtx, - }; - await expect(runGate(opCtx)).rejects.toMatchObject({ name: 'PermissionDeniedError' }); - }); - - it('DENIES an update that RE-BADGES an env row as package-managed (update-to-forge)', async () => { - const opCtx: any = { - object: 'sys_permission_set', operation: 'update', - data: { id: 'ps_env', managed_by: 'package', package_id: 'com.example.crm' }, - options: { where: { id: 'ps_env' } }, - context: adminCtx, - }; - // Even though the EXISTING row is env-owned, the payload forges provenance. - await expect( - runGate(opCtx, () => ({ id: 'ps_env', name: 'my_custom', managed_by: 'user' })), - ).rejects.toMatchObject({ name: 'PermissionDeniedError' }); - }); - - it('DENIES even a principal-less lifecycle write to a package row (gate is before the fall-open)', async () => { - const opCtx: any = { - object: 'sys_permission_set', operation: 'purge', - options: { where: { id: 'ps_pkg' } }, - context: {}, // no roles, no permissions, no userId, not isSystem - }; - await expect( - runGate(opCtx, () => ({ id: 'ps_pkg', managed_by: 'package', package_id: 'com.example.crm' })), - ).rejects.toMatchObject({ name: 'PermissionDeniedError' }); - }); - - it('ALLOWS a normal admin-door insert (no forged provenance)', async () => { - const opCtx: any = { - object: 'sys_permission_set', operation: 'insert', - data: { name: 'my_custom', label: 'Custom' }, - context: adminCtx, - }; - await expect(runGate(opCtx)).resolves.toBeDefined(); - }); - - it('DENIES a filter LIFECYCLE write whose filter matches a package-managed row', async () => { - const opCtx: any = { - object: 'sys_permission_set', operation: 'purge', - options: { where: { active: true } }, // no single id → filter path - context: adminCtx, - }; - await expect( - // the gate probes for a package row within the filter → found → deny - runGate(opCtx, () => ({ id: 'ps_pkg', managed_by: 'package' })), - ).rejects.toMatchObject({ name: 'PermissionDeniedError' }); - }); - - it('ALLOWS a filter update (bulk edits route through the write-through downstream)', async () => { - const opCtx: any = { - object: 'sys_permission_set', operation: 'update', - data: { label: 'bulk-rename' }, options: { where: { managed_by: 'user' } }, - context: adminCtx, - }; - await expect(runGate(opCtx, () => null)).resolves.toBeDefined(); - }); - - it('lets system/boot writes through (isSystem bypass) even on a package row', async () => { - const opCtx: any = { - object: 'sys_permission_set', operation: 'update', - data: { id: 'ps_pkg' }, options: { where: { id: 'ps_pkg' } }, - context: { isSystem: true }, - }; - await expect( - runGate(opCtx, () => ({ id: 'ps_pkg', managed_by: 'package', package_id: 'com.example.crm' })), - ).resolves.toBeDefined(); - }); - }); - - // ── ADR-0066 / #2918 — built-in row-write guardrail (sys_position / sys_capability) ── - // Platform/application-managed asset rows are not the admin's to delete or - // rewrite. Unlike sys_permission_set these objects have no ADR-0094 overlay - // write-through, so the refusal must hold for update/delete at this gate. - // Matrix: non-admin/admin × managed/admin-authored × delete/update. - describe('system-row write gate (sys_position / sys_capability provenance)', () => { - const adminSet: PermissionSet = { - name: 'admin_full_access', label: 'Admin', - objects: { '*': { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true, modifyAllRecords: true } }, - } as any; - const adminCtx = { userId: 'admin1', tenantId: 'org-1', positions: [], permissions: ['admin_full_access'] }; - - const runGate = async (opCtx: any, findOneImpl?: (q: any) => any) => { - const plugin = new SecurityPlugin({ fallbackPermissionSet: 'admin_full_access' }); - const harness = makeMiddlewareCtx({ - permissionSets: [adminSet], - objectFields: ['id', 'name', 'label', 'managed_by'], - ...(findOneImpl ? { findOneImpl } : {}), - }); - await plugin.init(harness.ctx); - await plugin.start(harness.ctx); - return harness.run(opCtx); - }; - - it('DENIES deleting a platform-managed position (sys_position managed_by:platform)', async () => { - const opCtx: any = { - object: 'sys_position', operation: 'delete', - options: { where: { id: 'pos_admin' } }, context: adminCtx, - }; - await expect( - runGate(opCtx, () => ({ id: 'pos_admin', name: 'platform_admin', managed_by: 'platform' })), - ).rejects.toMatchObject({ name: 'PermissionDeniedError' }); - }); - - it('DENIES updating a package-declared position (sys_position managed_by:package)', async () => { - const opCtx: any = { - object: 'sys_position', operation: 'update', - data: { id: 'pos_sales', label: 'renamed' }, options: { where: { id: 'pos_sales' } }, - context: adminCtx, - }; - await expect( - runGate(opCtx, () => ({ id: 'pos_sales', name: 'sales_rep', managed_by: 'package' })), - ).rejects.toMatchObject({ name: 'PermissionDeniedError' }); - }); - - // [#2926 ①] Regression: the A4 rename (system→platform, config→package) once - // disarmed this gate because the provenance map only knew the legacy values. - // Rows the boot normalizer has not healed yet must STAY protected too. - it('KEEPS denying legacy-vocab managed positions (managed_by:system, pre-normalizer rows)', async () => { - const opCtx: any = { - object: 'sys_position', operation: 'delete', - options: { where: { id: 'pos_legacy' } }, context: adminCtx, - }; - await expect( - runGate(opCtx, () => ({ id: 'pos_legacy', name: 'everyone', managed_by: 'system' })), - ).rejects.toMatchObject({ name: 'PermissionDeniedError' }); - }); - - it('KEEPS denying legacy-vocab managed positions (managed_by:config, pre-normalizer rows)', async () => { - const opCtx: any = { - object: 'sys_position', operation: 'update', - data: { id: 'pos_legacy2', label: 'renamed' }, options: { where: { id: 'pos_legacy2' } }, - context: adminCtx, - }; - await expect( - runGate(opCtx, () => ({ id: 'pos_legacy2', name: 'sales_rep', managed_by: 'config' })), - ).rejects.toMatchObject({ name: 'PermissionDeniedError' }); - }); - - it('DENIES deleting a platform-owned capability (sys_capability managed_by:platform)', async () => { - const opCtx: any = { - object: 'sys_capability', operation: 'delete', - options: { where: { id: 'cap_plat' } }, context: adminCtx, - }; - await expect( - runGate(opCtx, () => ({ id: 'cap_plat', name: 'manage_users', managed_by: 'platform' })), - ).rejects.toMatchObject({ name: 'PermissionDeniedError' }); - }); - - it('DENIES updating a package-owned capability (sys_capability managed_by:package)', async () => { - const opCtx: any = { - object: 'sys_capability', operation: 'update', - data: { id: 'cap_pkg', label: 'renamed' }, options: { where: { id: 'cap_pkg' } }, - context: adminCtx, - }; - await expect( - runGate(opCtx, () => ({ id: 'cap_pkg', name: 'crm.export', managed_by: 'package' })), - ).rejects.toMatchObject({ name: 'PermissionDeniedError' }); - }); - - it('ALLOWS deleting an admin-authored position (sys_position managed_by:admin)', async () => { - const opCtx: any = { - object: 'sys_position', operation: 'delete', - options: { where: { id: 'pos_user' } }, context: adminCtx, - }; - await expect( - runGate(opCtx, () => ({ id: 'pos_user', name: 'my_team', managed_by: 'admin' })), - ).resolves.toBeDefined(); - }); - - it('ALLOWS deleting a legacy admin-authored position (managed_by:user, pre-normalizer)', async () => { - const opCtx: any = { - object: 'sys_position', operation: 'delete', - options: { where: { id: 'pos_user_legacy' } }, context: adminCtx, - }; - await expect( - runGate(opCtx, () => ({ id: 'pos_user_legacy', name: 'my_team', managed_by: 'user' })), - ).resolves.toBeDefined(); - }); - - it('ALLOWS updating an admin-authored position with NO managed_by (tenant-created)', async () => { - const opCtx: any = { - object: 'sys_position', operation: 'update', - data: { id: 'pos_user', label: 'renamed' }, options: { where: { id: 'pos_user' } }, - context: adminCtx, - }; - await expect( - runGate(opCtx, () => ({ id: 'pos_user', name: 'my_team', managed_by: null })), - ).resolves.toBeDefined(); - }); - - it('ALLOWS updating an admin-authored capability (sys_capability managed_by:admin)', async () => { - const opCtx: any = { - object: 'sys_capability', operation: 'update', - data: { id: 'cap_admin', label: 'renamed' }, options: { where: { id: 'cap_admin' } }, - context: adminCtx, - }; - await expect( - runGate(opCtx, () => ({ id: 'cap_admin', name: 'my_cap', managed_by: 'admin' })), - ).resolves.toBeDefined(); - }); - - it('DENIES an admin-door insert that forges platform provenance (sys_position managed_by:platform)', async () => { - const opCtx: any = { - object: 'sys_position', operation: 'insert', - data: { name: 'forged', managed_by: 'platform' }, context: adminCtx, - }; - await expect(runGate(opCtx)).rejects.toMatchObject({ name: 'PermissionDeniedError' }); - }); - - it('DENIES an admin-door insert that forges LEGACY platform provenance (managed_by:system)', async () => { - const opCtx: any = { - object: 'sys_position', operation: 'insert', - data: { name: 'forged_legacy', managed_by: 'system' }, context: adminCtx, - }; - await expect(runGate(opCtx)).rejects.toMatchObject({ name: 'PermissionDeniedError' }); - }); - - it('DENIES a bulk/ARRAY insert that forges package provenance on any element (sys_capability)', async () => { - const opCtx: any = { - object: 'sys_capability', operation: 'insert', - data: [ - { name: 'ok_admin_cap' }, - { name: 'forged', managed_by: 'package' }, - ], - context: adminCtx, - }; - await expect(runGate(opCtx)).rejects.toMatchObject({ name: 'PermissionDeniedError' }); - }); - - it('DENIES an update that RE-BADGES an admin row as package-managed (update-to-forge)', async () => { - const opCtx: any = { - object: 'sys_capability', operation: 'update', - data: { id: 'cap_admin', managed_by: 'package' }, options: { where: { id: 'cap_admin' } }, - context: adminCtx, - }; - await expect( - runGate(opCtx, () => ({ id: 'cap_admin', name: 'my_cap', managed_by: 'admin' })), - ).rejects.toMatchObject({ name: 'PermissionDeniedError' }); - }); - - it('ALLOWS a normal admin-door capability insert (no forged provenance)', async () => { - const opCtx: any = { - object: 'sys_capability', operation: 'insert', - data: { name: 'my_cap', label: 'Custom', managed_by: 'admin' }, - context: adminCtx, - }; - await expect(runGate(opCtx)).resolves.toBeDefined(); - }); - - it('DENIES a filter DELETE whose filter matches a platform/package-managed row', async () => { - const opCtx: any = { - object: 'sys_position', operation: 'delete', - options: { where: { active: false } }, // no single id → filter path - context: adminCtx, - }; - await expect( - runGate(opCtx, () => ({ id: 'pos_admin', managed_by: 'platform' })), - ).rejects.toMatchObject({ name: 'PermissionDeniedError' }); - }); - - it('ALLOWS a filter delete that matches only admin-authored rows (probe finds none)', async () => { - const opCtx: any = { - object: 'sys_position', operation: 'delete', - options: { where: { managed_by: 'admin' } }, - context: adminCtx, - }; - // The managed-row probe finds nothing → the write proceeds. - await expect(runGate(opCtx, () => null)).resolves.toBeDefined(); - }); - - it('DENIES even a principal-less delete of a managed row (gate is before the fall-open)', async () => { - const opCtx: any = { - object: 'sys_position', operation: 'delete', - options: { where: { id: 'pos_admin' } }, - context: {}, // no roles, no permissions, no userId, not isSystem - }; - await expect( - runGate(opCtx, () => ({ id: 'pos_admin', name: 'platform_admin', managed_by: 'platform' })), - ).rejects.toMatchObject({ name: 'PermissionDeniedError' }); - }); - - it('lets system/boot writes through (isSystem bypass) even on a platform-managed position', async () => { - const opCtx: any = { - object: 'sys_position', operation: 'update', - data: { id: 'pos_admin', label: 'reseed' }, options: { where: { id: 'pos_admin' } }, - context: { isSystem: true }, - }; - await expect( - runGate(opCtx, () => ({ id: 'pos_admin', name: 'platform_admin', managed_by: 'platform' })), - ).resolves.toBeDefined(); - }); - }); - - // ── #8552 / ADR-0066 D1 — curated capability-name refusal (create + rename) ── - // A `sys_capability` name in PLATFORM_CAPABILITY_NAMES is the platform's: - // Setup may neither CREATE a row with it nor RENAME a row TO it — refused at - // this write door with an error naming the colliding curated name, never - // answered 200. Existing colliding rows stay the operator's (ruling option - // 1): a payload repeating a row's own curated name is not a rename and - // passes, and renaming AWAY stays open. The load-bearing half of this suite - // is the POSITIVE controls — a refusal too broad passes every "it refuses" - // assertion, so non-curated authoring is pinned open in BOTH deployment - // shapes (org-scoped, and the community NULL-bucket shape with no tenant). - describe('curated capability-name write gate (#8552, sys_capability create/rename)', () => { - const adminSet: PermissionSet = { - name: 'admin_full_access', label: 'Admin', - objects: { '*': { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true, modifyAllRecords: true } }, - } as any; - // Enterprise / org-scoped shape: the caller carries an active organization. - const orgCtx = { userId: 'admin1', tenantId: 'org-1', positions: [], permissions: ['admin_full_access'] }; - // Community NULL-bucket shape: no `@objectstack/organizations` stamper, no - // tenantId — every Setup-authored row lands in the NULL-organization - // bucket. This is the default community deployment, not an edge case. - const communityCtx = { userId: 'admin1', positions: [], permissions: ['admin_full_access'] }; - - const runGate = async (opCtx: any, findOneImpl?: (q: any) => any) => { - const plugin = new SecurityPlugin({ fallbackPermissionSet: 'admin_full_access' }); - const harness = makeMiddlewareCtx({ - permissionSets: [adminSet], - objectFields: ['id', 'name', 'label', 'managed_by', 'active'], - ...(findOneImpl ? { findOneImpl } : {}), - }); - await plugin.init(harness.ctx); - await plugin.start(harness.ctx); - return harness.run(opCtx); - }; - - // The refusal envelope, asserted whole (code AND status, plus the located - // name) — `rejects.toThrow()` alone would stay green on any refusal from - // any gate, which is exactly the vacuity this suite must not have. - const curatedRefusal = (name: string) => ({ - name: 'PermissionDeniedError', - code: 'PERMISSION_DENIED', - statusCode: 403, - message: expect.stringContaining(`'${name}' is a platform-curated capability name`), - }); - - it('DENIES creating a capability with a curated name (org-scoped shape), naming the collision', async () => { - const opCtx: any = { - object: 'sys_capability', operation: 'insert', - data: { name: 'manage_users', label: 'Mine now' }, context: orgCtx, - }; - await expect(runGate(opCtx)).rejects.toMatchObject(curatedRefusal('manage_users')); - }); - - it('DENIES creating a capability with a curated name (community NULL-bucket shape)', async () => { - const opCtx: any = { - object: 'sys_capability', operation: 'insert', - data: { name: 'setup.write', label: 'Setup Write' }, context: communityCtx, - }; - await expect(runGate(opCtx)).rejects.toMatchObject(curatedRefusal('setup.write')); - }); - - it('DENIES an ARRAY insert when ANY element claims a curated name', async () => { - const opCtx: any = { - object: 'sys_capability', operation: 'insert', - data: [{ name: 'my_cap_a' }, { name: 'manage_sharing' }], - context: orgCtx, - }; - await expect(runGate(opCtx)).rejects.toMatchObject(curatedRefusal('manage_sharing')); - }); - - it('DENIES RENAMING an admin-authored capability TO a curated name (by-id update)', async () => { - const opCtx: any = { - object: 'sys_capability', operation: 'update', - data: { id: 'cap_admin', name: 'manage_users' }, options: { where: { id: 'cap_admin' } }, - context: orgCtx, - }; - await expect( - runGate(opCtx, () => ({ id: 'cap_admin', name: 'my_cap', managed_by: 'admin' })), - ).rejects.toMatchObject(curatedRefusal('manage_users')); - }); - - it('DENIES a rename-to-curated even for a principal-less context (gate is before the fall-open)', async () => { - const opCtx: any = { - object: 'sys_capability', operation: 'update', - data: { id: 'cap_admin', name: 'manage_users' }, options: { where: { id: 'cap_admin' } }, - context: {}, - }; - await expect( - runGate(opCtx, () => ({ id: 'cap_admin', name: 'my_cap', managed_by: 'admin' })), - ).rejects.toMatchObject(curatedRefusal('manage_users')); - }); - - it('FAILS CLOSED when the renamed row cannot be read back (absent pre-image refuses)', async () => { - const opCtx: any = { - object: 'sys_capability', operation: 'update', - data: { id: 'cap_gone', name: 'manage_users' }, options: { where: { id: 'cap_gone' } }, - context: orgCtx, - }; - // Every findOne answers null: no pre-image exists to prove the payload - // repeats the row's own name, so the write is refused. - await expect(runGate(opCtx, () => null)).rejects.toMatchObject(curatedRefusal('manage_users')); - }); - - it('DENIES a FILTER update stamping a curated name onto differently-named rows', async () => { - const opCtx: any = { - object: 'sys_capability', operation: 'update', - data: { name: 'manage_users', active: false }, options: { where: { active: true } }, - context: orgCtx, - }; - // The $ne probe (and only it) finds a row NOT already named - // manage_users inside the write's own filter; the system-row gate's - // managed-row probe finds nothing, so the refusal measured here is this - // gate's, not a neighbour's. - const impl = (q: any) => { - const and = q?.where?.$and; - if (Array.isArray(and) && and.some((c: any) => c && typeof c === 'object' && c.name && typeof c.name === 'object' && '$ne' in c.name)) { - return { id: 'cap_other', name: 'my_cap', managed_by: 'admin' }; - } - return null; - }; - await expect(runGate(opCtx, impl)).rejects.toMatchObject(curatedRefusal('manage_users')); - }); - - it('DENIES a WHOLE-TABLE update stamping a curated name (no filter to scope the rename)', async () => { - const opCtx: any = { - object: 'sys_capability', operation: 'update', - data: { name: 'manage_users' }, context: orgCtx, - }; - await expect(runGate(opCtx, () => null)).rejects.toMatchObject(curatedRefusal('manage_users')); - }); - - // ── positive controls — the load-bearing half ── - - it('ALLOWS creating a NON-curated capability (org-scoped shape)', async () => { - const opCtx: any = { - object: 'sys_capability', operation: 'insert', - data: { name: 'org_export_reports', label: 'Export Reports', managed_by: 'admin' }, - context: orgCtx, - }; - await expect(runGate(opCtx)).resolves.toBeDefined(); - }); - - it('ALLOWS creating a NON-curated capability (community NULL-bucket shape)', async () => { - const opCtx: any = { - object: 'sys_capability', operation: 'insert', - data: { name: 'org_export_reports', label: 'Export Reports', managed_by: 'admin' }, - context: communityCtx, - }; - await expect(runGate(opCtx)).resolves.toBeDefined(); - }); - - it('ALLOWS a full-record update of an EXISTING colliding admin row that repeats its own name (not a rename)', async () => { - const opCtx: any = { - object: 'sys_capability', operation: 'update', - data: { id: 'cap_collide', name: 'manage_users', label: 'Renamed label', active: false }, - options: { where: { id: 'cap_collide' } }, - context: communityCtx, - }; - await expect( - runGate(opCtx, () => ({ id: 'cap_collide', name: 'manage_users', managed_by: 'admin' })), - ).resolves.toBeDefined(); - }); - - it('ALLOWS renaming an EXISTING colliding admin row AWAY from the curated name (the operator remediation)', async () => { - const opCtx: any = { - object: 'sys_capability', operation: 'update', - data: { id: 'cap_collide', name: 'org_manage_users' }, - options: { where: { id: 'cap_collide' } }, - context: communityCtx, - }; - await expect( - runGate(opCtx, () => ({ id: 'cap_collide', name: 'manage_users', managed_by: 'admin' })), - ).resolves.toBeDefined(); - }); - - it('ALLOWS a FILTER update stamping a curated name when it provably renames nothing (probe finds no other-named row)', async () => { - const opCtx: any = { - object: 'sys_capability', operation: 'update', - data: { name: 'manage_users', active: true }, options: { where: { name: 'manage_users' } }, - context: communityCtx, - }; - await expect(runGate(opCtx, () => null)).resolves.toBeDefined(); - }); - - it('lets system/boot writes through (isSystem bypass) — the curated seeder itself is unaffected', async () => { - const opCtx: any = { - object: 'sys_capability', operation: 'insert', - data: { name: 'manage_users', label: 'Manage Users', managed_by: 'platform' }, - context: { isSystem: true }, - }; - await expect(runGate(opCtx)).resolves.toBeDefined(); - }); - - it('does NOT reach sys_position — a position may share a curated capability STRING (scope control)', async () => { - const opCtx: any = { - object: 'sys_position', operation: 'insert', - data: { name: 'manage_users', label: 'A position, not a capability' }, - context: orgCtx, - }; - await expect(runGate(opCtx)).resolves.toBeDefined(); - }); - }); }); // --------------------------------------------------------------------------- describe('PermissionEvaluator', () => { diff --git a/packages/plugins/plugin-security/src/security-plugin.ts b/packages/plugins/plugin-security/src/security-plugin.ts index 23b54065e47..a3c88b7d419 100644 --- a/packages/plugins/plugin-security/src/security-plugin.ts +++ b/packages/plugins/plugin-security/src/security-plugin.ts @@ -108,7 +108,6 @@ import { type ObjectAccessScope, type TenancyPosture, } from '@objectstack/spec/security'; -import { PLATFORM_CAPABILITY_NAMES } from '@objectstack/spec/security'; import { RLS_MEMBERSHIP_RESOLVER_SERVICE, RESERVED_RLS_MEMBERSHIP_KEYS, @@ -583,24 +582,6 @@ interface CbpRelation { omissionRefusedByValidation: boolean; } -/** - * [ADR-0066 / #2918] Provenance spec for the platform/application asset objects - * whose managed rows are write-protected by {@link SecurityPlugin.assertSystemRowWriteGate}. - * - * Both objects share the unified A4 (#2920) `managed_by` vocabulary — a row - * authored by the platform or an application package is not the admin's to - * delete or rewrite: - * • `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: 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. - */ /** * [#7281] The scope `checkAuthoredRowWrite`'s probe read runs under. * @@ -794,28 +775,6 @@ function isTenantWallDenial(filter: Record | null | undefined): return filter === RLS_DENY_FILTER; } -const SYSTEM_ROW_PROVENANCE: Record< - string, - { noun: string; pluralNoun: string; managed: Record } -> = { - sys_position: { - noun: 'position', - pluralNoun: 'positions', - managed: { - platform: 'the platform', - package: 'an application package', - // Legacy pre-A4 values — keep guarded until every store is normalized. - system: 'the platform', - config: 'an application package', - }, - }, - sys_capability: { - noun: 'capability', - pluralNoun: 'capabilities', - managed: { platform: 'the platform', package: 'an application package' }, - }, -}; - /** Normalize a raw object `requiredPermissions` (string[] | per-op map) into buckets. */ function normalizeRequiredPermissions(raw: unknown): NormalizedRequiredPermissions { if (Array.isArray(raw)) { @@ -2462,51 +2421,6 @@ export class SecurityPlugin implements Plugin { ); } - // [ADR-0086 P2 — 块2, evolved by ADR-0094] Two-doors write gate. A - // permission set stamped `managed_by:'package'` is owned by the PACKAGE - // door: its BASELINE is authored in the package and lands via publish - // (块1). The admin door must never FORGE that provenance (insert or - // update), and the lifecycle ops with no overlay translation - // (transfer/restore/purge) stay refused on package rows. Ordinary - // `update`/`delete` on a package row are handled downstream by the - // ADR-0094 write-through, which TRANSLATES them into a metadata write. - // Whether that write is ACCEPTED is ADR-0005's call, not this gate's, - // and since ADR-0094 D5-R (commit ee58392e1 rolled `permission` back to - // `allowEnvironmentOverlay: false`) a CODE-DECLARED set is refused there with - // 403 `NOT_OVERRIDABLE` — the 2026-07-14 "customize / reset via an env - // overlay" direction this comment used to state is RETIRED. - // Placed BEFORE the principal-less refusal and the CRUD check so the - // forging boundary holds even for a principal-less context and a - // superuser with modifyAllRecords. System/boot writes carry `isSystem` - // and already short-circuited the whole middleware above. - await this.assertPackageManagedWriteGate(opCtx); - - // [ADR-0066 / #2918] Built-in row-write guardrail for the platform/app - // ASSET objects sys_position / sys_capability. Like the package gate - // above, an unconditional data-layer boundary: a row authored by the - // platform or an application package (provenance recorded in - // `managed_by`) may not be deleted or rewritten through the admin door. - // Unlike sys_permission_set there is NO ADR-0094 overlay write-through - // for these objects, so the refusal must hold for update/delete here - // rather than deferring to a downstream translation. Runs BEFORE the - // principal-less refusal and the CRUD check so the boundary holds even - // for a principal-less context and a superuser with modifyAllRecords. - // System/boot writes carry `isSystem` and already short-circuited above, - // so the seeder and package publish are unaffected. - await this.assertSystemRowWriteGate(opCtx); - - // [#8552 / ADR-0066 D1] Curated-name authoring refusal for - // sys_capability. A name in PLATFORM_CAPABILITY_NAMES is a capability - // the PLATFORM defines; a Setup-authored row claiming one is a - // declaration the platform cannot honour, so it is refused here — - // create and rename-to both — rather than answered 200 and then - // declined by the seeder on every boot. Runs AFTER the provenance gate - // above (a platform/package row's rename is already refused there on - // provenance) and, like it, BEFORE the principal-less refusal and - // the CRUD check. System/boot writes carry `isSystem` and - // short-circuited above, so the curated seeder itself is unaffected. - await this.assertCuratedCapabilityNameGate(opCtx); - // [ADR-0103] Engine-owned write guard. Fail-closed on a user-context // generic write to a `system`/`append-only` object whose resolved // affordances forbid the verb (the engine-owned default). Keyed off @@ -4446,9 +4360,8 @@ export class SecurityPlugin implements Plugin { }); everyoneMutationWired = true; } - // [#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) and the grants naming the sets it + // [#2747] When the owning package is uninstalled, remove the + // package's suggestion rows and revoke 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. @@ -6837,386 +6750,6 @@ export class SecurityPlugin implements Plugin { * multi-row predicate and returns `null` (multi-row writes route through the * `*Many` paths, out of scope for the by-id pre-image check). */ - /** - * [ADR-0086 P2 — 块2, evolved by ADR-0094] Two-doors data-layer gate for - * `sys_permission_set`. - * - * A row with `managed_by:'package'` is owned by the package door (its - * baseline is authored in the package, materialized on publish). This gate - * refuses (a) FORGING that provenance through the admin door — insert or - * update, single object or array — and (b) the lifecycle ops with no - * overlay translation (`transfer`/`restore`/`purge`) on package rows. - * Ordinary `update`/`delete` pass through: the ADR-0094 write-through - * downstream translates them into env-scope overlay operations (customize / - * reset), and re-asserts the refusal itself when the kernel has no metadata - * 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 system writes are unaffected. - */ - private async assertPackageManagedWriteGate(opCtx: any): Promise { - if (opCtx?.object !== 'sys_permission_set') return; - const op = opCtx.operation; - if (!['insert', 'update', 'delete', 'transfer', 'restore', 'purge'].includes(op)) return; - - // (a) Reject any admin-door PAYLOAD that CLAIMS package provenance - // (`managed_by:'package'`), on insert OR update, single object OR array - // (`engine.insert`/`update` both accept `T | T[]` and route arrays - // through this same middleware). Only the package publish path — which - // carries `isSystem` and short-circuited the whole middleware above — - // may stamp package provenance. This also closes update-to-forge: an - // env row cannot be re-badged package-managed through the admin door. - const payloadRows = Array.isArray(opCtx.data) - ? opCtx.data - : (opCtx.data && typeof opCtx.data === 'object' ? [opCtx.data] : []); - if (payloadRows.some((r: unknown) => r && typeof r === 'object' && (r as Record).managed_by === 'package')) { - throw new PermissionDeniedError( - `[Security] Access denied: cannot set 'managed_by:package' on a permission set through the admin door — ` + - `package permission sets are authored in their package and land via publish (ADR-0086 two-doors).`, - { operation: op, object: opCtx.object }, - ); - } - if (op === 'insert') return; // no existing row to protect - - // [ADR-0094 D5-R] `update`/`delete` on a package-managed row are not - // refused HERE: the write-through middleware (which runs after this gate - // + the delegated-admin gate + the CRUD checks) translates them into a - // metadata write, and the refusal is LEFT TO THAT PRODUCER. Since commit - // ee58392e1 rolled `permission` back to `allowEnvironmentOverlay: false`, a - // CODE-DECLARED (artifact-backed) set is refused there with 403 - // `NOT_OVERRIDABLE`; a `sys_metadata`-backed set rides - // `allowRuntimeCreate` and still lands. The 2026-07-14 "customize / reset - // via the standard ADR-0005 layering" direction this comment used to cite - // is RETIRED. The lifecycle ops below have no metadata translation, so the - // package-row protection stays for them. - if (op === 'update' || op === 'delete') return; - - if (!this.ql) return; - - const targetId = this.extractSingleId(opCtx); - if (targetId == null) { - // Multi-row / filter write with no single id. Deny ONLY if a package-owned - // row actually falls within the write's own filter — so a bulk edit that - // targets only env-authored rows still succeeds (no over-broad block). A - // whole-table write (no filter) matches every package row, so it is denied. - const writeWhere = opCtx?.options?.where; - const packageWhere = writeWhere && typeof writeWhere === 'object' - ? { $and: [writeWhere, { managed_by: 'package' }] } - : { managed_by: 'package' }; - // [#7505] No `.catch(() => null)`: a fault here is not "no package row - // matched". Swallowed, it stood this guard down for the duration of a - // store outage and let the admin-door write through — the same collapse - // `readRowById` carried on the by-id branch just below, and this gate - // must not answer one way for a single id and the opposite for a filter. - // The fault propagates, so the write is refused and the caller sees the - // outage. - const hitsPackageRow = await this.ql - .findOne('sys_permission_set', { where: packageWhere, context: { isSystem: true } }); - if (hitsPackageRow) { - throw new PermissionDeniedError( - `[Security] Access denied: this '${op}' on 'sys_permission_set' targets one or more package-managed ` + - `rows — change those by editing their package and re-publishing, not through the admin door ` + - `(ADR-0086 two-doors separation).`, - { operation: op, object: opCtx.object }, - ); - } - return; - } - - const existing = await this.readRowById('sys_permission_set', targetId, { isSystem: true }); - if (existing && (existing as Record).managed_by === 'package') { - const row = existing as Record; - throw new PermissionDeniedError( - `[Security] Access denied: '${String(row.name ?? targetId)}' is a package-managed permission set ` + - `(managed_by:'package') — change it by editing its package and re-publishing, not through the ` + - `admin door (ADR-0086 two-doors separation).`, - { - operation: op, - object: opCtx.object, - recordId: targetId, - packageId: (row.package_id as string | null) ?? null, - }, - ); - } - } - - /** - * [ADR-0066 / #2918] Built-in row-write guardrail for the platform/application - * ASSET objects `sys_position` and `sys_capability`. - * - * ADR-0066's asset-ownership model splits authoring from assignment: WHAT a - * position or capability *is* is defined by the platform or an application - * package developer; a customer admin only decides WHO it is assigned to (via - * the RBAC link tables, which are governed separately by the delegated-admin - * gate). The `managed_by` provenance column on each object already records - * that ownership, but until now nothing ENFORCED it at the data layer — an - * admin could delete or rewrite a platform/package-managed row and silently - * break that app's authorization baseline (ADR-0049: a provenance attribute - * that exists but is never enforced is exactly the gap to close). - * - * This gate is an unconditional data-layer boundary, mirroring the - * `sys_permission_set` two-doors gate above: - * (a) The admin door may never FORGE managed provenance — stamping - * `managed_by` to a platform/package value on insert OR update (single - * object OR array) is refused; only the platform seeder / package - * publish path (which carries `isSystem` and short-circuited the whole - * middleware above) may author it. This also closes update-to-forge. - * (b) delete / update / transfer / restore / purge on a row whose EXISTING - * `managed_by` is platform/package-owned are refused — unlike - * `sys_permission_set`, these objects have NO ADR-0094 overlay - * write-through, so the mutation would otherwise go straight to the - * driver. - * (c) admin-authored rows (`managed_by` user/∅/admin) are untouched — the - * admin fully owns those (incl. a delegate's rows in their own subtree). - * (d) [#22360] ONE carve-out from (b): an `update` of a PACKAGE-provided - * `sys_position` row whose patch touches only ROW STATE passes — see - * {@link SecurityPlugin.PACKAGE_POSITION_ROW_STATE_COLUMNS}. Platform - * rows and `sys_capability` get no carve-out. - * Fails CLOSED and never depends on the caller's grants, so a superuser with - * modifyAllRecords cannot delete a platform position either. - */ - private async assertSystemRowWriteGate(opCtx: any): Promise { - const spec = SYSTEM_ROW_PROVENANCE[opCtx?.object as string]; - if (!spec) return; - const op = opCtx.operation; - if (!['insert', 'update', 'delete', 'transfer', 'restore', 'purge'].includes(op)) return; - - const managedValues = Object.keys(spec.managed); - - // (a) Reject any admin-door PAYLOAD that CLAIMS platform/package provenance, - // on insert OR update, single object OR array. Only the seeder / publish - // path (which carries `isSystem` and short-circuited above) may stamp it. - const payloadRows = Array.isArray(opCtx.data) - ? opCtx.data - : (opCtx.data && typeof opCtx.data === 'object' ? [opCtx.data] : []); - if ( - payloadRows.some( - (r: unknown) => - r && - typeof r === 'object' && - managedValues.includes(String((r as Record).managed_by ?? '')), - ) - ) { - throw new PermissionDeniedError( - `[Security] Access denied: cannot stamp a platform/package 'managed_by' value on a ${spec.noun} ` + - `through the admin door — ${spec.pluralNoun} provided by the platform or an application package are ` + - `authored there and land via seeding/publish, not through Setup (ADR-0066 asset ownership).`, - { operation: op, object: opCtx.object }, - ); - } - if (op === 'insert') return; // no existing row to protect - - if (!this.ql) return; - - // (d) Whether this write is the row-state-only update the carve-out admits. - const rowStateOnly = SecurityPlugin.isPackagePositionRowStatePatch(opCtx); - - const targetId = this.extractSingleId(opCtx); - if (targetId == null) { - // Multi-row / filter write with no single id. Deny ONLY if a managed row - // actually falls within the write's own filter — so a bulk edit that - // targets only admin-authored rows still succeeds (no over-broad block). A - // whole-table write (no filter) matches every managed row, so it is denied. - // - // (d) A row-state-only patch asks only about the rows the carve-out does - // not cover: package positions in the filter no longer refuse it, a - // platform position in the filter still does. - const guardedValues = rowStateOnly - ? managedValues.filter((v) => !SecurityPlugin.PACKAGE_POSITION_PROVENANCE.has(v)) - : managedValues; - const writeWhere = opCtx?.options?.where; - const managedWhere = - writeWhere && typeof writeWhere === 'object' - ? { $and: [writeWhere, { managed_by: { $in: guardedValues } }] } - : { managed_by: { $in: guardedValues } }; - // [#7505] No `.catch(() => null)` — see the sibling gate: a swallowed - // fault made this asset guard answer "nothing managed matched" and admit - // the write. Fail-closed: propagate. - const hitsManagedRow = await this.ql - .findOne(opCtx.object, { where: managedWhere, context: { isSystem: true } }); - if (hitsManagedRow) { - throw new PermissionDeniedError( - `[Security] Access denied: this '${op}' on '${opCtx.object}' targets one or more ${spec.pluralNoun} ` + - `provided by the platform or an application package — those cannot be deleted or modified through ` + - `the admin door (ADR-0066 asset ownership).`, - { operation: op, object: opCtx.object }, - ); - } - return; - } - - const existing = await this.readRowById(opCtx.object, targetId, { isSystem: true }); - const existingManagedBy = existing - ? String((existing as Record).managed_by ?? '') - : ''; - const ownerLabel = spec.managed[existingManagedBy]; - // (d) The carve-out: a row-state-only patch of a package-provided position. - if (existing && rowStateOnly && SecurityPlugin.PACKAGE_POSITION_PROVENANCE.has(existingManagedBy)) return; - if (existing && ownerLabel) { - const row = existing as Record; - const source = ownerLabel === 'the platform' ? 'platform definition' : 'application package'; - throw new PermissionDeniedError( - `[Security] Access denied: '${String(row.name ?? row.label ?? targetId)}' is a ${spec.noun} provided ` + - `by ${ownerLabel} — it cannot be deleted or modified through the admin door. Change it by editing ` + - `its ${source} and re-publishing (ADR-0066 asset ownership).`, - { operation: op, object: opCtx.object, recordId: targetId, managedBy: existingManagedBy }, - ); - } - } - - /** - * [#22360] The ROW-STATE columns of `sys_position`: the one named allowlist - * the carve-out (d) of {@link SecurityPlugin.assertSystemRowWriteGate} admits - * on a package-provided position. - * - * A package position row is locked the way a packaged permission set is. - * `permission-set-projection.ts`'s write door (#4669) passes a bare - * `{ active }` patch on a packaged set straight through: switching a packaged - * set off is not a customization of it. Under #15196's Q2 = A, - * `sys_position.active` stays row-authoritative, so switching a package's - * position off — or making it the default for new users — is the operator's - * lever on the row, not an edit of the package's definition. Neither column - * is a `PositionSchema` key. - * - * Everything else stays refused, with the gate's own code and message: - * a definition column (`name`, `label`, `description`, `delegatable`), any - * column not named here, `delete`, and `transfer` / `restore` / `purge`. - * `delegatable` is deliberately NOT row state: it is declared on the position - * definition, and the triage ruling locks it ahead of #15196's S8b reader - * switch. ⛔ Widening this set is a ruling, not an edit. - */ - private static readonly PACKAGE_POSITION_ROW_STATE_COLUMNS: ReadonlySet = new Set(['active', 'is_default']); - - /** - * [#22360] The `sys_position` provenance values carve-out (d) applies to: - * `package`, and `config`, its legacy pre-A4 spelling, which - * `SYSTEM_ROW_PROVENANCE` guards as the same owner ("an application - * package") — the two vocabularies are kept in lockstep there, so a legacy - * row the boot normalizer has not healed yet answers as its canonical twin. - * ⛔ Never `platform` / `system`: switching off a built-in such as - * `everyone` is not an operator lever. - */ - private static readonly PACKAGE_POSITION_PROVENANCE: ReadonlySet = new Set(['package', 'config']); - - /** - * [#22360] Is this the write carve-out (d) admits: an `update` of - * `sys_position` whose patch is ONE object naming at least one row-state - * column and nothing else? `id` is the row's identifier (the single-id - * branch reads it from the payload), not a column the patch changes. An - * array payload is never admitted here: its rows are judged by the gate - * exactly as before. - */ - private static isPackagePositionRowStatePatch(opCtx: any): boolean { - if (opCtx?.object !== 'sys_position' || opCtx?.operation !== 'update') return false; - const patch = opCtx.data; - if (!patch || typeof patch !== 'object' || Array.isArray(patch)) return false; - const columns = Object.keys(patch).filter((k) => k !== 'id'); - return columns.length > 0 - && columns.every((k) => SecurityPlugin.PACKAGE_POSITION_ROW_STATE_COLUMNS.has(k)); - } - - /** - * [#8552 / ADR-0066 D1] Curated-name authoring refusal for `sys_capability`. - * - * A name in `PLATFORM_CAPABILITY_NAMES` is a capability the PLATFORM - * defines: grants (`systemPermissions`) and requirements - * (`requiredPermissions`) resolve it by name, and its definition is served - * by the registry (`builtin-capabilities.ts`, ADR-0131 D3), not by a row. A - * Setup-authored row claiming such a name is a declaration the platform - * cannot honour — it can never BE the curated capability (provenance - * forging is refused by the gate above). Maintainer ruling on - * #8552 (2026-08-13): refuse the collision AT THE WRITE DOOR — create and - * rename both — naming the colliding curated name; never answer 200. - * - * What is refused, and exactly that: - * - `insert` whose payload names a curated capability (every row of an - * array payload); - * - `update` that RENAMES a row TO a curated name. A payload repeating - * the row's OWN existing curated name is not a rename and passes: the - * ruling deliberately leaves pre-existing colliding rows to the - * operator, so the operator's remediation on one (edit the label, - * deactivate, rename AWAY) must stay open, and a full-record PATCH from - * Setup echoes `name` unchanged. The pre-image is read under a system - * context to establish that fact; per the #7505 ruling the read fails - * CLOSED — an engine fault propagates, and an absent pre-image refuses, - * because a write claiming a curated name for a row that cannot be - * shown to already hold it is exactly the state this gate prevents. - * - a FILTER update stamping a curated name is allowed only when the - * filter provably matches nothing but rows already holding that name - * (`$ne` probe, the sibling gate's bulk-check shape); no filter at all - * is a whole-table stamp and is refused. - * - * Deliberately NOT covered: `restore` of a soft-deleted row that already - * held a curated name re-establishes a pre-existing collision rather than - * minting a new one — that residue is the operator's (option 1 of the - * ruling), not this gate's. Creating NON-curated names stays open — the - * legitimate half of both measured flows (community NULL-bucket `admin` - * rows, package declarations), and the seeders themselves carry `isSystem` - * and short-circuit the middleware entirely. - */ - private async assertCuratedCapabilityNameGate(opCtx: any): Promise { - if (opCtx?.object !== 'sys_capability') return; - const op = opCtx.operation; - if (op !== 'insert' && op !== 'update') return; - - const payloadRows: unknown[] = Array.isArray(opCtx.data) - ? opCtx.data - : opCtx.data && typeof opCtx.data === 'object' - ? [opCtx.data] - : []; - - const curatedNameOf = (r: unknown): string | null => { - if (!r || typeof r !== 'object') return null; - const name = (r as Record).name; - return typeof name === 'string' && PLATFORM_CAPABILITY_NAMES.has(name) ? name : null; - }; - - const refuse = (name: string): never => { - throw new PermissionDeniedError( - `[Security] Access denied: '${name}' is a platform-curated capability name — a sys_capability ` + - `row cannot be created with it or renamed to it through the admin door. The platform defines ` + - `this capability and seeds its own row for it; grants and requiredPermissions already resolve ` + - `the name. A curated name is refused at authoring so that no admin-authored row can collide ` + - `with the row the platform seeds for it. Choose a different capability name (ADR-0066 asset ` + - `ownership).`, - { operation: op, object: opCtx.object, name, curated: true }, - ); - }; - - if (op === 'insert') { - for (const r of payloadRows) { - const name = curatedNameOf(r); - if (name != null) refuse(name); - } - return; - } - - // update — refuse a rename TO a curated name. - const isScalarId = (v: unknown): v is string | number | bigint => - v !== null && (typeof v === 'string' || typeof v === 'number' || typeof v === 'bigint'); - for (const r of payloadRows) { - const name = curatedNameOf(r); - if (name == null) continue; - const ownId = (r as Record).id; - const rowId = isScalarId(ownId) ? ownId : this.extractSingleId(opCtx); - if (rowId != null) { - if (!this.ql) refuse(name); - // [#7505] Fail-closed: a probe fault propagates; `null` means absent. - const existing = await this.readRowById('sys_capability', rowId, { isSystem: true }); - if (existing && String((existing as Record).name ?? '') === name) continue; - refuse(name); - } - const writeWhere = opCtx?.options?.where; - if (!writeWhere || typeof writeWhere !== 'object' || !this.ql) refuse(name); - // [#7505] No swallow here either — a probe fault propagates (fail closed). - const renamesOtherRow = await this.ql.findOne('sys_capability', { - where: { $and: [writeWhere, { name: { $ne: name } }] }, - context: { isSystem: true }, - }); - if (renamesOtherRow) refuse(name); - } - } - /** * [commit 6feac910b] Does this object declare that its access — writes included — derives * from a master record (ADR-0055 `controlled_by_parent`)? @@ -7421,11 +6954,10 @@ export class SecurityPlugin implements Plugin { * - `assertControlledByParentWrite` answered `404 RECORD_NOT_FOUND` — an * SDK treats that as TERMINAL (drop the id, do not retry) when the * truthful answer was a transient outage it should have backed off on; - * - the two admin-door provenance gates - * (`assertPackageManagedWriteGate`, `assertSystemRowWriteGate`) read - * `null` as "this row is not package/platform-managed" and let the write - * THROUGH — fail-OPEN, the whole guard silently stood down for the - * duration of a store fault. + * - the two admin-door provenance gates on the catalog row objects + * (retired with those objects, ADR-0131 D3) read `null` as "this row is + * not package/platform-managed" and let the write THROUGH — fail-OPEN, + * the whole guard silently stood down for the duration of a store fault. * * Maintainer ruling of 2026-08-11 on #7505: FAIL-CLOSED. An engine fault * PROPAGATES out of this probe instead of being flattened, so the write is diff --git a/packages/plugins/plugin-security/src/store-fault-fail-closed.test.ts b/packages/plugins/plugin-security/src/store-fault-fail-closed.test.ts index e4653c0fc2f..a0772ee0a81 100644 --- a/packages/plugins/plugin-security/src/store-fault-fail-closed.test.ts +++ b/packages/plugins/plugin-security/src/store-fault-fail-closed.test.ts @@ -16,7 +16,9 @@ // • the two admin-door PROVENANCE gates read `null` as "this row is not // package/platform-managed" and let the write THROUGH. That is fail-OPEN: // for the duration of a store fault, ADR-0086's two-doors boundary and -// ADR-0066's asset-ownership boundary both silently stood down; +// ADR-0066's asset-ownership boundary both silently stood down (both +// gates retired with the catalog row objects, ADR-0131 D3, and their legs +// here with them); // • `getCallerPreImage`'s owner-echo consumer caught the throw and answered // `403 changing record ownership` — fail-closed, but with a sentence that // accuses the caller of something they did not do and an envelope an SDK @@ -102,22 +104,6 @@ const ADMIN_SET: PermissionSet = { }, } as unknown as PermissionSet; -/** - * `ADMIN_SET` plus the tenant-admin wildcard. Needed only for - * `sys_permission_set`: the ADR-0090 D12 delegated-admin gate runs a few lines - * AFTER the two-doors gate and refuses RBAC-table writes from a principal with - * mere CRUD grants, so without this the "admitted" half of a steady-state pair - * would be measuring that gate instead of this one. It carries - * `modifyAllRecords`, which is exactly why the two-doors boundary is placed - * ahead of the CRUD check — a superuser does not get to forge provenance - * either. - */ -const TENANT_ADMIN_SET: PermissionSet = { - name: 'tenant_admin', - label: 'Tenant Admin', - objects: { '*': { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true, modifyAllRecords: true } }, -} as unknown as PermissionSet; - interface BootOptions { rows?: Record; /** `findOne` on this object throws a datasource outage. */ @@ -230,226 +216,6 @@ function expectPropagatedOutage(err: any): void { // --------------------------------------------------------------------------- -describe('[#7505] assertPackageManagedWriteGate — the two-doors boundary under a store fault', () => { - const rows = () => ({ - sys_permission_set: [ - { id: 'ps_pkg', name: 'Package Set', managed_by: 'package' }, - { id: 'ps_env', name: 'Env Set', managed_by: 'admin' }, - ], - }); - // `update` / `delete` defer to the ADR-0094 write-through, so the gate's own - // row protection is reachable on the lifecycle verbs only. Of those, - // `transfer` is the one a caller can still be GRANTED since #12497 - // (`restore`/`purge` lost their bits and their evaluator rows, so the - // object-level gate downstream refuses them unconditionally and an - // "admitted" leg could never resolve) — the two-doors boundary this suite - // measures is verb-agnostic, so the probe verb moved from `purge` to - // `transfer` with nothing else changing. - const lifecycleWrite = (where: Row) => ({ object: 'sys_permission_set', operation: 'transfer', options: { where } }); - // See TENANT_ADMIN_SET: this table's writes pass a second, later gate. - const bootPkg = (o: BootOptions = {}) => boot({ sets: [TENANT_ADMIN_SET], ...o }); - - it('STEADY STATE: a package-managed row is refused, an admin-authored row is admitted', async () => { - const h = await bootPkg({ rows: rows() }); - const denied = await refusalOf(h.write(lifecycleWrite({ id: 'ps_pkg' }))); - expect(denied.code).toBe('PERMISSION_DENIED'); - expect(denied.message).toContain('package-managed permission set'); - await expect(h.write(lifecycleWrite({ id: 'ps_env' }))).resolves.toBe('admitted'); - }); - - it('FAULT (by id): the outage propagates — it does NOT read as "no package row here"', async () => { - // The measured fail-open. Before the fix this resolved: the probe threw, - // `readRowById` answered `null`, the gate concluded the row was not - // package-managed, and the purge went through — against the very row the - // steady-state case above proves is protected. - const h = await bootPkg({ rows: rows(), faultOn: 'sys_permission_set' }); - expectPropagatedOutage(await refusalOf(h.write(lifecycleWrite({ id: 'ps_pkg' })))); - }); - - it('FAULT (bulk filter): the same answer — one gate cannot answer two ways for one outage', async () => { - // The sibling branch, one `if` above: a filter write with no single id asks - // the same question of the same store with `.findOne`, and used to swallow - // the same fault with `.catch(() => null)`. - const h = await bootPkg({ rows: rows(), faultOn: 'sys_permission_set' }); - expectPropagatedOutage(await refusalOf(h.write(lifecycleWrite({ name: 'Package Set' })))); - }); - - it('STEADY STATE (bulk filter): a filter that hits no package row is still admitted', async () => { - // Fail-closed must not become "refuse everything": the bulk branch exists - // precisely so a bulk edit that touches only env-authored rows succeeds. - const h = await bootPkg({ rows: rows() }); - await expect(h.write(lifecycleWrite({ managed_by: 'admin' }))).resolves.toBe('admitted'); - }); -}); - -describe('[#7505] assertSystemRowWriteGate — ADR-0066 asset ownership under a store fault', () => { - const rows = () => ({ - sys_position: [ - { id: 'pos_platform', name: 'CEO', managed_by: 'platform' }, - { id: 'pos_admin', name: 'Regional Lead', managed_by: 'admin' }, - ], - }); - const del = (where: Row) => ({ object: 'sys_position', operation: 'delete', options: { where } }); - - it('STEADY STATE: a platform-provided position is refused, an admin-authored one is admitted', async () => { - const h = await boot({ rows: rows() }); - const denied = await refusalOf(h.write(del({ id: 'pos_platform' }))); - expect(denied.code).toBe('PERMISSION_DENIED'); - expect(denied.message).toContain('provided by the platform'); - await expect(h.write(del({ id: 'pos_admin' }))).resolves.toBe('admitted'); - }); - - it('FAULT (by id): the outage propagates instead of admitting the delete', async () => { - const h = await boot({ rows: rows(), faultOn: 'sys_position' }); - expectPropagatedOutage(await refusalOf(h.write(del({ id: 'pos_platform' })))); - }); - - it('FAULT (bulk filter): the outage propagates there too', async () => { - const h = await boot({ rows: rows(), faultOn: 'sys_position' }); - expectPropagatedOutage(await refusalOf(h.write(del({ name: 'CEO' })))); - }); -}); - -/** - * [#22360] The same gate, in steady state, on a PACKAGE-provided `sys_position` - * row: its definition is locked, its row state is not. - * - * The declared-position seeder stamps `managed_by: 'package'` on the row of a - * position a code package holds, so this gate now protects that row. A package - * position row is locked the way a packaged permission set is - * (`permission-set-projection.ts`, #4669): a patch touching ONLY row state - * (`active`, `is_default`) passes, because switching a package's position off, - * or making it the default for new users, is not an edit of its definition. - * Everything else stays refused with the gate's own code: a definition column - * (`name`, `label`, `description`, `delegatable`), any other column, a delete - * and the other lifecycle verbs. Platform (built-in) rows and `sys_capability` - * get no carve-out. Housed here, beside the #7505 cases of the same gate, so the - * suite's one store double serves both (no second double to ledger). - */ -describe('[#22360] assertSystemRowWriteGate — a package position row: row state switches, the definition stays locked', () => { - const rows = () => ({ - sys_position: [ - { id: 'pos_pkg_a', name: 'field_rep', label: 'Field Rep', managed_by: 'package', active: true }, - { id: 'pos_pkg_b', name: 'auditor', label: 'Auditor', managed_by: 'package', active: true }, - { id: 'pos_legacy', name: 'legacy_lead', label: 'Legacy Lead', managed_by: 'config', active: true }, - { id: 'pos_platform', name: 'everyone', label: 'Everyone', managed_by: 'platform', active: true }, - { id: 'pos_admin', name: 'regional_lead', label: 'Regional Lead', managed_by: 'admin', active: true }, - ], - sys_capability: [ - { id: 'cap_pkg', name: 'pkg_cap', label: 'Package Capability', managed_by: 'package', active: true }, - ], - }); - const update = (object: string, data: unknown, where: Row = {}) => ({ object, operation: 'update', data, options: { where } }); - const byId = (id: string, patch: Row) => update('sys_position', { id, ...patch }, { id }); - const PKG_NAMES = { name: { $in: ['field_rep', 'auditor'] } }; - /** The refusal's code and status — the gate's existing envelope, unchanged by the carve-out. */ - const refused = async (run: Promise) => { - const err = await refusalOf(run); - return { code: err.code, status: err.status ?? err.statusCode, message: String(err.message) }; - }; - const REFUSED = { code: 'PERMISSION_DENIED', status: 403 }; - - it('a row-state-only patch passes: deactivate, activate, Set as Default, both together', async () => { - const h = await boot({ rows: rows() }); - await expect(h.write(byId('pos_pkg_a', { active: false }))).resolves.toBe('admitted'); - await expect(h.write(byId('pos_pkg_a', { active: true }))).resolves.toBe('admitted'); - await expect(h.write(byId('pos_pkg_a', { is_default: true }))).resolves.toBe('admitted'); - await expect(h.write(byId('pos_pkg_a', { active: false, is_default: false }))).resolves.toBe('admitted'); - // The id carried only by the filter, as the engine's by-id update spells it. - await expect(h.write(update('sys_position', { active: false }, { id: 'pos_pkg_a' }))).resolves.toBe('admitted'); - }); - - it('a definition column, any other column, or a mixed patch is refused with the gate\'s code', async () => { - const h = await boot({ rows: rows() }); - for (const patch of [ - { label: 'Field Rep (edited)' }, - { description: 'edited' }, - { delegatable: true }, - { name: 'field_rep_two' }, - { active: false, label: 'Field Rep (edited)' }, - { is_default: true, delegatable: true }, - { active: false, notes: 'not a row-state column' }, - ]) { - const r = await refused(h.write(byId('pos_pkg_a', patch))); - expect(r, JSON.stringify(patch)).toMatchObject(REFUSED); - expect(r.message).toContain('provided by an application package'); - } - }); - - it('an empty patch admits nothing new: it is refused as before', async () => { - const h = await boot({ rows: rows() }); - expect(await refused(h.write(byId('pos_pkg_a', {})))).toMatchObject(REFUSED); - }); - - it('delete and the other lifecycle verbs stay refused', async () => { - const h = await boot({ rows: rows() }); - for (const operation of ['delete', 'transfer', 'restore', 'purge']) { - const r = await refused(h.write({ object: 'sys_position', operation, options: { where: { id: 'pos_pkg_a' } } })); - expect(r, operation).toMatchObject(REFUSED); - } - }); - - it('the legacy `config` spelling of package provenance answers as its canonical twin', async () => { - const h = await boot({ rows: rows() }); - await expect(h.write(byId('pos_legacy', { active: false }))).resolves.toBe('admitted'); - expect(await refused(h.write(byId('pos_legacy', { label: 'edited' })))).toMatchObject(REFUSED); - }); - - it('control: a built-in\'s bare { active } patch stays refused', async () => { - const h = await boot({ rows: rows() }); - const r = await refused(h.write(byId('pos_platform', { active: false }))); - expect(r).toMatchObject(REFUSED); - expect(r.message).toContain('provided by the platform'); - expect(await refused(h.write(byId('pos_platform', { is_default: true })))).toMatchObject(REFUSED); - }); - - it('control: sys_capability gets no carve-out', async () => { - const h = await boot({ rows: rows() }); - expect(await refused(h.write(update('sys_capability', { id: 'cap_pkg', active: false }, { id: 'cap_pkg' })))) - .toMatchObject(REFUSED); - }); - - it('control: an administrator-authored position stays fully editable', async () => { - const h = await boot({ rows: rows() }); - await expect(h.write(byId('pos_admin', { label: 'Regional Lead (edited)', delegatable: true }))).resolves.toBe('admitted'); - }); - - it('control: forging provenance in a row-state patch is still refused (a)', async () => { - const h = await boot({ rows: rows() }); - const r = await refused(h.write(byId('pos_admin', { active: false, managed_by: 'package' }))); - expect(r).toMatchObject(REFUSED); - expect(r.message).toContain('cannot stamp'); - }); - - it('filtered: a row-state-only patch over package rows passes', async () => { - const h = await boot({ rows: rows() }); - await expect(h.write(update('sys_position', { active: false }, PKG_NAMES))).resolves.toBe('admitted'); - await expect(h.write(update('sys_position', { is_default: false }, PKG_NAMES))).resolves.toBe('admitted'); - }); - - it('filtered: a label patch over a package row is refused', async () => { - const h = await boot({ rows: rows() }); - const r = await refused(h.write(update('sys_position', { label: 'Renamed' }, PKG_NAMES))); - expect(r).toMatchObject(REFUSED); - expect(r.message).toContain('provided by the platform or an application package'); - }); - - it('filtered: a row-state-only patch whose filter also reaches a built-in is refused', async () => { - const h = await boot({ rows: rows() }); - expect(await refused(h.write(update('sys_position', { active: false }, { name: { $in: ['field_rep', 'everyone'] } })))) - .toMatchObject(REFUSED); - // A whole-table write matches every built-in. - expect(await refused(h.write({ object: 'sys_position', operation: 'update', data: { active: false }, options: {} }))) - .toMatchObject(REFUSED); - }); - - it('control (filtered): a delete over package rows stays refused', async () => { - const h = await boot({ rows: rows() }); - expect(await refused(h.write({ object: 'sys_position', operation: 'delete', options: { where: PKG_NAMES } }))) - .toMatchObject(REFUSED); - }); -}); - describe('[#7505] getCallerPreImage — the owner-anchor echo under a store fault', () => { const rows = () => ({ crm_task: [ 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 d7314fdec3e..834fe5e27cb 100644 --- a/packages/plugins/plugin-security/src/translations/en.objects.generated.ts +++ b/packages/plugins/plugin-security/src/translations/en.objects.generated.ts @@ -84,7 +84,7 @@ export const enObjects: NonNullable = { sys_user_position: { label: "User Position", pluralLabel: "User Positions", - description: "Assigns a position (sys_position.name) to a user. Platform-owned (ADR-0057 D4, ADR-0090 D3).", + description: "Assigns a position, by name, to a user. Platform-owned (ADR-0057 D4, ADR-0090 D3).", fields: { id: { label: "Assignment ID", @@ -100,7 +100,7 @@ export const enObjects: NonNullable = { }, position: { label: "Position", - help: "Position machine name (references sys_position.name)." + help: "Position machine name, resolved in the security catalog." }, business_unit_id: { label: "Business Unit", 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 1434a33c7e5..daadd6f3961 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 @@ -84,7 +84,7 @@ export const esESObjects: NonNullable = { sys_user_position: { label: "Puesto de usuario", pluralLabel: "Puestos de usuario", - description: "Asigna un puesto (sys_position.name) a un usuario. Gestionado por la plataforma (ADR-0057 D4, ADR-0090 D3).", + description: "Asigna un puesto, por nombre, a un usuario. Gestionado por la plataforma (ADR-0057 D4, ADR-0090 D3).", fields: { id: { label: "ID de asignación", @@ -100,7 +100,7 @@ export const esESObjects: NonNullable = { }, position: { label: "Puesto", - help: "Nombre de máquina del puesto (referencia a sys_position.name)." + help: "Nombre de máquina del puesto, resuelto en el catálogo de seguridad." }, business_unit_id: { label: "Unidad de negocio", 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 fc0b17d2c6b..1ed611dbadd 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 @@ -84,7 +84,7 @@ export const jaJPObjects: NonNullable = { sys_user_position: { label: "ユーザーポジション", pluralLabel: "ユーザーポジション", - description: "ポジション(sys_position.name)をユーザーに割り当てます。プラットフォーム管理(ADR-0057 D4、ADR-0090 D3)。", + description: "ポジションを名前でユーザーに割り当てます。プラットフォーム管理(ADR-0057 D4、ADR-0090 D3)。", fields: { id: { label: "割り当て ID", @@ -100,7 +100,7 @@ export const jaJPObjects: NonNullable = { }, position: { label: "ポジション", - help: "ポジションのマシン名(sys_position.name を参照)。" + help: "ポジションのマシン名(セキュリティカタログで解決)。" }, business_unit_id: { label: "ビジネスユニット", 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 1488d2650b4..a8e4c79921b 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 @@ -84,7 +84,7 @@ export const zhCNObjects: NonNullable = { sys_user_position: { label: "用户岗位", pluralLabel: "用户岗位", - description: "将岗位(sys_position.name)分配给用户。由平台管理(ADR-0057 D4、ADR-0090 D3)。", + description: "按名称将岗位分配给用户。由平台管理(ADR-0057 D4、ADR-0090 D3)。", fields: { id: { label: "分配 ID", @@ -100,7 +100,7 @@ export const zhCNObjects: NonNullable = { }, position: { label: "岗位", - help: "岗位机器名(引用 sys_position.name)。" + help: "岗位机器名(在安全目录中解析)。" }, business_unit_id: { label: "业务单元", diff --git a/packages/plugins/plugin-security/src/walled-catalog-data-door.ts b/packages/plugins/plugin-security/src/walled-catalog-data-door.ts deleted file mode 100644 index d10cc2a76ee..00000000000 --- a/packages/plugins/plugin-security/src/walled-catalog-data-door.ts +++ /dev/null @@ -1,88 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * [ADR-0131 D3] The walled catalog refusal at the DATA door: a non-system - * insert, update or delete on `sys_position`, `sys_permission_set` or - * `sys_capability`, under a posture that enforces an organization wall, by a - * caller who is not the platform operator, is refused `403 PERMISSION_DENIED`. - * - * ## Why the data door needs it - * - * ADR-0131 D3: "Shared-DB multi-tenant (`group` / `isolated`): tenant admins - * cannot create or edit catalog items … Creation through Setup or the API is - * refused". Setup writes the catalog through these three objects. Under a wall - * the stock organization administrator is already refused there by its own - * grants (the anti-escalation deny in `objects/default-permission-sets.ts`), - * but any other organization-scoped principal whose sets admit the table was - * not: - * - * - a `sys_permission_set` write is redirected into the environment ledger - * (`createPermissionSetWriteThrough`, ADR-0094 D3) in every posture, so an - * organization-scoped principal created an ENVIRONMENT permission set every - * organization resolves by name — measured on a booted walled stack; - * - a `sys_position` write landed as a row of the writer's organization, a - * position with no registry home (the `single`-only write-through stands - * down under a wall) — measured in this package's harness. - * - * The verdict and its sentence are the metadata door's own - * (`walledCatalogWriteVerdict`, `@objectstack/metadata-core`), so the data - * door and the `/meta` doors refuse the same callers with the same words. - * - * ## Where it runs - * - * Registered AFTER the security middleware, so it runs INSIDE it: a caller who - * may not write the table at all keeps its CRUD refusal and learns nothing - * here. Registered BEFORE the permission-set write-through, so a refused write - * is never translated into a metadata save. - * - * ## What it does not judge - * - * - **Assignments.** `sys_user_position` and `sys_user_permission_set` rows - * are organization rows in every posture (ADR-0131 D3); an organization's - * administrators keep assigning the catalog to their own members. - * - **System writes** (`isSystem`): the seeders, the package door, migrations. - * - **The platform operator** (the ADR-0095 `PLATFORM_ADMIN` rung on the - * context). A hand-built context that carries no rung is not the operator. - * - **`single`**: inert; the organization is the environment. - */ - -import { walledCatalogWriteVerdict } from '@objectstack/metadata-core'; -import type { TenancyPosture } from '@objectstack/spec/security'; -import { PermissionDeniedError } from './errors.js'; - -/** The catalog objects this guard judges, and the metadata type each one writes. */ -export const WALLED_CATALOG_OBJECT_TYPES: Readonly> = Object.freeze({ - sys_position: 'position', - sys_permission_set: 'permission', - sys_capability: 'capability', -}); - -/** The engine operations that change a catalog row. */ -const WRITE_OPERATIONS: ReadonlySet = new Set(['insert', 'update', 'delete']); - -export interface WalledCatalogDataDoorDeps { - /** The deployment's effective tenancy posture, as the security plugin resolved it. */ - getPosture: () => TenancyPosture | undefined; -} - -/** The engine middleware. Registered once per catalog object (see {@link WALLED_CATALOG_OBJECT_TYPES}). */ -export function createWalledCatalogDataDoorRefusal( - deps: WalledCatalogDataDoorDeps, -): (opCtx: any, next: () => Promise) => Promise { - return async (opCtx: any, next: () => Promise): Promise => { - const object = opCtx?.object; - const type = typeof object === 'string' ? WALLED_CATALOG_OBJECT_TYPES[object] : undefined; - if (!type || !WRITE_OPERATIONS.has(opCtx?.operation)) return next(); - const context = opCtx?.context ?? {}; - const verdict = walledCatalogWriteVerdict({ - type, - tenancyPosture: deps.getPosture(), - isSystem: context.isSystem === true, - callerPosture: context.posture, - }); - if (!verdict.allowed) { - throw new PermissionDeniedError(verdict.message, { operation: opCtx.operation, object }); - } - return next(); - }; -} diff --git a/packages/plugins/plugin-security/src/write-refusals.ts b/packages/plugins/plugin-security/src/write-refusals.ts index 2430dfda0e6..b9017d24f04 100644 --- a/packages/plugins/plugin-security/src/write-refusals.ts +++ b/packages/plugins/plugin-security/src/write-refusals.ts @@ -93,8 +93,7 @@ export function logSeedDurabilityFailure( meta?: Record, ): void { // No single cause: this line summarises N refusals, so the cause slot is - // `undefined` and the detail travels in meta — the same shape the sibling - // reconcile summary in `permission-set-projection.ts` uses. + // `undefined` and the detail travels in meta. if (logger?.error) logger.error(message, undefined, meta); else logger?.warn?.(message, meta); } diff --git a/packages/plugins/plugin-security/tsconfig.test.json b/packages/plugins/plugin-security/tsconfig.test.json index 592765cee44..af17e381139 100644 --- a/packages/plugins/plugin-security/tsconfig.test.json +++ b/packages/plugins/plugin-security/tsconfig.test.json @@ -32,7 +32,7 @@ // the CHECK rather than the code — TS1470 (`import.meta` in a file "which // will build into CommonJS output", `src/audience-anchor-set-claims.pin.test.ts`, // a file vitest runs as ESM every day) and TS2550 (`Array.prototype.at` -// against a `lib` older than es2022, `src/permission-set-projection.test.ts`, +// against a `lib` older than es2022, `src/permission-set-projection.test.ts`, since deleted, // on Node >= 22). Matching vitest is fidelity, not laxity: it is the same // subtraction `packages/spec` and `packages/rest` made, and it removes the // pressure that produced the `__dirname` workaround above. diff --git a/packages/plugins/plugin-sharing/src/objects/sys-sharing-rule.object.ts b/packages/plugins/plugin-sharing/src/objects/sys-sharing-rule.object.ts index 3b86f89cc43..66b0adb1244 100644 --- a/packages/plugins/plugin-sharing/src/objects/sys-sharing-rule.object.ts +++ b/packages/plugins/plugin-sharing/src/objects/sys-sharing-rule.object.ts @@ -238,12 +238,11 @@ export const SysSharingRule = ObjectSchema.create({ }), // ── Provenance (#2909 P0 — record-authoritative seed-not-clobber) ── - // Unified A4 (#2920) tri-state, shared verbatim with sys_position / - // sys_capability / sys_permission_set. Both columns are `readonly`: + // Unified A4 (#2920) tri-state, the vocabulary the retired security + // catalog rows shared. Both columns are `readonly`: // the engine strips them from non-system payloads (forge/clear-proof), // while the seeder and the provenance stamp hook write with isSystem. - // NOTE deliberately NOT in SYSTEM_ROW_PROVENANCE (no write gate): - // sharing rules are a first-class admin authoring/tuning surface — + // NOTE deliberately no provenance write gate: sharing rules are a first-class admin authoring/tuning surface — // admins may edit or deactivate package rules; the seeder simply stops // overwriting rows once `customized` is stamped (ADR-0094 addendum). managed_by: Field.select({ diff --git a/packages/plugins/plugin-sharing/src/sharing-rule-provenance.ts b/packages/plugins/plugin-sharing/src/sharing-rule-provenance.ts index c4ce3200947..863d66bedc0 100644 --- a/packages/plugins/plugin-sharing/src/sharing-rule-provenance.ts +++ b/packages/plugins/plugin-sharing/src/sharing-rule-provenance.ts @@ -13,8 +13,8 @@ * Why a data hook (and not a write gate or the REST layer): * - admins edit rules through several doors (Setup UI generic data door, * scripts, console) — an engine hook covers them all; - * - unlike sys_position/sys_capability there is deliberately NO - * SYSTEM_ROW_PROVENANCE write gate here: sharing rules are a first-class + * - there is deliberately NO provenance write gate here (the retired + * security catalog rows had one): sharing rules are a first-class * admin authoring surface, so edits are allowed — they just have to be * remembered; * - both provenance columns are `readonly`, and the engine's readonly strip diff --git a/packages/qa/dogfood/test/permission-set-clone-door.dogfood.test.ts b/packages/qa/dogfood/test/permission-set-clone-door.dogfood.test.ts index ab7e476bd7f..e4c241c17fd 100644 --- a/packages/qa/dogfood/test/permission-set-clone-door.dogfood.test.ts +++ b/packages/qa/dogfood/test/permission-set-clone-door.dogfood.test.ts @@ -69,15 +69,9 @@ describe('the permission-set clone door, on a booted showcase', () => { expect(source?.packageId, 'the source is not packaged on this boot').toBeTruthy(); expect(source?.definition.adminScope, 'the source carries no adminScope').toBeTruthy(); - // The source's catalog ROWS are removed first, where the row object is - // still registered: the door reads the catalog, so the clone must land - // with no row to read. (C3 stage 8 retires the object, and there are - // then no rows at all.) - if (ql.getObject?.('sys_permission_set')) { - const rows = await ql.find('sys_permission_set', { where: { name: DELEGATE_SET }, context: SYSTEM_CTX }); - for (const row of rows) await ql.delete('sys_permission_set', { where: { id: row.id }, context: SYSTEM_CTX }); - expect(await ql.find('sys_permission_set', { where: { name: DELEGATE_SET }, context: SYSTEM_CTX })).toEqual([]); - } + // The door reads the catalog, and no row stands for the source: C3 + // stage 8 retired the `sys_permission_set` row object. + expect(ql.getObject?.('sys_permission_set')).toBeFalsy(); const res = await clone(token, DELEGATE_SET, { name: DELEGATE_COPY, label: 'My Field Ops Delegate' }); const text = await res.clone().text(); diff --git a/packages/qa/dogfood/test/wall-catalog-refusal.dogfood.test.ts b/packages/qa/dogfood/test/wall-catalog-refusal.dogfood.test.ts index 54813c3396a..823b0bad08f 100644 --- a/packages/qa/dogfood/test/wall-catalog-refusal.dogfood.test.ts +++ b/packages/qa/dogfood/test/wall-catalog-refusal.dogfood.test.ts @@ -22,10 +22,9 @@ // Pinned here: // - walled: org A's catalog writes at `/meta` (save, create, delete) answer // `403 FORBIDDEN`, and B's resolution does not move; -// - walled: the data door's catalog creates answer `403 PERMISSION_DENIED` -// while the catalog row objects are registered, and `404 OBJECT_NOT_FOUND` -// once C3 stage 8 retires them; either way nothing reaches the environment -// ledger; +// - walled: the data door's catalog creates answer `404 OBJECT_NOT_FOUND` +// (C3 stage 8 retired the catalog row objects), and nothing reaches the +// environment ledger; // - walled: the permission-set clone door refuses org A a clone // `403 PERMISSION_DENIED`, and nothing is saved; // - walled CONTROLS: org A still assigns a catalog permission set to its own @@ -172,24 +171,19 @@ for (const posture of ['isolated', 'group'] as const) { expect((await definitionOf('permission', SHARED))?.item?.name).toBe(SHARED); }); - it('the data door refuses org A a catalog create, and nothing reaches the environment ledger', async () => { - // While the catalog row objects are registered the walled data-door - // refusal answers; once C3 stage 8 retires them (ADR-0131 D3) the data - // door serves no such object at all. - const refusal = (object: string) => (ql.getObject?.(object) - ? { status: 403, code: 'PERMISSION_DENIED' } - : { status: 404, code: 'OBJECT_NOT_FOUND' }); + it('the data door serves org A no catalog object 404 OBJECT_NOT_FOUND, and nothing reaches the environment ledger', async () => { + // C3 stage 8 retired the catalog row objects (ADR-0131 D3): the catalog + // is written through the metadata door alone. + expect(ql.getObject?.('sys_permission_set')).toBeFalsy(); + expect(ql.getObject?.('sys_position')).toBeFalsy(); const set = await stack.apiAs(tokA, 'POST', '/data/sys_permission_set', { name: 'w_dd_set', label: 'Data door set' }); - expect(await envelopeOf(set)).toEqual(refusal('sys_permission_set')); + expect(await envelopeOf(set)).toEqual({ status: 404, code: 'OBJECT_NOT_FOUND' }); expect((await definitionOf('permission', 'w_dd_set'))?.item).toBeFalsy(); const position = await stack.apiAs(tokA, 'POST', '/data/sys_position', { name: 'w_dd_pos', label: 'Data door position' }); - expect(await envelopeOf(position)).toEqual(refusal('sys_position')); + expect(await envelopeOf(position)).toEqual({ status: 404, code: 'OBJECT_NOT_FOUND' }); expect((await definitionOf('position', 'w_dd_pos'))?.item).toBeFalsy(); - if (ql.getObject?.('sys_position')) { - expect(await ql.find('sys_position', { where: { name: 'w_dd_pos' }, context: SYS })).toEqual([]); - } }); it("the clone door refuses org A a clone of an environment set 403 PERMISSION_DENIED, and nothing is saved", async () => { diff --git a/packages/spec/src/api/error-code-ledger.zod.ts b/packages/spec/src/api/error-code-ledger.zod.ts index 373a846df7c..3dd3b8e8623 100644 --- a/packages/spec/src/api/error-code-ledger.zod.ts +++ b/packages/spec/src/api/error-code-ledger.zod.ts @@ -1254,7 +1254,7 @@ export const ERROR_CODE_LEDGER = { // its casing and every other package's rows are unchanged. // `PackagedPermissionSetLockedError` and // `PackagedPermissionSetProvenanceUnknownError` - // (`packaged-permission-set-lock.ts`), 403: a data-path write targets a + // (`packaged-permission-set-lock.ts`), 403: a metadata-door save targets a // package-declared permission set, or one whose provenance cannot be // determined (fail-closed). Deliberately the SAME envelope // `@objectstack/metadata-protocol`'s ADR-0005 tier gate answers for this @@ -1264,32 +1264,6 @@ export const ERROR_CODE_LEDGER = { 'NOT_OVERRIDABLE', 'SUGGESTION_NOT_FOUND', 'SUGGESTION_STATE', // suggestion exists but is not in a confirmable/dismissable state - // [commit 8f6d83147] The data door's duplicate-name refusal on `sys_permission_set` - // — `PermissionSetNameConflictError` (`errors.ts`), thrown by the - // ADR-0094 D3 write-through middleware's insert leg - // (`permission-set-projection.ts`) when a set with that machine name - // already exists. `code` / `status` via the package's exported - // `PERMISSION_SET_NAME_CONFLICT_CODE` / `PERMISSION_SET_NAME_CONFLICT_STATUS`. - // - // THIRD EMITTER of a code `@objectstack/rest` (SQL conflict) and - // `@objectstack/driver-memory` (in-memory uniqueness refusal) already - // register, and the wire identity is deliberately the SAME for the reason - // their rows give: this object declares `{ fields: ['name'], unique: - // 'organization' }`, so the very same collision answers `409 - // UNIQUE_VIOLATION` when the index catches it instead of this pre-check - // (the reading recorded on that index, #8554). A second spelling here - // would make one condition answer two envelopes depending only on which - // layer got there first. Per this file's header, a code emitted by - // several packages is listed once per emitting package — provenance, not - // identity; the union, its casing and every other package's rows are - // unchanged. - // - // Wire-reachable by the test the "Retiring a code" section applies - // (#8035), measured live on `examples/app-showcase` over a cookie session: - // `POST /api/v1/data/sys_permission_set {"name":""}` - // answers `409` with this code on the flat `{ error, code }` responder - // (`mapDataError`'s declared-status 4xx arm, `thrownCodeFields`). - 'UNIQUE_VIOLATION', ], '@objectstack/plugin-webhooks': [ // [#13353] The redeliver endpoint's malformed-body refusal — the plugin diff --git a/packages/spec/src/api/protocol.zod.ts b/packages/spec/src/api/protocol.zod.ts index af9b56ceab9..15614b34dbf 100644 --- a/packages/spec/src/api/protocol.zod.ts +++ b/packages/spec/src/api/protocol.zod.ts @@ -887,7 +887,7 @@ export const SaveMetaItemResponseSchema = lazySchema(() => z.object({ }).optional().describe( 'Outcome of the awaited ADR-0094 mutation projector — the post-persist step ' + 'that materializes this metadata into its derived data-plane read model ' - + '(e.g. `permission` → `sys_permission_set`). Present ONLY when a projector ' + + '(e.g. `datasource` → its live connection pool). Present ONLY when a projector ' + 'is registered for this metadata type, which is why it is optional: its ' + 'absence means "no projector ran", never "the projection failed". ' + 'Best-effort by design — a projector failure is reported here and logged, ' @@ -1044,7 +1044,8 @@ export const PublishMetaItemRequestSchema = lazySchema(() => z.object({ * - `seedApplied` — only when the published type is `seed`. Publishing a seed * is what makes its rows live, so the row materialization rides along. * - `materializeApplied` — only when an ADR-0086 P2 publish materializer is - * registered for the type (e.g. `permission` → `sys_permission_set`). + * registered for the type (e.g. `flow`, whose draft credentials become the + * live ones). * - `projectionApplied` — only when an ADR-0094 mutation projector is * registered for the type. Same field, same meaning, as on * {@link SaveMetaItemResponseSchema}: both write doors run the projector. @@ -1116,8 +1117,8 @@ export const PublishMetaItemResponseSchema = lazySchema(() => z.object({ error: z.string().optional().describe('Materializer failure message, present only when `success` is false.'), }).optional().describe( 'Outcome of the ADR-0086 P2 publish-time materializer — the step that ' - + 'projects the published body into its data-plane row (e.g. `permission` ' - + '→ `sys_permission_set`, under the owning package). Present ONLY when a ' + + 'applies the published body to its data-plane state (e.g. `flow`, whose ' + + 'draft credentials become the live ones). Present ONLY when a ' + 'materializer is registered for this metadata type, which is why it is ' + 'optional: its absence means "no materializer ran", never "it failed". ' + 'Best-effort, same contract as `seedApplied`.', @@ -1375,7 +1376,7 @@ export const PublishPackageDraftsResponseSchema = lazySchema(() => z.object({ ), }).optional().describe( 'ADR-0086 P2 — aggregate result of publish-time materializers across ' - + 'the batch (e.g. `permission` → `sys_permission_set`), including ' + + 'the batch (e.g. `flow`, whose draft credentials become the live ones), including ' + 'side-effect failures surfaced by the per-item effects loop. Present ' + 'ONLY when at least one published item had a registered materializer ' + 'or a side-effect failure. Best-effort, same contract as ' diff --git a/packages/spec/src/identity/eval-user.zod.ts b/packages/spec/src/identity/eval-user.zod.ts index f0ae93559bd..1d89c3c3e21 100644 --- a/packages/spec/src/identity/eval-user.zod.ts +++ b/packages/spec/src/identity/eval-user.zod.ts @@ -21,7 +21,7 @@ import type { PermissionSet } from '../security/permission.zod'; * ## Which axis `positions` is — and which it is not (#15136) * * It is the **security** axis: the built-in identity names plus the - * `sys_position` names the principal actually holds through `sys_user_position` + * position names the principal actually holds through `sys_user_position` * (ADR-0057 D4) and their active membership, plus the `everyone` anchor * (ADR-0090 D5) — exactly the set `/auth/me/permissions` reports and * `resolveUserAuthzGrants` resolves. Every surface derives it from that one @@ -192,7 +192,7 @@ export const ORGANIZATION_ADMIN_GRANTS: readonly string[] = [ ORGANIZATION_ADMIN_NO_BYPASS, ] as const; -/** Human-readable metadata for the built-in identity names (seeded into `sys_position`; AI grounding). */ +/** Human-readable metadata for the built-in identity names (declared as positions in the security catalog; AI grounding). */ export const BUILTIN_IDENTITY_METADATA: Record = { [BUILTIN_IDENTITY_PLATFORM_ADMIN]: { label: 'Platform Admin', description: 'Platform operator (SaaS admin). NOT a tenant user role.' }, [BUILTIN_IDENTITY_ORG_OWNER]: { label: 'Organization Owner', description: 'Organization owner within a tenant.' }, @@ -225,7 +225,7 @@ export const EvalUserSchema = lazySchema(() => * position names. The SECURITY axis (#15136) — never the better-auth * `sys_user.role` scalar, which stays published as `user.role`. */ - positions: z.array(z.string()).default([]).describe('Canonical position/identity names the user holds — built-in identity names plus sys_position assignments, scope-resolved (ADR-0068 D3). The security axis, the same set /auth/me/permissions reports; NOT the better-auth user.role scalar, which remains published as user.role'), + positions: z.array(z.string()).default([]).describe('Canonical position/identity names the user holds — built-in identity names plus the positions assigned through sys_user_position, scope-resolved (ADR-0068 D3). The security axis, the same set /auth/me/permissions reports; NOT the better-auth user.role scalar, which remains published as user.role'), /** * The `PLATFORM_ADMIN` standing of ADR-0095 D3: true when the platform * resolves the user, per request, to the `PLATFORM_ADMIN` posture rung — diff --git a/packages/spec/src/identity/position.zod.ts b/packages/spec/src/identity/position.zod.ts index 6a75395d2b9..97b27155cef 100644 --- a/packages/spec/src/identity/position.zod.ts +++ b/packages/spec/src/identity/position.zod.ts @@ -61,8 +61,8 @@ export const PositionSchema = lazySchema(() => strictObject( permissions: '`permissions` is not a Position field — there are no direct position-level ' + 'permission strings anywhere on the platform: capability reaches a position ' + - 'ONLY through the permission sets it names in `permissionSets`. The `sys_position` ' + - 'row column of the same name — a "JSON-serialized array of permission ' + + 'ONLY through the permission sets it names in `permissionSets`. The column of the same ' + + 'name on the retired `sys_position` row — a "JSON-serialized array of permission ' + 'strings" textarea no producer ever wrote and no runtime path ever read — ' + 'was retired under ADR-0049 enforce-or-remove. Delete the key; to ' + 'grant capability, name permission sets in `permissionSets` instead.', @@ -73,14 +73,14 @@ export const PositionSchema = lazySchema(() => strictObject( }, { /** Identity */ - // [#8468] "unique per organization", not bare "unique". This `describe()` is - // the source of the generated reference page's `name` row - // (`content/docs/references/identity/position.mdx`), so the bare wording - // published the installation-wide reading that `sys_position`'s declared - // index accidentally materialized — as if it had been intended. The ruling of - // 2026-08-13 scopes the name per organization; the text now says so. + // [ADR-0131 D3] The catalog is environment-level: there is no + // organization-level catalog, so a name is one item in the security catalog + // the environment serves. This `describe()` is the source of the generated + // reference page's `name` row (`content/docs/references/identity/position.mdx`); + // its earlier "unique per organization" (#8468) read the retired + // `sys_position` row's index. name: SnakeCaseIdentifierSchema.describe( - 'Position name, unique per organization (lowercase snake_case)', + 'Position name, unique within the security catalog the environment serves (lowercase snake_case)', ), label: z.string().describe('Display label (e.g. VP of Sales)'), @@ -96,8 +96,8 @@ export const PositionSchema = lazySchema(() => strictObject( * This key replaced the `sys_position_permission_set` junction as the * position → set binding: the authorization resolver reads this list and no * junction row. A name that resolves to no permission set confers nothing. - * A set the catalog row deactivates confers nothing either: the `active` - * flag stays on the row until it gains a definition home. + * A set deactivated in the activation ledger (`sys_metadata_activation`) + * confers nothing either. * * The built-in `everyone` anchor (ADR-0090 D5) is declared by the platform * with the deployment's baseline sets; a package does not name it here, it diff --git a/packages/spec/src/kernel/metadata-plugin.zod.ts b/packages/spec/src/kernel/metadata-plugin.zod.ts index b6f2a5ab24a..5a652a7c247 100644 --- a/packages/spec/src/kernel/metadata-plugin.zod.ts +++ b/packages/spec/src/kernel/metadata-plugin.zod.ts @@ -1163,8 +1163,8 @@ export const DEFAULT_METADATA_TYPE_REGISTRY: MetadataTypeRegistryEntryParsed[] = // rows for either type in-repo at rollback. // // Blast radius, measured while landing commit ee58392e1: plugin-security's ADR-0094 - // write-through (`permission-set-projection.ts`) routes data-door edits of - // permission sets into `saveMetaItem`. Runtime-created sets — including + // write-through (`permission-set-projection.ts`, retired with C3 stage 7a) + // routed data-door edits of permission sets into `saveMetaItem`. Runtime-created sets — including // package-bound rows MATERIALIZED through the metadata door, whose // provenance is `sys_metadata`, not an artifact — ride `allowRuntimeCreate` // (still `true`) and keep working; a data-door edit of a CODE-DECLARED diff --git a/packages/spec/src/security/permission.zod.ts b/packages/spec/src/security/permission.zod.ts index dd2382b0148..8a7b5c014ec 100644 --- a/packages/spec/src/security/permission.zod.ts +++ b/packages/spec/src/security/permission.zod.ts @@ -807,22 +807,20 @@ export const PermissionSetSchema = lazySchema(() => strictObject( 'believed a capability boundary was declared that the runtime never saw.', }, { - // [#8554] "unique per organization", not bare "unique". This `describe()` is - // the SOURCE of the generated reference page - // (`content/docs/references/security/permission.mdx`), so the bare wording had - // already reached authors as published contract — the same route the - // `sys_position` accident took (#8468). `sys_permission_set` is tenant-scoped - // and the declared index is now `unique: 'organization'`. - /** Permission set name, unique per organization */ - name: SnakeCaseIdentifierSchema.describe('Permission set name, unique per organization (lowercase snake_case)'), + // [ADR-0131 D3] The catalog is environment-level: there is no + // organization-level catalog, so a name is one item in the security catalog + // the environment serves. This `describe()` is the SOURCE of the generated + // reference page (`content/docs/references/security/permission.mdx`); its + // earlier "unique per organization" (#8554) read the retired + // `sys_permission_set` row's index. + /** Permission set name, unique within the security catalog the environment serves */ + name: SnakeCaseIdentifierSchema.describe('Permission set name, unique within the security catalog the environment serves (lowercase snake_case)'), /** Display label */ label: z.string().optional().describe('Display label'), /** - * Human-readable description, surfaced on `sys_permission_set` (list columns - * + form) and the Setup projection (`permission-set-projection.ts` reads it - * from the authored set). The built-in default sets have always authored it — + * Human-readable description of the set. The built-in default sets have always authored it — * but until #4001 the schema could not represent it, so it was silently * stripped at parse (the ADR-0078 §3 inverse-drift class); the `.strict()` * gate surfaced the gap and it is now declared. diff --git a/scripts/adr-anchors/packages__plugins__plugin-security__src__walled-catalog-data-door.ts.json b/scripts/adr-anchors/packages__plugins__plugin-security__src__walled-catalog-data-door.ts.json deleted file mode 100644 index 0f79c307ca6..00000000000 --- a/scripts/adr-anchors/packages__plugins__plugin-security__src__walled-catalog-data-door.ts.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "file": "packages/plugins/plugin-security/src/walled-catalog-data-door.ts", - "adrs": [ - "ADR-0131" - ], - "invariant": "Under a walled posture (`group` / `isolated`) the security catalog — positions, permission sets, capabilities — is environment-level and every organization resolves it by name, so a data-door create, update or delete of a catalog row is the platform operator's alone (ADR-0131 D3: tenants are refused creation and may only assign). Assignment rows are never judged here; `single` is inert." -} diff --git a/scripts/check-durability-degradation-log-level.mjs b/scripts/check-durability-degradation-log-level.mjs index 62abfc5e3a2..e92845fec29 100644 --- a/scripts/check-durability-degradation-log-level.mjs +++ b/scripts/check-durability-degradation-log-level.mjs @@ -153,7 +153,7 @@ * them: the BATCH SUMMARY of the failures the catch just counted. * * packages/plugins/plugin-email/src/outbox-sweep.ts "N stranded sys_email row(s) could NOT be delivered" - * packages/plugins/plugin-security/src/permission-set-projection.ts "N FAILED backfill(s)" + * packages/plugins/plugin-security/src/permission-set-projection.ts "N FAILED backfill(s)" (since retired) * * The repair made the split WORSE, not better: against an `error`-less sink the * per-row line now lands at `warn` while the total stays silent, so the detail @@ -412,10 +412,6 @@ const DURABILITY_CRITICAL_CALLEES = new Map([ 'persistGrantNameBackfillRecord', "The one-time grant-name backfill (ADR-0131 D4) named the grants it could, but its `sys_migration` verdict row was never written. Every log line reads clean and the names it wrote stand, while the record that makes the pass ONE-TIME is absent, so every later boot scans the whole grant table again and re-reports the grants it cannot name.", ], - [ - 'persistPositionBackfillRecord', - "The one-time row-only position backfill (ADR-0131 D3) gave the positions it could their environment definitions, but its `sys_migration` verdict row was never written. Every log line reads clean and the definitions it wrote stand, while the record that makes the pass ONE-TIME is absent, so every later boot scans the whole position table again and re-reports the positions it cannot give a definition.", - ], [ 'runWideningAlters', "The widening ALTER never ran — the MySQL column keeps its legacy zero-precision type (`TIMESTAMP` for a `Field.datetime`, `TIME` for a `Field.time`) while the object stays registered and served, so every subsequent write silently drops the milliseconds the canonical storage form promises are always present: a `TIMESTAMP` truncates them, and a `TIME(0)` ROUNDS a fractional literal, changing the wall clock it was asked to store. Reads come back looking clean because the value that was stored is the value that is returned, and nothing else reports the column is still un-widened (#9609).", diff --git a/scripts/measure-durability-swallow-family.mjs b/scripts/measure-durability-swallow-family.mjs index 2f9468962f2..22619cb53f5 100644 --- a/scripts/measure-durability-swallow-family.mjs +++ b/scripts/measure-durability-swallow-family.mjs @@ -277,8 +277,9 @@ const GATE_VOCABULARY_IDENT = 'DURABILITY_CRITICAL_CALLEES'; * (`packages/spec/src/contracts/data-driver.ts`). * - `objectql` -- the ObjectQL-level write surface the seeders use. * - `seed-wrapper` -- the `catch { return null; }` helpers this family IS - * (`permission-set-projection.ts` and its per-file - * copies) that the gate does NOT declare. `tryInsert` + * (the per-file copies of the retired + * `permission-set-projection.ts` one) that the gate + * does NOT declare. `tryInsert` * and `tryUpdate` sat here until the ruling's last step * handed them over (PR #15458); they are * `gate-vocabulary` now, because that is what the tree @@ -331,7 +332,6 @@ const WRITE_SHAPED_CALLEES = new Map([ ['persistSeedTenancyReceiptRow', 'gate-vocabulary'], ['persistLedgerDecisionRow', 'gate-vocabulary'], ['persistGrantNameBackfillRecord', 'gate-vocabulary'], - ['persistPositionBackfillRecord', 'gate-vocabulary'], ['recordLog', 'gate-vocabulary'], ['runWideningAlters', 'gate-vocabulary'], ['applyConfigPatch', 'gate-vocabulary'],