diff --git a/.changeset/15204-s1-position-permission-sets.md b/.changeset/15204-s1-position-permission-sets.md new file mode 100644 index 00000000000..f00986eb63d --- /dev/null +++ b/.changeset/15204-s1-position-permission-sets.md @@ -0,0 +1,52 @@ +--- +'@objectstack/spec': minor +'@objectstack/core': minor +'@objectstack/plugin-security': minor +'@objectstack/plugin-auth': minor +'@objectstack/platform-objects': patch +--- + +feat(spec,core)!: a position's permission sets are declared in `permissionSets` on its definition, and the authorization resolver reads positions, their sets and the set bodies from the security catalog (ADR-0131 D3/D4) + +Clause-②: yes + + + +**BREAKING** (the resolver stops reading the position → permission-set junction), shipped as `minor` under the launch-window convention for breaking changes. + +**FROM → TO.** A position's permission sets were `sys_position_permission_set` rows. They are now declared in `permissionSets` on the position's definition; junction rows no longer grant. + +```ts +definePosition({ name: 'sales_rep', label: 'Sales Representative', permissionSets: ['crm_sales_user'] }); +``` + +The one-line fix for an app that bound sets to its positions with a boot-time binder: name the sets in `permissionSets` on each position. + +**`@objectstack/spec`.** `PositionSchema` accepts `permissionSets`: a list of permission-set names (lowercase snake_case). Before, the key was refused with a pointer to the junction. Each name is resolved in the environment catalog when a holder's grants are resolved. A name the catalog does not hold confers nothing. + +**`@objectstack/core`.** `resolveUserAuthzGrants`, and every surface built on it (`resolveAuthzContext`, `hasPlatformAdminStanding`, the permission explainer, `runAs: 'user'` automation), reads the security catalog the security plugin binds to its engine (`bindSecurityCatalogReader`, `securityCatalogReaderOf`): + +- A held position grants the sets its definition's `permissionSets` names. The junction rows and the read of set rows by the junction's ids are gone. +- A held set, granted directly or through a position, has the body (`systemPermissions`, `tabPermissions`) of its catalog definition. A set that only a `sys_permission_set` row carries, with no definition, confers nothing — including a row inserted directly (a script or a harness, outside the product's doors). +- **Deactivation moves to the activation ledger** (ADR-0049, as ADR-0131 D3 and ADR-0126 §4 place it). A position or a permission set is switched off by a `sys_metadata_activation` row of type `position` or `permission` whose `active` is false, deployment-wide. The `sys_position` and `sys_permission_set` rows are no longer read at all, their `active` column included. A name with no ledger row is in effect. A composition that does not register the ledger object (no `PlatformObjectsPlugin`) switches nothing off and issues no ledger read. +- The exported `ADMIN_STANDING_SURFACE` follows what the resolver reads: `sys_permission_set`, `sys_position` and `sys_position_permission_set` leave it, and `sys_metadata_activation` enters in the role `derives`, with the columns `metadata_type`, `name` and `active`. The `kind` union of `ADMIN_STANDING_NON_TABLE_INPUTS` gains `security-catalog`, for the catalog's `admin_full_access` definition. +- `convertDeactivatedCatalogRows` turns the rows' `active: false` flags into ledger rows. A name some organizations switched off and others kept on is reported `conflicting`, because the ledger is deployment-wide. The upgrade ceremony applies it beside `convertPositionBindingRows`. ⛔ It never runs at boot. +- Platform standing still requires an organization-less `admin_full_access` user grant, now resolved by name in the catalog. +- An engine no security plugin started on has no catalog bound, and resolves no set and no position-bound set. That is what an engine with no permission tables provisioned resolved before. +- `convertPositionBindingRows` converts the junction rows into each position's `permissionSets`. It answers per name `declared`, `converted` or `conflicting`, and it lists dangling bindings. The upgrade ceremony applies it. ⛔ It never runs at boot. + +**`@objectstack/plugin-security`.** The plugin binds the catalog read (engine registry and metadata service) to its engine at `start()`. At `kernel:ready` the built-in `everyone` anchor declares the deployment's baseline in its `permissionSets`: the app's `isDefault` set and the platform's `member_default`. It is judged by the same high-privilege check against the stack's declared capabilities that the boot binding used. Under a wall the envelope now carries `member_default` beside the app's set, as it did under `single`. `member_default` carries no system or tab permission, and enforcement already applied it to every human as the additive baseline. A Setup edit of a position row under `single` keeps the `permissionSets` the stored definition names (the row has no column for it). + +**`@objectstack/plugin-auth`.** The last-administrator guard follows what the resolver reads. Its standing-key map judges `sys_metadata_activation` (`metadata_type`, `name`, `active`) in place of `sys_permission_set`. A new hook refuses a ledger write that switches `admin_full_access` off (`refuseLedgerSwitchingAdminOff`). Its `sys_permission_set` hooks stay registered, unchanged. + +**`@objectstack/platform-objects`.** The position metadata form gains a `permissionSets` row, translated in zh-CN, ja-JP and es-ES. + +**`@objectstack/plugin-security`.** The name-fold warning's remedy now names the position definition's `permissionSets`, not a junction row. + +**What stops granting at upgrade, and what to do.** + +- **A deactivation made in Setup.** The Deactivate action on a position or a permission set writes the row's `active` column, which no longer switches anything off. ⚠️ Until the upgrade ceremony converts the rows (`convertDeactivatedCatalogRows`), a position or set deactivated that way **grants again**. No enable/disable door accepts the `permission` or `position` type yet, so a new deactivation has no in-product path. To revoke now, remove the assignments or the binding. + +- **A binding made in Setup.** Binding a permission set on a position's page writes a `sys_position_permission_set` row, and so does confirming an audience-binding suggestion. That row no longer grants. Until Setup writes the definition (objectui C9), declare the binding in the position's `permissionSets` through the metadata door (`PUT /api/v1/meta/position/:name`, platform administrator) or in the app's code. +- **Under a wall (`group`, `isolated`): a position or permission set an organization authored in Setup.** It is a data row with no definition in the environment catalog (ADR-0131 D3), so it stops granting (ruling #15196 Q3 = A). To keep it, a platform administrator re-creates it as environment metadata through the metadata door, under the same name, with its `permissionSets` or its set body. No command promotes a row in place today. +- **Existing junction rows** are converted into definitions by the ADR-0131 upgrade ceremony (`os migrate`), which applies `convertPositionBindingRows`. A name that organizations bound differently is reported `conflicting` and is never merged. diff --git a/content/docs/data-modeling/objects.mdx b/content/docs/data-modeling/objects.mdx index b772f3a5e32..9e02192755e 100644 --- a/content/docs/data-modeling/objects.mdx +++ b/content/docs/data-modeling/objects.mdx @@ -332,9 +332,9 @@ export const SysHolidayCalendar = ObjectSchema.create({ ``` The reason is leverage, not authorization. The bucket's charter members are the -RBAC link tables — `sys_user_position`, `sys_user_permission_set`, -`sys_position_permission_set` — which are the grant surface of the entire -permission model. Every row a CSV import writes still passes the delegated-admin +RBAC link tables — `sys_user_position` and `sys_user_permission_set`, the grant +surface of the entire permission model, and `sys_position_permission_set`, +whose rows no longer grant (a position's sets are its `permissionSets`). Every row a CSV import writes still passes the delegated-admin gate, RLS and permission-set adjudication one at a time, so an admin who cannot grant a permission set by hand cannot grant it by file either. What differs is blast radius: row by row, one misclick affects one person; one wrong CSV is a diff --git a/content/docs/deployment/environment-variables.mdx b/content/docs/deployment/environment-variables.mdx index bd22e11b997..3d694a5eb19 100644 --- a/content/docs/deployment/environment-variables.mdx +++ b/content/docs/deployment/environment-variables.mdx @@ -67,7 +67,7 @@ read at startup unless noted otherwise. Boolean variables accept `true` / `false | `OS_DEV_CRYPTO_KEY` | string | — | Development convenience crypto key, consulted after `OS_SECRET_KEY`. Do not use in production. | | `OS_CLUSTER_DRIVER` | string | `memory` | Cluster coordination driver id. When set to anything other than `memory`, the runtime treats the deployment as multi-node (and requires `OS_SECRET_KEY`). Non-memory drivers are opt-in sibling packages (e.g. `redis` via `@objectstack/service-cluster-redis`) — see [Cluster](/docs/kernel/cluster). | | `OS_REDIS_URL` | url | — | Connection URL passed to a non-memory cluster driver (e.g. `OS_CLUSTER_DRIVER=redis`). | -| `OS_AUTHZ_GRANTS_CACHE_TTL_MS` | number | `0` | Staleness bound, in milliseconds, for the cross-request authorization grants cache (#11633). `0` (the default) means **off** — a real path, not a degenerate TTL. A non-zero value caches the resolved authorization grants envelope (`resolveUserAuthzGrants` — positions, permission sets, posture, RLS peer ids) per `(user, organization, seed)` on each node. On the writing node the cache is retired **immediately** by any write to a watched authorization object (`sys_member`, `sys_user_position`, `sys_user_permission_set`, `sys_position`, `sys_position_permission_set`, `sys_permission_set`, `sys_user`) and by any `metadata.changed` (a permission set can be declared in metadata); `sys_session` is deliberately not watched, so session-activity writes cannot flush it. Entries additionally expire at the earliest upcoming ADR-0091 validity boundary — `min(ttl, nextBoundary)` — because a `valid_until` passing is a permission change with **no write anywhere**. The permission explainer and `runAs:'user'` automation runs always read uncached. When a value is set with no cross-node invalidation bus — no cluster service, or the in-process `memory` driver, which fans out to nobody — the boot says so loudly, every time: the TTL is then the whole bound on how long this replica may honour a grant another replica revoked. A malformed value is treated as `0` and warned about rather than silently read as "disabled". Deployment config only; it is deliberately not a settings row, because a cached path must not serve the knob that bounds the cache. | +| `OS_AUTHZ_GRANTS_CACHE_TTL_MS` | number | `0` | Staleness bound, in milliseconds, for the cross-request authorization grants cache (#11633). `0` (the default) means **off** — a real path, not a degenerate TTL. A non-zero value caches the resolved authorization grants envelope (`resolveUserAuthzGrants` — positions, permission sets, posture, RLS peer ids) per `(user, organization, seed)` on each node. On the writing node the cache is retired **immediately** by any write to a watched authorization object (`sys_member`, `sys_user_position`, `sys_user_permission_set`, `sys_position`, `sys_position_permission_set`, `sys_permission_set`, `sys_user`, `sys_metadata_activation`) and by any `metadata.changed` (a permission set can be declared in metadata); `sys_session` is deliberately not watched, so session-activity writes cannot flush it. Entries additionally expire at the earliest upcoming ADR-0091 validity boundary — `min(ttl, nextBoundary)` — because a `valid_until` passing is a permission change with **no write anywhere**. The permission explainer and `runAs:'user'` automation runs always read uncached. When a value is set with no cross-node invalidation bus — no cluster service, or the in-process `memory` driver, which fans out to nobody — the boot says so loudly, every time: the TTL is then the whole bound on how long this replica may honour a grant another replica revoked. A malformed value is treated as `0` and warned about rather than silently read as "disabled". Deployment config only; it is deliberately not a settings row, because a cached path must not serve the knob that bounds the cache. | --- diff --git a/content/docs/permissions/authorization.mdx b/content/docs/permissions/authorization.mdx index 426e851a9cb..7af47d16388 100644 --- a/content/docs/permissions/authorization.mdx +++ b/content/docs/permissions/authorization.mdx @@ -34,7 +34,7 @@ Authorization splits into three concerns that stay decoupled: resource merely *references* one by name (see Requirement, below). 2. **Assignment** — *who holds it* — permission sets / positions / user bindings (`sys_permission_set`, `sys_position`, `sys_user_permission_set`, - `sys_position_permission_set`, `sys_user_position`). Runtime records, + `sys_user_position`, and the sets a position names in its `permissionSets`). Runtime records, maintained by admins in Setup — and, since ADR-0090 D12, a **governed** surface: writing them requires tenant-level administration or a covering delegated `adminScope`, never just CRUD on the tables. @@ -232,8 +232,9 @@ one of two doors, each writing only what it owns: unaffected while a set is still a draft (drafts never enter the active resolve). - **Environment-admin door** (`metadata-admin`) — the cross-package all-objects - matrix plus subject **assignment** (`sys_user_permission_set`, - `sys_position_permission_set`), edited **live** (config). It owns env-authored + matrix plus subject **assignment** (`sys_user_permission_set`), edited + **live** (config); the sets a position distributes are its definition's + `permissionSets`. It owns env-authored sets (`managedBy` `platform`/`user`) and assignments — not package sets. Since ADR-0094 the set **definition** itself has one authoritative store — the metadata layer: an env-door write to `sys_permission_set` (the Setup @@ -395,26 +396,32 @@ substrate above. ## Grant lifecycle: the `active` switch (ADR-0049) Validity windows above date a **user's grant row**. The second lifecycle -control dates the **catalogue row itself**: `sys_permission_set.active` and -`sys_position.active`, the switch behind the Deactivate action on both objects. -It answers a different question — "switch this grant off for everyone, without -deleting it or unwinding the assignments" — and it is enforced in the same -place, by the same discipline: **resolution-time filtering, fail-closed**, in +control switches off the **permission set or position itself**: a +`sys_metadata_activation` row of type `permission` or `position` whose `active` +reads false. The activation ledger is deployment-wide. It answers a different +question — "switch this grant off for everyone, without deleting it or +unwinding the assignments" — and it is enforced in the same place, by the same +discipline: **resolution-time filtering, fail-closed**, in `resolveAuthzContext`, with no cleanup job involved. +A catalogue row's own `active` column (`sys_permission_set.active`, +`sys_position.active`) no longer decides a grant. That column is what Setup's +Deactivate action writes today, so a Deactivate in Setup does not switch the +grant off. + - A deactivated **permission set** contributes nothing: not its name, not its `system_permissions`, not its `tab_permissions`. As with an expired grant, a deactivated unscoped `admin_full_access` no longer derives `platform_admin` — the flag is applied *before* the posture is derived, not after. A **configured** administrator is judged elsewhere: that anchor reads the deployment's configuration and the caller's own `sys_user` row, never the - catalogue row, so the flag does not decide their standing. + ledger, so the switch does not decide their standing. - A deactivated **position** stops carrying its permission sets, and its name stops appearing in `positions`, so a permission set that merely shares the position's name cannot resolve through it either. - Assignments are untouched. `sys_user_position` and - `sys_user_permission_set` rows stay exactly as they were, and re-activating - the catalogue row restores every grant it carried, at the next resolution. + `sys_user_permission_set` rows stay exactly as they were, and switching the + ledger row back on restores every grant it carried, at the next resolution. The explain engine reports a deactivated-but-held row with the same dedicated contributor state the expired case gets (`state: 'deactivated'`, "held — @@ -428,20 +435,19 @@ already walks: `sys_user_position` rows and direct `sys_user_permission_set` grants (a deactivated set reached only through an active position's linkage is not re-derived — that would replicate the resolver's aggregation). -**Absent is ACTIVE.** Only a stored value that really reads false takes a grant -away, so a row that predates the column keeps granting. The same predicate -(`isRowActive`) is used by every reader, including the last-administrator -guard's simulation — a guard that modelled "deactivated" differently from the -resolver would permit exactly the write it exists to refuse. - -**Deactivating the break-glass set is refused.** Switching `admin_full_access` -off un-makes every platform admin who holds it *through a grant row*, in one -write — and re-activating it needs the permission just lost. That write is -judged like deleting or renaming the row (ADR-0135 D5.2): it is refused while -it would leave the environment with no administrator who can sign in. The -enumeration behind that judgement counts **both** anchors, so an environment -whose administrators are configured rather than granted is not empty, and the -write is permitted there. Re-activation is never refused. +**Absent is ACTIVE.** Only a ledger row whose `active` reads false takes a +grant away; a permission set or position with no ledger row is in effect. The +last-administrator guard's enumeration still reads the catalogue row's +`active`, so it counts a row-deactivated `admin_full_access` as off and refuses +more than it must, never less. + +**Switching the break-glass set off is refused.** A ledger row switching +`admin_full_access` off would un-make every platform admin who holds it +*through a grant row*, in one write — and switching it back on needs the +permission just lost. The last-administrator guard refuses such a ledger +write outright, whatever the administrator population (ADR-0135 D5.2), and +refuses one whose target row it cannot read. Switching it back on is never +refused. Deactivation is an incident-response control, so what it does **not** touch is deliberate. Administration surfaces keep listing and editing a deactivated diff --git a/content/docs/permissions/delegated-administration.mdx b/content/docs/permissions/delegated-administration.mdx index 9bf0bfad9a3..ba00c335783 100644 --- a/content/docs/permissions/delegated-administration.mdx +++ b/content/docs/permissions/delegated-administration.mdx @@ -158,8 +158,8 @@ Two independent switches, and you need both: | An `adminScope`-carrying permission set | *What* the invitation may place (subtree + allowlist) | The gate refuses every placement — the role alone administers nothing | The role carries no ObjectStack authority of its own: it passes through as a -position name, and with no `sys_position_permission_set` binding it resolves to -nothing. **Role = can reach the endpoint. `adminScope` = what the endpoint +position name, and with no position of that name declaring `permissionSets` it +resolves to nothing. **Role = can reach the endpoint. `adminScope` = what the endpoint permits.** A default deployment is unaffected — nobody holds the role until you set it. diff --git a/content/docs/permissions/permission-sets.mdx b/content/docs/permissions/permission-sets.mdx index 025181fc002..20f43a137d6 100644 --- a/content/docs/permissions/permission-sets.mdx +++ b/content/docs/permissions/permission-sets.mdx @@ -211,7 +211,8 @@ process reload. ## Assigning permission sets -Grants are plain data rows: +A direct grant is a data row; a position's sets are declared on the position's +definition: ```typescript // Grant a set to a single user (direct grant) @@ -220,13 +221,21 @@ await objectql.object('sys_user_permission_set').insert({ permission_set_id: psId, }); -// Bind a set to a position (everyone holding it gets the set) -await objectql.object('sys_position_permission_set').insert({ - position_id: positionId, - permission_set_id: psId, +// A position names the sets it distributes (everyone holding it gets them) +import { definePosition } from '@objectstack/spec/identity'; + +export const salesRep = definePosition({ + name: 'sales_rep', + label: 'Sales Representative', + permissionSets: ['crm_sales_user'], }); ``` +Each name in `permissionSets` is resolved in the environment's catalog when a +holder's grants are resolved; a name the catalog does not hold confers nothing. +A `sys_position_permission_set` row confers nothing either: the position's +definition is the only binding that grants. + These writes are **governed** (ADR-0090 D12): tenant-level admins pass through to the ordinary checks; a delegated admin needs a covering `adminScope`; plain CRUD on the tables grants nothing by itself. Bindings to @@ -264,9 +273,11 @@ accepted set — it previously reached the query as a filter nothing matched, so a mistyped status returned an empty list that read as "no suggestions". All three require a tenant-level administrator (the anchors are tenant-level -only — D12). Confirm writes the `sys_position_permission_set` row **as the +only — D12). Confirm writes a `sys_position_permission_set` row **as the caller**, so the audience-anchor gate re-checks the forbidden-bits predicate -at the data layer; a suggestion whose binding was created out-of-band (boot +at the data layer. That row does not grant the set: what `everyone` grants is +the `permissionSets` its definition names, so a confirmed suggestion changes no +one's access until the set is named there. A suggestion whose binding was created out-of-band (boot baseline, manual bind) is marked `confirmed` automatically, and uninstalling the package prunes its pending suggestions. Studio surfaces pending suggestions after marketplace installs and in the Access pillar. diff --git a/content/docs/permissions/positions.mdx b/content/docs/permissions/positions.mdx index 203ce27aa89..3856ee87d39 100644 --- a/content/docs/permissions/positions.mdx +++ b/content/docs/permissions/positions.mdx @@ -5,9 +5,10 @@ description: "Positions are flat groups that hand permission sets to users — t --- A **position** (岗位) is a named, assignable distribution group: users hold -positions (`sys_user_position` rows), positions bind permission sets -(`sys_position_permission_set` rows), and a user's capability is the union of -every set reached that way plus direct grants. Positions carry **no capability +positions (`sys_user_position` rows), a position names the permission sets it +distributes in `permissionSets` on its definition, and a user's capability is +the union of every set reached that way plus direct grants. A +`sys_position_permission_set` row grants nothing. Positions carry **no capability of their own** — they only decide *who gets which sets*. > **Formerly "roles".** ADR-0090 D3 renamed the concept in one step and @@ -26,9 +27,9 @@ of their own** — they only decide *who gets which sets*. import type { Position } from '@objectstack/spec/identity'; export const positions: Position[] = [ - { name: 'sales_manager', label: 'Sales Manager' }, - { name: 'sales_rep', label: 'Sales Representative' }, - { name: 'service_agent', label: 'Service Agent' }, + { name: 'sales_manager', label: 'Sales Manager', permissionSets: ['crm_sales_manager'] }, + { name: 'sales_rep', label: 'Sales Representative', permissionSets: ['crm_sales_user'] }, + { name: 'service_agent', label: 'Service Agent', permissionSets: ['service_agent_access'] }, ]; ``` diff --git a/content/docs/permissions/profiles.mdx b/content/docs/permissions/profiles.mdx index 2efbf245d7d..27df81986ce 100644 --- a/content/docs/permissions/profiles.mdx +++ b/content/docs/permissions/profiles.mdx @@ -18,7 +18,7 @@ Everything a profile did is expressed with the remaining vocabulary: |:---|:---| | A user's baseline permissions | A permission set bound to the **`everyone`** audience anchor (default grants for all authenticated users), or the configured baseline set (`member_default`) — both **additive** (ADR-0090 D5) | | A package's default access posture | `isDefault: true` on the package's permission set — an install-time **suggestion** to bind it to `everyone`; the admin confirms, nothing auto-binds | -| A job function's permissions ("Sales Rep") | An ordinary permission set bound to a **[position](/docs/permissions/positions)** (`sales_rep`) via `sys_position_permission_set` | +| A job function's permissions ("Sales Rep") | An ordinary permission set named in a **[position](/docs/permissions/positions)**'s `permissionSets` (`sales_rep`) | | Tab/app visibility defaults | `tabPermissions` on any permission set (unchanged) | The former `isProfile` flag was deleted from `PermissionSetSchema`; the diff --git a/content/docs/references/api/package-api-assembled.mdx b/content/docs/references/api/package-api-assembled.mdx index 54454ca1bef..cc5ac42cb2f 100644 --- a/content/docs/references/api/package-api-assembled.mdx +++ b/content/docs/references/api/package-api-assembled.mdx @@ -127,7 +127,7 @@ Installed package row whose manifest is the assembled package body | **emailTemplates** | `{ name: string; label: string; category?: Enum<'auth' \| 'notification' \| 'workflow' \| 'marketing' \| 'custom'>; locale?: string; … }[]` | optional | Email Templates resolved by IEmailService.sendTemplate(`{ template, locale }`) | | **docs** | `{ name: string; label?: string; description?: string; content: string; … }[]` | optional | Package documentation — flat Markdown items compiled from src/docs/*.md (ADR-0046) | | **books** | `{ name: string; label?: string; description?: string; slug?: string; … }[]` | optional | Documentation navigation spines — ordered groups with derived membership (ADR-0046 §6) | -| **positions** | `{ name: string; label: string; description?: string; delegatable?: boolean; … }[]` | optional | Positions — flat capability-distribution groups (ADR-0090 D3) | +| **positions** | `{ name: string; label: string; description?: string; permissionSets?: string[]; … }[]` | optional | Positions — flat capability-distribution groups (ADR-0090 D3) | | **sharingRules** | `{ name: string; label?: string; description?: string; object: string; … }[]` | optional | Record Sharing Rules | | **apis** | `{ name: string; path: string; method: Enum<'GET' \| 'POST' \| 'PUT' \| 'DELETE' \| 'PATCH' \| 'HEAD' \| 'OPTIONS'>; summary?: string; … }[]` | optional | API Endpoints — declared endpoints are live from protocol 17; each is gated at publish (ADR-0121) | | **webhooks** | `{ name: string; label?: string; object?: string; triggers?: Enum<'create' \| 'update' \| 'delete' \| 'bulk_update' \| 'bulk_delete'>[]; … }[]` | optional | Outbound Webhooks | @@ -371,7 +371,7 @@ Installed package row whose manifest is the assembled package body | **emailTemplates** | `{ name: string; label: string; category?: Enum<'auth' \| 'notification' \| 'workflow' \| 'marketing' \| 'custom'>; locale?: string; … }[]` | optional | Email Templates resolved by IEmailService.sendTemplate(`{ template, locale }`) | | **docs** | `{ name: string; label?: string; description?: string; content: string; … }[]` | optional | Package documentation — flat Markdown items compiled from src/docs/*.md (ADR-0046) | | **books** | `{ name: string; label?: string; description?: string; slug?: string; … }[]` | optional | Documentation navigation spines — ordered groups with derived membership (ADR-0046 §6) | -| **positions** | `{ name: string; label: string; description?: string; delegatable?: boolean; … }[]` | optional | Positions — flat capability-distribution groups (ADR-0090 D3) | +| **positions** | `{ name: string; label: string; description?: string; permissionSets?: string[]; … }[]` | optional | Positions — flat capability-distribution groups (ADR-0090 D3) | | **sharingRules** | `{ name: string; label?: string; description?: string; object: string; … }[]` | optional | Record Sharing Rules | | **apis** | `{ name: string; path: string; method: Enum<'GET' \| 'POST' \| 'PUT' \| 'DELETE' \| 'PATCH' \| 'HEAD' \| 'OPTIONS'>; summary?: string; … }[]` | optional | API Endpoints — declared endpoints are live from protocol 17; each is gated at publish (ADR-0121) | | **webhooks** | `{ name: string; label?: string; object?: string; triggers?: Enum<'create' \| 'update' \| 'delete' \| 'bulk_update' \| 'bulk_delete'>[]; … }[]` | optional | Outbound Webhooks | diff --git a/content/docs/references/identity/position.mdx b/content/docs/references/identity/position.mdx index 0a619287f3e..ff1dc3a4d65 100644 --- a/content/docs/references/identity/position.mdx +++ b/content/docs/references/identity/position.mdx @@ -31,6 +31,7 @@ const result = PositionSchema.parse(data); | **name** | `string` | ✅ | Position name, unique per organization (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). | | **delegatable** | `boolean` | optional (default: `false`) | ADR-0091 D3: holders may self-service delegate this position, time-boxed (default false). | | **protection** | `{ lock: Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>; reason: string; docsUrl?: string }` | optional | Package author protection block — lock policy for this position. | | **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). | diff --git a/content/docs/references/ui/page.mdx b/content/docs/references/ui/page.mdx index 74e9453e0f8..9a0a374e261 100644 --- a/content/docs/references/ui/page.mdx +++ b/content/docs/references/ui/page.mdx @@ -178,7 +178,7 @@ View filter rule | **template** | `string` | optional (default: `"default"`) | Layout template name (e.g. "header-sidebar-main") | | **regions** | `{ name: string; width?: Enum<'small' \| 'medium' \| 'large' \| 'full'>; components: object[] }[]` | optional | Layout regions (header, main, sidebar, footer) with their components. Optional — list pages use interfaceConfig, slotted pages use slots, and an empty full page falls back to the synthesized default layout. | | **isDefault** | `boolean` | optional (default: `false`) | | -| **assignedProfiles** | `never` | optional | [REMOVED] `page.assignedProfiles` was removed in @objectstack/spec 17.5.0 (ADR-0090 D2, ADR-0049 enforce-or-remove) — it was named for the Profile concept ADR-0090 D2 deleted, and it gated nothing: no renderer, route or metadata read door ever read the key, so a page that "assigned profiles" stayed open to every caller who could reach it. Delete the key. Page audience is the permission set's: gate the DATA the page shows with the object's permission sets, and bind those sets to people through positions (`sys_position_permission_set`) — those are the checks the runtime actually runs. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | +| **assignedProfiles** | `never` | optional | [REMOVED] `page.assignedProfiles` was removed in @objectstack/spec 17.5.0 (ADR-0090 D2, ADR-0049 enforce-or-remove) — it was named for the Profile concept ADR-0090 D2 deleted, and it gated nothing: no renderer, route or metadata read door ever read the key, so a page that "assigned profiles" stayed open to every caller who could reach it. Delete the key. Page audience is the permission set's: gate the DATA the page shows with the object's permission sets, and bind those sets to people through positions (named in a position's `permissionSets`) — those are the checks the runtime actually runs. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **interfaceConfig** | `{ source?: string; columns?: string[] \| object[]; sort?: object[]; filterBy?: object[]; … }` | optional | Interface-level page configuration (for Airtable-style interface pages) | | **aria** | `{ ariaLabel?: string \| Record; ariaDescribedBy?: string; role?: string }` | optional | ARIA accessibility attributes | | **print** | `{ paperSize?: Enum<'A4' \| 'A5' \| 'Letter' \| 'Legal'>; orientation?: Enum<'portrait' \| 'landscape'>; margins?: object; repeatHeader?: boolean; … }` | optional | Print declaration — makes this page a print page, a document authored in the page's own blocks: paper size and orientation, margins in millimetres, the running header and footer (the page's own `header` / `footer` regions), page numbers and page-break hints, each mapped to print CSS. A print page is a `kind: 'full'` page of `type: 'record'`, `'home'` or `'app'` with its blocks in `regions`, and every block in it must come from the printable block subset: containers that draw every child (`page:section`, `page:card`, `page:footer`), field blocks (`record:details`, `record:highlights`), the child-record table `record:line_items`, `element:text`, `element:image`, `element:divider`, `element:definition-list`, `element:repeater`, `element:number` and `object-metric`; `os validate` / `os build` / `os lint` and the metadata save door refuse any other block inside it. Consumer: the console's browser print rendering, and later the server-side PDF renderer; until it ships, the declaration is validated but nothing applies it. A list view's print button is `allowPrinting`, not this. | diff --git a/examples/app-crm/src/security/bind-position-sets.ts b/examples/app-crm/src/security/bind-position-sets.ts index 895404bbd97..e8b9944d199 100644 --- a/examples/app-crm/src/security/bind-position-sets.ts +++ b/examples/app-crm/src/security/bind-position-sets.ts @@ -3,10 +3,16 @@ /** * [#8060] Position ↔ permission-set bindings for the CRM example. * - * Mirrors `examples/app-showcase/src/security/bind-position-sets.ts` — see - * that file for the full rationale. Short version: the permission model is - * record-authoritative (ADR-0090/0094), bindings live only as - * `sys_position_permission_set` rows, and this app declared three positions + * [ADR-0131 D3/D4] The bindings are DECLARED on the positions + * (`permissionSets` in `sales-positions.ts`), and that is what the + * authorization resolver reads. This binder still writes the same pairs as + * `sys_position_permission_set` rows for the readers that have not moved off + * the junction yet; it is deleted with the last of them, and + * `test/position-bindings.test.ts` holds its list equal to the declarations. + * + * Mirrors `examples/app-showcase/src/security/bind-position-sets.ts`. The + * history: bindings used to live only as `sys_position_permission_set` rows, + * and this app declared three positions * (`sales_rep`, `sales_manager`, `finance_approver`) and a `crm_sales_user` * permission set that never met — every persona silently degraded to the * `everyone` baseline (probe-blocked on every CRM object) until an admin @@ -27,7 +33,7 @@ * (ruling on #8060). Only the three declared positions get the set. */ -const BINDINGS: ReadonlyArray = [ +export const BINDINGS: ReadonlyArray = [ ['sales_rep', 'crm_sales_user'], ['sales_manager', 'crm_sales_user'], ['finance_approver', 'crm_sales_user'], diff --git a/examples/app-crm/src/security/sales-positions.ts b/examples/app-crm/src/security/sales-positions.ts index 9f5bfeb7826..9cad970c3c3 100644 --- a/examples/app-crm/src/security/sales-positions.ts +++ b/examples/app-crm/src/security/sales-positions.ts @@ -5,30 +5,35 @@ import { definePermissionSet } from '@objectstack/spec/security'; /** * Example positions — flat distribution groups for a small sales team - * (positions carry no hierarchy — ADR-0090 D3). + * (positions carry no hierarchy — ADR-0090 D3). Each names the permission sets + * it distributes in `permissionSets` (ADR-0131 D3/D4), which is what the + * authorization resolver reads. */ export const SalesRepPosition = definePosition({ name: 'sales_rep', label: 'Sales Representative', description: 'Front-line sales representative.', + permissionSets: ['crm_sales_user'], }); export const SalesManagerPosition = definePosition({ name: 'sales_manager', label: 'Sales Manager', description: 'Manages a team of sales reps.', + permissionSets: ['crm_sales_user'], }); /** - * Registered in `objectstack.config.ts` (`positions`) and bound to the - * `crm_sales_user` permission set by `bind-position-sets.ts`, alongside - * `sales_rep` and `sales_manager`. This app models no approval flow; the - * worked approval flows live in `examples/app-showcase/src/automation/flows/`. + * Registered in `objectstack.config.ts` (`positions`) and distributing the + * `crm_sales_user` permission set, alongside `sales_rep` and `sales_manager`. + * This app models no approval flow; the worked approval flows live in + * `examples/app-showcase/src/automation/flows/`. */ export const FinanceApproverPosition = definePosition({ name: 'finance_approver', label: 'Finance Approver', description: 'Finance team member authorised to approve discounts above 30%.', + permissionSets: ['crm_sales_user'], }); /** diff --git a/examples/app-crm/test/position-bindings.test.ts b/examples/app-crm/test/position-bindings.test.ts new file mode 100644 index 00000000000..7756be56f1f --- /dev/null +++ b/examples/app-crm/test/position-bindings.test.ts @@ -0,0 +1,24 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [ADR-0131 D3/D4] The CRM personas' permission sets are declared on their + * positions (`permissionSets`), which the authorization resolver reads. The + * junction binder (`src/security/bind-position-sets.ts`) still writes the same + * pairs for the readers that have not moved off `sys_position_permission_set`; + * until it is deleted, the two must name the same pairs, or those readers + * judge a position by bindings the resolver does not grant. + */ + +import { describe, expect, it } from 'vitest'; +import { BINDINGS } from '../src/security/bind-position-sets.js'; +import { FinanceApproverPosition, SalesManagerPosition, SalesRepPosition } from '../src/security/sales-positions.js'; + +describe('CRM position bindings', () => { + it('the junction binder writes exactly the bindings the position definitions declare', () => { + const declared = [SalesRepPosition, SalesManagerPosition, FinanceApproverPosition] + .flatMap((p) => (p.permissionSets ?? []).map((s) => `${p.name} -> ${s}`)) + .sort(); + expect(declared).toHaveLength(3); + expect(BINDINGS.map(([p, s]) => `${p} -> ${s}`).sort()).toEqual(declared); + }); +}); diff --git a/examples/app-showcase/src/security/bind-position-sets.ts b/examples/app-showcase/src/security/bind-position-sets.ts index 72bcdde3c1d..645ccd00166 100644 --- a/examples/app-showcase/src/security/bind-position-sets.ts +++ b/examples/app-showcase/src/security/bind-position-sets.ts @@ -3,10 +3,13 @@ /** * [#2926 ②] Position ↔ permission-set bindings for the showcase personas. * - * The permission model is record-authoritative (ADR-0090/0094): bindings live - * only as `sys_position_permission_set` rows. A fresh deploy used to boot with - * ZERO bindings — every persona silently degraded to the `everyone` baseline - * until an admin hand-assigned all sets. + * [ADR-0131 D3/D4] The bindings are DECLARED on the positions + * (`permissionSets` in `positions.ts`), and that is what the authorization + * resolver reads. This binder still writes the same pairs as + * `sys_position_permission_set` rows for the readers that have not moved off + * the junction yet (the delegated-admin gate, the explainer, the sharing + * recipients); it is deleted with the last of them. + * `test/inert-wirings.test.ts` holds this list equal to the declarations. * * This cannot be a declarative SEED: the seed loader runs before the security * bootstrap creates the `sys_position` / `sys_permission_set` rows, so the name @@ -30,11 +33,8 @@ */ /** - * The persona bindings, exported because they are the only place that answers - * "which permission sets does a position actually hold?" — the question a - * sharing rule with a `position` recipient has to answer before its grant can - * mean anything (a share row for a principal with no object-level `allowRead` - * is never consulted). `inert-wirings.test.ts` §6 reads this list. + * The persona bindings as junction rows — the same pairs the position + * definitions declare (`inert-wirings.test.ts` pins the equality). */ export const POSITION_PERMISSION_SET_BINDINGS: ReadonlyArray = [ ['contributor', 'showcase_contributor'], diff --git a/examples/app-showcase/src/security/positions.ts b/examples/app-showcase/src/security/positions.ts index 39e0661c5de..297cd2e2401 100644 --- a/examples/app-showcase/src/security/positions.ts +++ b/examples/app-showcase/src/security/positions.ts @@ -9,6 +9,12 @@ * src/data/seed/), and the manager chain is `sys_user.manager_id` * (ADR-0090 D3; the old `parent` field on positions never existed at runtime). * + * Each position names the permission sets it distributes in `permissionSets` + * (ADR-0131 D3/D4): the authorization resolver reads them from here, by name. + * `src/security/bind-position-sets.ts` still writes the same bindings as + * `sys_position_permission_set` rows, for the readers that have not moved off + * the junction yet; `test/inert-wirings.test.ts` holds the two equal. + * * Two positions are BUILT-IN and never declared by an app: `everyone` * (implicitly held by every authenticated member — the tenant baseline, * ADR-0090 D5) and `guest` (implicitly held by anonymous visitors, @@ -23,6 +29,7 @@ export const ContributorPosition = definePosition({ name: 'contributor', label: 'Contributor', description: 'Works tasks on their own projects.', + permissionSets: ['showcase_contributor'], }); /** Runs a unit: depth-scoped visibility over the unit's private records. */ @@ -30,6 +37,7 @@ export const ManagerPosition = definePosition({ name: 'manager', label: 'Project Manager', description: 'Manages projects and the contributors on them.', + permissionSets: ['showcase_manager'], }); /** Org-wide read for reporting — depth-based (`readScope: org`), not VAMA. */ @@ -37,6 +45,7 @@ export const ExecPosition = definePosition({ name: 'exec', label: 'Executive', description: 'Read-all visibility for reporting.', + permissionSets: ['showcase_executive'], }); /** Compliance: View-All bypass (VAMA) — reads everything, changes nothing. */ @@ -44,6 +53,7 @@ export const AuditorPosition = definePosition({ name: 'auditor', label: 'Auditor', description: 'Compliance read-only view across private records (viewAllRecords).', + permissionSets: ['showcase_auditor'], }); /** Back-office: system permissions, Modify-All repairs, the Operations app. */ @@ -51,6 +61,7 @@ export const OpsPosition = definePosition({ name: 'ops', label: 'Operations', description: 'Back-office operations — Setup access, announcement repairs, Operations app.', + permissionSets: ['showcase_ops'], }); /** @@ -62,6 +73,7 @@ export const FieldOpsDelegatePosition = definePosition({ name: 'field_ops_delegate', label: 'Field Ops Delegate Admin', description: 'Scoped administration of the Field Operations business-unit subtree.', + permissionSets: ['showcase_field_ops_delegate'], }); /** @@ -74,6 +86,7 @@ export const ClientLiaisonPosition = definePosition({ name: 'client_liaison', label: 'Client Liaison', description: 'Prepares and publishes client-facing project briefs; cannot see internal budget figures.', + permissionSets: ['showcase_client_liaison'], }); /** @@ -86,6 +99,7 @@ export const ClientPortalUserPosition = definePosition({ name: 'client_portal_user', label: 'Client Portal User', description: 'External client admitted to the Client Portal.', + permissionSets: ['showcase_guest_portal'], }); /** diff --git a/examples/app-showcase/test/inert-wirings.test.ts b/examples/app-showcase/test/inert-wirings.test.ts index 2ef7fbfea1a..1af7f7ca9f0 100644 --- a/examples/app-showcase/test/inert-wirings.test.ts +++ b/examples/app-showcase/test/inert-wirings.test.ts @@ -680,12 +680,18 @@ describe('sharing rules target an object under record-sharing enforcement (#9237 sharingModel?: string; }>; const sets = ((stack as { permissions?: unknown[] }).permissions ?? []) as AuthoredSet[]; + const positions = ((stack as { positions?: unknown[] }).positions ?? []) as Array<{ + name: string; + permissionSets?: string[]; + }>; - /** The permission sets a holder of `position` effectively carries. */ + /** + * The permission sets a holder of `position` effectively carries: the ones + * its definition names (`permissionSets`, ADR-0131 D3/D4 — what the + * authorization resolver reads). + */ function setsHeldBy(position: string): AuthoredSet[] { - const held = new Set( - POSITION_PERMISSION_SET_BINDINGS.filter(([p]) => p === position).map(([, s]) => s), - ); + const held = new Set(positions.find((p) => p.name === position)?.permissionSets ?? []); // Every authenticated member also holds the `everyone` baseline (the // `isDefault` set, ADR-0090 D5) IN ADDITION to their explicit grants. return sets.filter((s) => held.has(s.name) || s.isDefault === true); @@ -695,6 +701,16 @@ describe('sharing rules target an object under record-sharing enforcement (#9237 expect(rules.length).toBeGreaterThan(0); }); + // [ADR-0131 D3/D4] The junction binder still writes the same bindings for the + // readers that have not moved off `sys_position_permission_set`; until it is + // deleted the two must name the same pairs, or those readers judge a + // position by bindings the resolver does not grant. + it('the junction binder writes exactly the bindings the position definitions declare', () => { + const declared = positions.flatMap((p) => (p.permissionSets ?? []).map((s) => `${p.name} -> ${s}`)).sort(); + expect(declared.length).toBeGreaterThan(0); + expect(POSITION_PERMISSION_SET_BINDINGS.map(([p, s]) => `${p} -> ${s}`).sort()).toEqual(declared); + }); + it('no rule is anchored on an object whose OWD leaves nothing to widen', () => { // `effectiveSharingModel` maps BOTH of these to 'public'; the // `controlled_by_parent` case has its own refusal ("share the master diff --git a/packages/cloud-connection/src/__tests__/security-catalog.testkit.ts b/packages/cloud-connection/src/__tests__/security-catalog.testkit.ts new file mode 100644 index 00000000000..a4c173ebf20 --- /dev/null +++ b/packages/cloud-connection/src/__tests__/security-catalog.testkit.ts @@ -0,0 +1,72 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Test scaffolding: bind a security catalog to a fake engine (ADR-0131 D3/D4). + * `resolveUserAuthzGrants` reads positions, their sets and the set bodies from + * the catalog the security plugin binds to its engine; a suite whose engine is + * a double binds one here. A row alone grants nothing: what a fixture grants + * must be declared. {@link catalogFromTables} declares a row-shaped fixture's + * catalog, its junction rows through `convertPositionBindingRows` — the + * conversion the upgrade ceremony applies. + */ + +import { bindSecurityCatalogReader, convertPositionBindingRows, createSecurityCatalogReader } from '@objectstack/core'; + +type Definition = Record; + +/** The definitions a suite declares, per catalog type. */ +export interface TestSecurityCatalog { + positions?: readonly Definition[]; + permissions?: readonly Definition[]; +} + +/** Bind a catalog over these definitions (read afresh per lookup when given a function); returns `engine`. */ +export function bindTestSecurityCatalog(engine: T, catalog: TestSecurityCatalog | (() => TestSecurityCatalog)): T { + const items = (type: string): readonly Definition[] => { + const c = typeof catalog === 'function' ? catalog() : catalog; + return (type === 'position' ? c.positions : type === 'permission' ? c.permissions : undefined) ?? []; + }; + bindSecurityCatalogReader(engine, createSecurityCatalogReader({ + registry: { getItem: (type, name) => items(type).find((d) => d.name === name), listItems: items, isPackageDisabled: () => false }, + metadata: { get: () => undefined, list: () => [] }, + })); + return engine; +} + +const parsed = (value: unknown): unknown => { + if (typeof value !== 'string') return value; + try { + return JSON.parse(value); + } catch { + return undefined; + } +}; + +/** The definitions a fixture's catalog rows convert to (first set row of a name wins; a `conflicting` position names no set). */ +export function catalogFromTables(tables: Record): TestSecurityCatalog { + const setRows = (tables.sys_permission_set ?? []) as Definition[]; + const permissions: Definition[] = []; + for (const row of setRows) { + if (typeof row.name !== 'string' || permissions.some((d) => d.name === row.name)) continue; + const definition: Definition = { name: row.name }; + for (const [column, key] of [['system_permissions', 'systemPermissions'], ['tab_permissions', 'tabPermissions'], ['object_permissions', 'objects']] as const) { + const value = parsed(row[column] ?? row[key]); + if (value !== undefined && value !== null) definition[key] = value; + } + permissions.push(definition); + } + const { positions } = convertPositionBindingRows({ + positions: (tables.sys_position ?? []) as Definition[], + permissionSets: setRows, + bindings: (tables.sys_position_permission_set ?? []) as Definition[], + }); + return { + permissions, + positions: positions.flatMap((f) => (f.fate === 'conflicting' ? [] : [{ name: f.name, permissionSets: [...f.permissionSets] }])), + }; +} + +/** Bind the catalog `tables` convert to, re-derived per lookup so a row written later is in it. */ +export function bindCatalogFromTables(engine: T, tables: Record): T { + return bindTestSecurityCatalog(engine, () => catalogFromTables(tables)); +} diff --git a/packages/cloud-connection/src/install-local-principal.fixtures.ts b/packages/cloud-connection/src/install-local-principal.fixtures.ts index 9bd1cb27214..f182e893ee8 100644 --- a/packages/cloud-connection/src/install-local-principal.fixtures.ts +++ b/packages/cloud-connection/src/install-local-principal.fixtures.ts @@ -36,9 +36,17 @@ * that some callers are not. */ +import { bindSecurityCatalogReader, createSecurityCatalogReader } from '@objectstack/core'; + /** The default fixture user id — matches what the suites already asserted on. */ export const INSTALLER_USER_ID = 'admin'; +/** + * [ADR-0131 D3/D4] The installer's set as the security catalog declares it — + * the resolver reads a set's body from its definition, never from the row. + */ +export const INSTALLER_SET = { name: 'admin_full_access', systemPermissions: ['manage_metadata', 'studio.access', 'setup.access'] }; + /** * The `sys_*` rows that make `userId` a holder of `manage_metadata`, shaped the * way `resolveAuthzContext` reads them (an UNSCOPED `sys_user_permission_set` @@ -51,6 +59,7 @@ export function installerGrantRows(userId: string = INSTALLER_USER_ID): Record>( ): T { const rows = installerGrantRows(userId); const inner = typeof engine?.find === 'function' ? engine.find.bind(engine) : undefined; - return { + const wrapped = { ...engine, find: async (object: string, options?: unknown) => { if (Object.prototype.hasOwnProperty.call(rows, object)) return rows[object]; return inner ? inner(object, options) : []; }, } as T; + // What the security plugin binds to a real engine: the catalog the + // installer's grant names its set in. + bindSecurityCatalogReader(wrapped, createSecurityCatalogReader({ + registry: { + getItem: (type, name) => (type === 'permission' && name === INSTALLER_SET.name ? INSTALLER_SET : undefined), + listItems: (type) => (type === 'permission' ? [INSTALLER_SET] : []), + isPackageDisabled: () => false, + }, + metadata: { get: () => undefined, list: () => [] }, + })); + return wrapped; } diff --git a/packages/cloud-connection/src/marketplace-install-local-capability-enumeration.test.ts b/packages/cloud-connection/src/marketplace-install-local-capability-enumeration.test.ts index 1ca5d5a4d5d..3b5acae1079 100644 --- a/packages/cloud-connection/src/marketplace-install-local-capability-enumeration.test.ts +++ b/packages/cloud-connection/src/marketplace-install-local-capability-enumeration.test.ts @@ -87,6 +87,7 @@ vi.mock('@objectstack/runtime', () => ({ import { assertEngineDeleteDispatch } from '@objectstack/metadata-core'; import { MarketplaceInstallLocalPlugin } from './marketplace-install-local-plugin.js'; +import { bindCatalogFromTables } from './__tests__/security-catalog.testkit.js'; const ROUTE_BASE = '/api/v1/marketplace/install-local'; @@ -132,6 +133,7 @@ function grantRows(shape: Shape): Record { sys_member: [], sys_user_position: [], sys_position: [], + sys_metadata_activation: [], sys_position_permission_set: [], sys_user_permission_set: held.length ? [{ @@ -191,11 +193,11 @@ async function mount(shape: Shape, storageDir: string) { const services: Record = { manifest: { register }, auth: { api: { getSession: async () => (sessionUser ? { user: sessionUser, session: {} } : null) } }, - objectql: { + objectql: bindCatalogFromTables({ syncSchemas, find: async (object: string) => (object === 'widget' ? widgets.map((w) => ({ ...w })) : rows[object] ?? []), delete: engineDelete, - }, + }, rows), metadata: { getObject: async () => ({ name: 'widget', fields: {} }) }, }; const ctx: any = { diff --git a/packages/cloud-connection/src/marketplace-install-local-list-posture.test.ts b/packages/cloud-connection/src/marketplace-install-local-list-posture.test.ts index f15e760c5d5..346bbb9fb50 100644 --- a/packages/cloud-connection/src/marketplace-install-local-list-posture.test.ts +++ b/packages/cloud-connection/src/marketplace-install-local-list-posture.test.ts @@ -69,6 +69,7 @@ import { tmpdir } from 'node:os'; import '@objectstack/runtime'; import { MarketplaceInstallLocalPlugin } from './marketplace-install-local-plugin.js'; import { LocalManifestSource } from './local-manifest-source.js'; +import { bindCatalogFromTables } from './__tests__/security-catalog.testkit.js'; const ROUTE_BASE = '/api/v1/marketplace/install-local'; @@ -109,6 +110,7 @@ function grantRows(shape: Shape): Record { sys_member: [], sys_user_position: [], sys_position: [], + sys_metadata_activation: [], sys_position_permission_set: [], sys_user_permission_set: held.length ? [{ @@ -163,7 +165,7 @@ async function mount(shape: Shape, storageDir: string) { const services: Record = { manifest: { register: vi.fn() }, auth: { api: { getSession: async () => (sessionUser ? { user: sessionUser, session: {} } : null) } }, - objectql: { syncSchemas: vi.fn(async () => undefined), find: async (object: string) => rows[object] ?? [] }, + objectql: bindCatalogFromTables({ syncSchemas: vi.fn(async () => undefined), find: async (object: string) => rows[object] ?? [] }, rows), metadata: {}, }; const ctx: any = { diff --git a/packages/cloud-connection/src/marketplace-install-local-listing-not-loaded.test.ts b/packages/cloud-connection/src/marketplace-install-local-listing-not-loaded.test.ts index 55a1a6970ee..69aef94f2c5 100644 --- a/packages/cloud-connection/src/marketplace-install-local-listing-not-loaded.test.ts +++ b/packages/cloud-connection/src/marketplace-install-local-listing-not-loaded.test.ts @@ -43,7 +43,8 @@ import { PROTOCOL_MAJOR } from '@objectstack/spec/kernel'; import '@objectstack/runtime'; import { MarketplaceInstallLocalPlugin } from './marketplace-install-local-plugin.js'; import { LocalManifestSource, type InstalledManifestEntry } from './local-manifest-source.js'; -import { installerGrantRows, INSTALLER_USER_ID } from './install-local-principal.fixtures.js'; +import { installerGrantRows, INSTALLER_USER_ID, INSTALLER_SET } from './install-local-principal.fixtures.js'; +import { bindTestSecurityCatalog } from './__tests__/security-catalog.testkit.js'; type Handler = (c: any) => Promise; type Row = Record & { id: string }; @@ -88,6 +89,7 @@ const MEMBER_GRANTS: Record = { sys_member: [], sys_user_position: [], sys_position: [], + sys_metadata_activation: [], sys_position_permission_set: [], sys_user_permission_set: [{ id: 'ups_member', user_id: MEMBER_ID, permission_set_id: 'ps_member', permission_set: 'organization_admin', organization_id: null }], sys_permission_set: [{ id: 'ps_member', name: 'organization_admin', system_permissions: ['setup.access', 'manage_org_users'] }], @@ -111,7 +113,7 @@ async function restartWith(manifests: Array<{ id: string; version: string }>, di const reads: string[] = []; const caller = { as: 'operator' as 'operator' | 'member' }; const grants = () => (caller.as === 'operator' ? installerGrantRows() : MEMBER_GRANTS); - const engine = { + const engine = bindTestSecurityCatalog({ syncSchemas: vi.fn(async () => undefined), registry: { getAllPackages: () => [] }, async find(object: string, query?: any): Promise { @@ -131,7 +133,7 @@ async function restartWith(manifests: Array<{ id: string; version: string }>, di })); return (typeof query?.limit === 'number' ? rows.slice(0, query.limit) : rows).map((row) => ({ ...row })); }, - }; + }, { permissions: [INSTALLER_SET, { name: 'organization_admin', systemPermissions: ['setup.access', 'manage_org_users'] }] }); const register = vi.fn((m: any) => { for (const o of m?.objects ?? []) objects.set(o.name, o); }); const services: Record = { manifest: { register }, diff --git a/packages/cloud-connection/src/marketplace-install-local-no-active-organization.test.ts b/packages/cloud-connection/src/marketplace-install-local-no-active-organization.test.ts index 17bfa8130d7..cfeff0f42df 100644 --- a/packages/cloud-connection/src/marketplace-install-local-no-active-organization.test.ts +++ b/packages/cloud-connection/src/marketplace-install-local-no-active-organization.test.ts @@ -61,8 +61,9 @@ vi.mock('@objectstack/spec/data', async (importOriginal) => ({ })); import { MarketplaceInstallLocalPlugin } from './marketplace-install-local-plugin.js'; -import { INSTALLER_USER_ID, withInstallerGrants } from './install-local-principal.fixtures.js'; +import { INSTALLER_USER_ID, withInstallerGrants, INSTALLER_SET } from './install-local-principal.fixtures.js'; import { LocalManifestSource } from './local-manifest-source.js'; +import { bindTestSecurityCatalog } from './__tests__/security-catalog.testkit.js'; type Handler = (c: any) => Promise; @@ -116,7 +117,7 @@ async function boot(opts: { posture: 'single' | 'isolated'; activeOrg?: string } syncSchemas: async () => undefined, find: async (object: string, query?: any) => { reads.push({ object, query }); return []; }, }); - const objectql = { + const objectql = bindTestSecurityCatalog({ ...granted, find: async (object: string, query?: any) => { if (object === 'sys_member' || object === 'sys_organization_member') { @@ -125,7 +126,7 @@ async function boot(opts: { posture: 'single' | 'isolated'; activeOrg?: string } } return granted.find(object, query); }, - }; + }, { permissions: [INSTALLER_SET] }); const register = vi.fn(); const services: Record = { manifest: { register }, diff --git a/packages/cloud-connection/src/marketplace-install-local-sample-data-not-loaded.test.ts b/packages/cloud-connection/src/marketplace-install-local-sample-data-not-loaded.test.ts index d6fa378d17f..8d88fcffcc8 100644 --- a/packages/cloud-connection/src/marketplace-install-local-sample-data-not-loaded.test.ts +++ b/packages/cloud-connection/src/marketplace-install-local-sample-data-not-loaded.test.ts @@ -49,7 +49,8 @@ import { PROTOCOL_MAJOR } from '@objectstack/spec/kernel'; import '@objectstack/runtime'; import { MarketplaceInstallLocalPlugin } from './marketplace-install-local-plugin.js'; import { LocalManifestSource, type InstalledManifestEntry } from './local-manifest-source.js'; -import { installerGrantRows, INSTALLER_USER_ID } from './install-local-principal.fixtures.js'; +import { installerGrantRows, INSTALLER_USER_ID, INSTALLER_SET } from './install-local-principal.fixtures.js'; +import { bindTestSecurityCatalog } from './__tests__/security-catalog.testkit.js'; type Handler = (c: any) => Promise; type Row = Record & { id: string }; @@ -131,7 +132,7 @@ async function restartWith( if (!objects.has(object)) throw new Error(`Object '${object}' not found`); return (tables[object] ??= []); }; - const engine = { + const engine = bindTestSecurityCatalog({ syncSchemas: vi.fn(async () => undefined), registry: { getAllPackages: () => [] }, async find(object: string, query?: any): Promise { @@ -164,7 +165,7 @@ async function restartWith( writes.push(`delete ${object}#${String(dispatch.id)}`); return true; }, - }; + }, { permissions: [INSTALLER_SET] }); const register = vi.fn((m: any) => { for (const o of m?.objects ?? []) objects.set(o.name, o); }); const i18n = { loadTranslations: vi.fn() }; const services: Record = { diff --git a/packages/cloud-connection/src/marketplace-install-local-tenancy-admission.test.ts b/packages/cloud-connection/src/marketplace-install-local-tenancy-admission.test.ts index 30c9c4495b6..81b091239dc 100644 --- a/packages/cloud-connection/src/marketplace-install-local-tenancy-admission.test.ts +++ b/packages/cloud-connection/src/marketplace-install-local-tenancy-admission.test.ts @@ -67,6 +67,7 @@ import { AUTHZ_STORE_UNAVAILABLE_STATUS, } from '@objectstack/core'; import { MarketplaceInstallLocalPlugin } from './marketplace-install-local-plugin.js'; +import { bindCatalogFromTables } from './__tests__/security-catalog.testkit.js'; const ROUTE_BASE = '/api/v1/marketplace/install-local'; const LEDGER_FILE = 'com.acme.tenancy.json'; @@ -146,6 +147,7 @@ function permissionStore(opts: { syncSchemas: () => Promise }) { ], sys_user_position: [], sys_position: [], + sys_metadata_activation: [], sys_position_permission_set: [], sys_user_permission_set: [ { id: 'ups_member', user_id: 'u_member', permission_set_id: 'ps_install', permission_set: 'admin_full_access', organization_id: null }, @@ -157,14 +159,14 @@ function permissionStore(opts: { syncSchemas: () => Promise }) { { id: 'ps_install', name: 'admin_full_access', system_permissions: ['manage_metadata', 'studio.access'] }, ], }; - return { + return bindCatalogFromTables({ syncSchemas: opts.syncSchemas, find: async (object: string, q: any = {}) => { const rows = (tables[object] ?? []).filter((row) => matchesWhere(row, q?.where)); // The caller's bound is held BY PRESENCE, never re-derived here. return typeof q?.limit === 'number' ? rows.slice(0, q.limit) : rows; }, - }; + }, tables); } type Tenancy = diff --git a/packages/core/src/security/__tests__/resolve-authz-context.batch-equivalence.testkit.ts b/packages/core/src/security/__tests__/resolve-authz-context.batch-equivalence.testkit.ts index 18454b94128..1c9ad3c081e 100644 --- a/packages/core/src/security/__tests__/resolve-authz-context.batch-equivalence.testkit.ts +++ b/packages/core/src/security/__tests__/resolve-authz-context.batch-equivalence.testkit.ts @@ -30,6 +30,7 @@ */ import type { ResolveUserAuthzGrantsOptions } from '../resolve-authz-context.js'; +import { bindCatalogFromTables, ledgerFromTables } from './security-catalog.testkit.js'; // ── Recording ObjectQL double ─────────────────────────────────────────────── @@ -48,6 +49,12 @@ export interface RecordedCall { object: string; where: unknown; limit: unknown; * while another is in flight JOINS the open leg. Sequential awaits therefore * count one leg each, and a `Promise.all` of any width counts one — which is * exactly the definition cloud#1539 measured latency against. + * + * [ADR-0131 D3/D4] The double also carries the security catalog the resolver + * reads positions, their sets and the set bodies from: the definitions the + * fixture's catalog rows convert to (`catalogFromTables` — the junction rows + * through the upgrade ceremony's own conversion). The catalog is in-process, + * so it adds no read and no leg. */ export function makeRecordingQl(tables: Record) { const calls: RecordedCall[] = []; @@ -62,7 +69,7 @@ export function makeRecordingQl(tables: Record) { if (v === null) return (row[k] ?? null) === null; return row[k] === v; }); - return { + return bindCatalogFromTables({ calls, legOf, get legs() { return legs; }, @@ -97,13 +104,16 @@ export function makeRecordingQl(tables: Record) { }); try { await new Promise((r) => setTimeout(r, 0)); - const rows = (tables[object] ?? []).filter((r) => matches(r, opts?.where)); + // [ADR-0131 D3] A fixture's deactivated catalog rows reach the resolver as + // the activation-ledger rows the upgrade ceremony converts them to. + const stored = object === 'sys_metadata_activation' && !tables[object] ? ledgerFromTables(tables) : tables[object]; + const rows = (stored ?? []).filter((r) => matches(r, opts?.where)); return typeof opts?.limit === 'number' ? rows.slice(0, opts.limit) : rows; } finally { inFlight -= 1; } }, - }; + }, tables); } // ── Fixture matrix — one entry per shape the eight reads discriminate on ──── diff --git a/packages/core/src/security/__tests__/security-catalog.testkit.ts b/packages/core/src/security/__tests__/security-catalog.testkit.ts new file mode 100644 index 00000000000..4e9254810e4 --- /dev/null +++ b/packages/core/src/security/__tests__/security-catalog.testkit.ts @@ -0,0 +1,121 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Test scaffolding: bind a security catalog to a fake engine. + * + * The authorization resolver reads positions, the sets they name and the set + * bodies from the catalog the security plugin binds to its engine + * (`bindSecurityCatalogReader`, ADR-0131 D3/D4). A unit suite's fake engine + * has no plugin, so it binds one here — over an in-memory registry holding the + * definitions the suite declares. + * + * {@link catalogFromTables} derives those definitions from a suite's row-shaped + * fixture: the `sys_permission_set` rows become set definitions, and the + * `sys_position_permission_set` rows become each position's `permissionSets` + * through `convertPositionBindingRows` — the very conversion the upgrade + * ceremony applies — so a fixture that resolved some grants from rows proves + * the converted definitions resolve the same ones. {@link ledgerFromTables} + * does the same for the rows' ADR-0049 `active` flags, through + * `convertDeactivatedCatalogRows`, into activation-ledger rows. + */ + +import { + bindSecurityCatalogReader, + createSecurityCatalogReader, + type SecurityCatalogReader, +} from '../security-catalog.js'; +import { convertDeactivatedCatalogRows, convertPositionBindingRows } from '../position-binding-conversion.js'; + +type Definition = Record; + +/** The definitions a suite declares, per catalog type. */ +export interface StaticCatalog { + positions?: readonly Definition[]; + permissions?: readonly Definition[]; +} + +/** + * A catalog over these definitions alone (first definition of a name wins). + * Given a function, the definitions are read afresh on every lookup — the + * shape for a suite that writes its fixture between two resolutions. + */ +export function createStaticSecurityCatalog(catalog: StaticCatalog | (() => StaticCatalog)): SecurityCatalogReader { + const current = typeof catalog === 'function' ? catalog : () => catalog; + const itemsOf = (type: string): readonly Definition[] => { + const c = current(); + return type === 'position' ? (c.positions ?? []) : type === 'permission' ? (c.permissions ?? []) : []; + }; + return createSecurityCatalogReader({ + registry: { + getItem: (type, name) => itemsOf(type).find((d) => d.name === name), + listItems: (type) => itemsOf(type), + isPackageDisabled: () => false, + }, + metadata: { get: () => undefined, list: () => [] }, + }); +} + +/** Bind {@link createStaticSecurityCatalog} to `engine`; returns the engine for chaining. */ +export function bindStaticSecurityCatalog(engine: T, catalog: StaticCatalog | (() => StaticCatalog)): T { + bindSecurityCatalogReader(engine, createStaticSecurityCatalog(catalog)); + return engine; +} + +const parsed = (value: unknown): unknown => { + if (typeof value !== 'string') return value; + try { + return JSON.parse(value); + } catch { + return undefined; + } +}; + +/** + * The definitions a row-shaped fixture's catalog rows convert to (module doc). + * A set name held by several rows takes the first row's body; a position name + * the conversion reports `conflicting` names no set. + */ +export function catalogFromTables(tables: Record): StaticCatalog { + const setRows = (tables.sys_permission_set ?? []) as Definition[]; + const positionRows = (tables.sys_position ?? []) as Definition[]; + const permissions: Definition[] = []; + for (const row of setRows) { + if (typeof row.name !== 'string' || permissions.some((d) => d.name === row.name)) continue; + const systemPermissions = parsed(row.system_permissions ?? row.systemPermissions); + const tabPermissions = parsed(row.tab_permissions ?? row.tabPermissions); + permissions.push({ + name: row.name, + ...(systemPermissions !== undefined && systemPermissions !== null ? { systemPermissions } : {}), + ...(tabPermissions !== undefined && tabPermissions !== null ? { tabPermissions } : {}), + }); + } + const conversion = convertPositionBindingRows({ + positions: positionRows, + permissionSets: setRows, + bindings: (tables.sys_position_permission_set ?? []) as Definition[], + }); + const positions: Definition[] = []; + for (const fate of conversion.positions) { + if (fate.fate !== 'conflicting') positions.push({ name: fate.name, permissionSets: [...fate.permissionSets] }); + } + return { positions, permissions }; +} + +/** + * Bind to `engine` the catalog `tables`' rows convert to ({@link catalogFromTables}), + * re-derived on every lookup, so a row the suite writes later is in it. + */ +export function bindCatalogFromTables(engine: T, tables: Record): T { + return bindStaticSecurityCatalog(engine, () => catalogFromTables(tables)); +} + +/** + * The `sys_metadata_activation` rows a row-shaped fixture's `active: false` + * catalog rows convert to (`convertDeactivatedCatalogRows`, module doc). + */ +export function ledgerFromTables(tables: Record): Definition[] { + return convertDeactivatedCatalogRows({ + positions: (tables.sys_position ?? []) as Definition[], + permissionSets: (tables.sys_permission_set ?? []) as Definition[], + }).ledger.map((row) => ({ ...row, package_id: null })); +} diff --git a/packages/core/src/security/admin-standing-surface.test.ts b/packages/core/src/security/admin-standing-surface.test.ts index 21808eb0731..c4459c44ca8 100644 --- a/packages/core/src/security/admin-standing-surface.test.ts +++ b/packages/core/src/security/admin-standing-surface.test.ts @@ -45,6 +45,7 @@ import { describe, it, expect } from 'vitest'; import { ADMIN_STANDING_SURFACE, adminStandingTables } from './admin-standing-surface.js'; import { resetPlatformAdminEmailMemo } from './platform-admin.js'; import { resolveAuthzContext } from './resolve-authz-context.js'; +import { bindCatalogFromTables } from './__tests__/security-catalog.testkit.js'; /** table -> every column name the resolver touched on it. */ type Observation = Map>; @@ -79,7 +80,7 @@ function makeRecordingQl(tables: Record>>, const raw = (row: Record, key: string): unknown => key in row ? row[key] : row[camelOf(key)]; - return { + return bindCatalogFromTables({ async find(object: string, opts: { where?: Record; limit?: number } = {}) { if (!seen.has(object)) seen.set(object, new Set()); const where = opts?.where ?? {}; @@ -116,7 +117,7 @@ function makeRecordingQl(tables: Record>>, }), ); }, - }; + }, tables); } const headers = () => new Headers(); @@ -211,8 +212,8 @@ const VARIANTS: Record< }, // Standing taken away every way the resolver knows: the set switched off - // (ADR-0049), the grant scoped to an organization, the window closed - // (ADR-0091), the position deactivated, and the JSON blobs stored as strings. + // (ADR-0049, in the activation ledger), the grant scoped to an organization, + // the window closed (ADR-0091), and the JSON blobs stored as strings. 'standing revoked every way': { org: 'org_1', tables: { @@ -259,6 +260,11 @@ const VARIANTS: Record< }, { id: 'pst_2', name: 'contributor_set', active: true }, ], + // [ADR-0131 D3, ADR-0126 §4] The switch the resolver honours: the set row's + // own `active` above is no longer read. + sys_metadata_activation: [ + { id: 'act_1', metadata_type: 'permission', name: 'admin_full_access', package_id: null, active: false }, + ], }, }, diff --git a/packages/core/src/security/admin-standing-surface.ts b/packages/core/src/security/admin-standing-surface.ts index bcef643b418..04717d4eb9d 100644 --- a/packages/core/src/security/admin-standing-surface.ts +++ b/packages/core/src/security/admin-standing-surface.ts @@ -98,39 +98,28 @@ export interface AdminStandingTable { * ADMISSION path (`resolveApiKeyAdmission`) is outside it on purpose: it * authenticates a principal and seeds `permissions` with the key's scopes, and * confers no administrator standing of its own — `hasPlatformAdminGrant` is set - * from a `sys_permission_set` row reached through an UNSCOPED + * from the catalog's `admin_full_access` set reached through an UNSCOPED * `sys_user_permission_set` grant (§6b, on a NON-WALLED posture only since * #11663 L5) or from the deployment config matched against the caller's own * STORED `sys_user` row (§6b-config, on every posture), never from a scope * string and never from the caller-seedable `grants.email`. */ export const ADMIN_STANDING_SURFACE: Readonly> = { - sys_permission_set: { + sys_metadata_activation: { role: 'derives', reason: - 'The row `admin_full_access` is resolved BY NAME from (§6b) — `platform_admin` is the ' - + "POSITION that row derives, not the row's own name. Renaming it, deleting it or switching " - + 'it off (ADR-0049 `active`, which this resolver honours) un-makes every GRANT-derived platform ' - + 'admin at once, with no identity table touched. ⚠️ It does NOT un-make a CONFIG-derived ' - + 'one (§6b-config): that route sets the same standing from ' - + "`ADMIN_FULL_ACCESS_CAPABILITIES` in `@objectstack/spec` and matches the caller's own " - + 'stored `sys_user` row, so it touches an identity table and never reads this one. With ' - + '`OS_PLATFORM_OWNER_EMAIL` unset the first sentence is the whole truth; with it declared, ' - + 'this row stops being the single point that un-makes every administrator. [ADR-0131 D4] ' - + "Its organization decides which row a grant's name resolves to — the grant's own " - + "organization's row, else the organization-less one — so an UNSCOPED grant reaches only " - + 'the organization-less `admin_full_access`: moving that row into an organization un-makes ' - + 'every grant-derived platform admin exactly as deleting it does.', + '[ADR-0131 D3, ADR-0126 §4] The activation ledger the resolver reads deactivation from ' + + '(`readDisabledCatalogNames`, §6a): a `permission` row of `admin_full_access` whose `active` ' + + 'is false un-makes every GRANT-derived platform administrator at once (§6b), with no identity ' + + 'table touched; a `position` row switches that position off. Deployment-wide (no tenant ' + + 'column). The `sys_permission_set` and `sys_position` rows are no longer read at all: the ' + + 'set itself — its existence and its body — is the catalog definition (see the ' + + '`security-catalog` non-table input). ⚠️ It does NOT un-make a CONFIG-derived administrator ' + + '(§6b-config), which never reads a set.', columns: [ - 'id', + 'metadata_type', 'name', 'active', - 'organization_id', - 'organizationId', - 'system_permissions', - 'systemPermissions', - 'tab_permissions', - 'tabPermissions', ], }, @@ -206,32 +195,21 @@ export const ADMIN_STANDING_SURFACE: Readonly role: 'reads-only', reason: 'ADR-0057 D4 platform-RBAC position assignments (§4). A position can carry permission sets ' - + '(see `sys_position_permission_set`) but never platform-admin standing — §6b requires the ' - + 'set to be reached through an unscoped USER grant (`unscopedUserPsIds`), so a ' + + '(its catalog definition\'s `permissionSets`, ADR-0131 D3/D4) but never platform-admin ' + + 'standing — §6b requires the set to be reached through an unscoped USER grant, so a ' + 'position-bound `admin_full_access` resolves the set name into `permissions` and leaves ' + '`hasPlatformAdminGrant` false.', }, - - sys_position: { - role: 'reads-only', - reason: - 'Read to drop DEACTIVATED positions (ADR-0049, §6a). Same reason as `sys_user_position`: ' - + 'the position path cannot reach `hasPlatformAdminGrant`.', - }, - - sys_position_permission_set: { - role: 'reads-only', - reason: - 'Position-bound permission sets (§6a). Contributes ids to `psIds` — and therefore names to ' - + '`permissions` — but not to `unscopedUserPsIds`, which is the set §6b tests for ' - + 'platform-admin standing.', - }, }; /** A derivation input that is not a table — see {@link ADMIN_STANDING_NON_TABLE_INPUTS}. */ export interface AdminStandingNonTableInput { - /** How the value reaches the resolver, e.g. `env` for a process environment variable. */ - readonly kind: 'env'; + /** + * How the value reaches the resolver: `env` for a process environment + * variable, `security-catalog` for a definition the resolver reads from the + * catalog bound to its engine (ADR-0131 D3/D4). + */ + readonly kind: 'env' | 'security-catalog'; /** The exact spelling an operator sets — quotable verbatim in a refusal message. */ readonly name: string; /** What it decides, and what a break-glass guard can and cannot do about it. */ @@ -266,6 +244,17 @@ export const ADMIN_STANDING_NON_TABLE_INPUTS: readonly AdminStandingNonTableInpu + 'break-glass guard can simulate a change to it: revocation is a configuration change plus ' + 'a process roll, by design.', }, + { + kind: 'security-catalog', + name: 'admin_full_access', + reason: + 'The permission-set definition an unscoped `admin_full_access` user grant names, read from ' + + 'the security catalog by name (ADR-0131 D3/D4) — the platform\'s own declaration, shipped ' + + 'on the security plugin\'s manifest. A name the catalog does not hold confers nothing, so ' + + 'every grant-derived platform administrator rests on it. It is code-declared and refused ' + + 'an overlay at the metadata door, so no runtime write reaches it either: removing it is ' + + 'a code change plus a process roll.', + }, ]; /** The tables a write to which can change who is an administrator. */ diff --git a/packages/core/src/security/authz-store-unavailable.test.ts b/packages/core/src/security/authz-store-unavailable.test.ts index 49f2e08b645..a1b35734797 100644 --- a/packages/core/src/security/authz-store-unavailable.test.ts +++ b/packages/core/src/security/authz-store-unavailable.test.ts @@ -32,6 +32,7 @@ import { AUTHZ_STORE_UNAVAILABLE_CODE, AUTHZ_STORE_UNAVAILABLE_STATUS, } from './authz-store-unavailable.js'; +import { bindStaticSecurityCatalog } from './__tests__/security-catalog.testkit.js'; const USER = 'u_admin'; const SESSION = { getSession: async () => ({ user: { id: USER } }) }; @@ -40,16 +41,18 @@ const SESSION = { getSession: async () => ({ user: { id: USER } }) }; const qlDown = () => ({ find: async () => { throw new Error('permission store unreachable'); } }); /** The INNOCENT TWIN: reachable, and genuinely holding no rows for this user. */ const qlEmpty = () => ({ find: async () => [] }); -/** A store that actually grants something, so "resolves" is read against a real grant. */ -const qlHealthy = () => ({ +/** + * A store that actually grants something, so "resolves" is read against a real + * grant: the grant row, and the set it names in the security catalog bound to + * the engine (ADR-0131 D3/D4). + */ +const qlHealthy = () => bindStaticSecurityCatalog({ find: async (object: string) => { if (object === 'sys_user_permission_set') return [{ permission_set_id: 'ps', permission_set: 'pkg_admin' }]; - if (object === 'sys_permission_set') { - return [{ id: 'ps', name: 'pkg_admin', system_permissions: ['studio.access'] }]; - } + if (object === 'sys_permission_set') return [{ id: 'ps', name: 'pkg_admin' }]; return []; }, -}); +}, { permissions: [{ name: 'pkg_admin', systemPermissions: ['studio.access'] }] }); const settle = async (p: Promise) => p.then((v) => ({ ok: true as const, v }), (e) => ({ ok: false as const, e })); diff --git a/packages/core/src/security/index.ts b/packages/core/src/security/index.ts index ebc327acce3..0a2181eb0ca 100644 --- a/packages/core/src/security/index.ts +++ b/packages/core/src/security/index.ts @@ -235,6 +235,8 @@ export { isRowActive, type ActivatableRow } from './row-active.js'; // effect: the row `active` flag above stays the authority for that. export { createSecurityCatalogReader, + bindSecurityCatalogReader, + securityCatalogReaderOf, type SecurityCatalogType, type SecurityCatalogSourceName, type SecurityCatalogRegistry, @@ -244,6 +246,23 @@ export { type SecurityCatalogReader, } from './security-catalog.js'; +// ADR-0131 D3/D4/D10 — the junction rows turned into the `permissionSets` each +// position definition carries. Applied by the upgrade ceremony (`os migrate`), +// never at boot. +export { + convertPositionBindingRows, + convertDeactivatedCatalogRows, + type ActivatableCatalogRow, + type CatalogLedgerRow, + type CatalogDeactivationConversion, + type CatalogRowForConversion, + type PositionBindingRow, + type PositionBindingConversionInput, + type PositionBindingFate, + type DanglingPositionBinding, + type PositionBindingConversion, +} from './position-binding-conversion.js'; + // [commit f8eb73601] The measured read surface of the administrator derivation — the // single source `plugin-auth`'s break-glass standing-key lists correspond to. export { diff --git a/packages/core/src/security/position-binding-conversion.test.ts b/packages/core/src/security/position-binding-conversion.test.ts new file mode 100644 index 00000000000..94db5a29915 --- /dev/null +++ b/packages/core/src/security/position-binding-conversion.test.ts @@ -0,0 +1,146 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [ADR-0131 D3/D4/D10] The pin of `convertPositionBindingRows` — the function + * the upgrade ceremony applies to turn junction rows into each position + * definition's `permissionSets`. + */ + +import { describe, expect, it } from 'vitest'; +import { convertDeactivatedCatalogRows, convertPositionBindingRows } from './position-binding-conversion.js'; + +const sets = [ + { id: 'ps_read', name: 'read_all' }, + { id: 'ps_write', name: 'write_all' }, + { id: 'ps_base', name: 'member_default' }, +]; + +describe('convertPositionBindingRows', () => { + it('names each position\'s bound sets by NAME, in row order', () => { + const out = convertPositionBindingRows({ + positions: [{ id: 'p_auditor', name: 'auditor' }, { id: 'p_contrib', name: 'contributor' }], + permissionSets: sets, + bindings: [ + { id: 'b1', position_id: 'p_contrib', permission_set_id: 'ps_write' }, + { id: 'b2', position_id: 'p_auditor', permission_set_id: 'ps_read' }, + { id: 'b3', position_id: 'p_contrib', permission_set_id: 'ps_read' }, + ], + }); + expect(out).toEqual({ + positions: [ + { name: 'contributor', fate: 'converted', permissionSets: ['write_all', 'read_all'] }, + { name: 'auditor', fate: 'converted', permissionSets: ['read_all'] }, + ], + dangling: [], + }); + }); + + it('keeps what the definition already names, and adds only what the rows bind beyond it', () => { + const out = convertPositionBindingRows({ + positions: [{ id: 'p_contrib', name: 'contributor' }, { id: 'p_auditor', name: 'auditor' }], + permissionSets: sets, + bindings: [ + { position_id: 'p_contrib', permission_set_id: 'ps_write' }, + { position_id: 'p_contrib', permission_set_id: 'ps_read' }, + { position_id: 'p_auditor', permission_set_id: 'ps_read' }, + ], + declared: new Map([ + ['contributor', ['member_default', 'write_all']], + ['auditor', ['read_all']], + ]), + }); + expect(out.positions).toEqual([ + { name: 'contributor', fate: 'converted', permissionSets: ['member_default', 'write_all', 'read_all'] }, + { name: 'auditor', fate: 'declared', permissionSets: ['read_all'] }, + ]); + }); + + it('one name materialized per organization with the SAME bindings converts once', () => { + const out = convertPositionBindingRows({ + positions: [ + { id: 'e_a', name: 'everyone', organization_id: 'org_a' }, + { id: 'e_b', name: 'everyone', organization_id: 'org_b' }, + ], + permissionSets: [ + { id: 'base_a', name: 'member_default', organization_id: 'org_a' }, + { id: 'base_b', name: 'member_default', organization_id: 'org_b' }, + ], + bindings: [ + { position_id: 'e_a', permission_set_id: 'base_a' }, + { position_id: 'e_b', permission_set_id: 'base_b' }, + ], + }); + expect(out.positions).toEqual([{ name: 'everyone', fate: 'converted', permissionSets: ['member_default'] }]); + }); + + it('organizations that bind DIFFERENT sets to one name are conflicting — never merged (D10 fate 4)', () => { + const out = convertPositionBindingRows({ + positions: [ + { id: 'm_a', name: 'manager', organization_id: 'org_a' }, + { id: 'm_b', name: 'manager', organization_id: 'org_b' }, + { id: 'm_c', name: 'manager', organization_id: 'org_c' }, + ], + permissionSets: sets, + bindings: [ + { position_id: 'm_a', permission_set_id: 'ps_write' }, + { position_id: 'm_b', permission_set_id: 'ps_read' }, + ], + }); + expect(out.positions).toEqual([ + { + name: 'manager', + fate: 'conflicting', + // org_c holds a row of the name and binds nothing: merging would hand it org_a's and org_b's sets. + byOrganization: { org_a: ['write_all'], org_b: ['read_all'], org_c: [] }, + }, + ]); + }); + + it('a binding to a missing position or set row is dangling and converted nowhere', () => { + const out = convertPositionBindingRows({ + positions: [{ id: 'p_auditor', name: 'auditor' }], + permissionSets: sets, + bindings: [ + { id: 'b_gone_position', position_id: 'p_gone', permission_set_id: 'ps_read' }, + { id: 'b_gone_set', position_id: 'p_auditor', permission_set_id: 'ps_gone' }, + ], + }); + expect(out).toEqual({ + positions: [], + dangling: [ + { id: 'b_gone_position', reason: 'position-missing' }, + { id: 'b_gone_set', reason: 'permission-set-missing' }, + ], + }); + }); +}); + +describe('convertDeactivatedCatalogRows', () => { + it('a name every row switches off becomes one ledger row; an active or flagless row converts to nothing', () => { + const out = convertDeactivatedCatalogRows({ + positions: [{ name: 'retired', active: false }, { name: 'auditor', active: true }, { name: 'contributor' }], + permissionSets: [{ name: 'dead_set', active: 0 }, { name: 'dead_set', active: false, organization_id: 'org_a' }], + }); + expect(out).toEqual({ + ledger: [ + { metadata_type: 'position', name: 'retired', active: false }, + { metadata_type: 'permission', name: 'dead_set', active: false }, + ], + conflicting: [], + }); + }); + + it('organizations that disagree about a name are conflicting — the deployment-wide ledger never guesses', () => { + const out = convertDeactivatedCatalogRows({ + positions: [], + permissionSets: [ + { name: 'tools', organization_id: 'org_a', active: false }, + { name: 'tools', organization_id: 'org_b', active: true }, + ], + }); + expect(out).toEqual({ + ledger: [], + conflicting: [{ metadata_type: 'permission', name: 'tools', byOrganization: { org_a: false, org_b: true } }], + }); + }); +}); diff --git a/packages/core/src/security/position-binding-conversion.ts b/packages/core/src/security/position-binding-conversion.ts new file mode 100644 index 00000000000..fc3f160e076 --- /dev/null +++ b/packages/core/src/security/position-binding-conversion.ts @@ -0,0 +1,210 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [ADR-0131 D3/D4/D10] The `sys_position_permission_set` junction rows, turned + * into the `permissionSets` each position definition carries. + * + * The authorization resolver reads a position's sets from its definition + * (`PositionSchema.permissionSets`) and no longer reads the junction. The rows + * a deployment already holds are converted by the upgrade ceremony + * (`os migrate`, C7b), which calls this function and applies its answer through + * the metadata door. ⛔ Never at boot: a conversion that ran on every start + * would be the dual source ADR-0131 D10 retires. + * + * The function is pure. It takes the three tables as read, and the definitions + * the catalog already holds, and answers per position name: + * + * - `declared` — the definition already names every set the rows bind: + * nothing to write. + * - `converted` — the rows bind sets the definition does not name: + * `permissionSets` is the definition's list followed by the rows' sets, in + * row order. A binding only ever adds, so the declared sets stay. + * - `conflicting` — organizations that hold a row of this name bind different + * sets to it (an organization's row with no binding binds the empty list). + * Merging them would grant one organization's members another's sets, so the + * name is reported and never guessed (D10 fate 4). + * + * A binding whose position or set row is missing, or names nothing, is listed + * in `dangling` and converted nowhere. + * + * Its sibling {@link convertDeactivatedCatalogRows} carries the rows' ADR-0049 + * `active` flags into the activation ledger, which is where the resolver reads + * deactivation from. + */ + +/** One `sys_position` or `sys_permission_set` row, as the conversion reads it. */ +export interface CatalogRowForConversion { + readonly id?: unknown; + readonly name?: unknown; + readonly organization_id?: unknown; +} + +/** One `sys_position_permission_set` row. */ +export interface PositionBindingRow { + readonly id?: unknown; + readonly position_id?: unknown; + readonly permission_set_id?: unknown; +} + +/** The rows and definitions a conversion reads. */ +export interface PositionBindingConversionInput { + readonly positions: readonly CatalogRowForConversion[]; + readonly permissionSets: readonly CatalogRowForConversion[]; + readonly bindings: readonly PositionBindingRow[]; + /** The `permissionSets` each position definition in the catalog names today, by position name. */ + readonly declared?: ReadonlyMap; +} + +/** The answer for one position name. */ +export type PositionBindingFate = + | { readonly name: string; readonly fate: 'declared'; readonly permissionSets: readonly string[] } + | { readonly name: string; readonly fate: 'converted'; readonly permissionSets: readonly string[] } + | { + readonly name: string; + readonly fate: 'conflicting'; + /** The sets each organization's row binds, keyed by organization id (`''` for an organization-less row). */ + readonly byOrganization: Readonly>; + }; + +/** A binding the conversion could not place. */ +export interface DanglingPositionBinding { + readonly id: string | undefined; + readonly reason: 'position-missing' | 'permission-set-missing'; +} + +export interface PositionBindingConversion { + /** One entry per position name a binding reaches, in first-binding order. */ + readonly positions: readonly PositionBindingFate[]; + readonly dangling: readonly DanglingPositionBinding[]; +} + +const text = (value: unknown): string | undefined => + (typeof value === 'string' && value !== '') || typeof value === 'number' ? String(value) : undefined; + +const organizationKey = (value: unknown): string => text(value) ?? ''; + +/** + * Convert the junction rows into position definitions' `permissionSets` + * (module doc). Deterministic: the same rows answer the same result. + */ +export function convertPositionBindingRows(input: PositionBindingConversionInput): PositionBindingConversion { + const positionById = new Map(); + for (const row of input.positions) { + const id = text(row.id); + const name = text(row.name); + if (id && name) positionById.set(id, { name, organization: organizationKey(row.organization_id) }); + } + const setNameById = new Map(); + for (const row of input.permissionSets) { + const id = text(row.id); + const name = text(row.name); + if (id && name) setNameById.set(id, name); + } + + // Position name → organization → the set names its row binds, in row order. + const bound = new Map>(); + const dangling: DanglingPositionBinding[] = []; + for (const binding of input.bindings) { + const position = positionById.get(text(binding.position_id) ?? ''); + if (!position) { + dangling.push({ id: text(binding.id), reason: 'position-missing' }); + continue; + } + const setName = setNameById.get(text(binding.permission_set_id) ?? ''); + if (!setName) { + dangling.push({ id: text(binding.id), reason: 'permission-set-missing' }); + continue; + } + let byOrganization = bound.get(position.name); + if (!byOrganization) bound.set(position.name, (byOrganization = new Map())); + const sets = byOrganization.get(position.organization) ?? []; + if (!sets.includes(setName)) sets.push(setName); + byOrganization.set(position.organization, sets); + } + + // Every organization holding a row of a bound name takes part, with what it binds. + for (const { name, organization } of positionById.values()) { + const byOrganization = bound.get(name); + if (byOrganization && !byOrganization.has(organization)) byOrganization.set(organization, []); + } + + const positions: PositionBindingFate[] = []; + for (const [name, byOrganization] of bound) { + const lists = [...byOrganization.values()]; + const signature = (sets: readonly string[]) => [...sets].sort().join('\u0000'); + if (new Set(lists.map(signature)).size > 1) { + positions.push({ name, fate: 'conflicting', byOrganization: Object.fromEntries(byOrganization) }); + continue; + } + const declared = input.declared?.get(name) ?? []; + const added = lists[0].filter((set) => !declared.includes(set)); + positions.push( + added.length === 0 + ? { name, fate: 'declared', permissionSets: [...declared] } + : { name, fate: 'converted', permissionSets: [...declared, ...added] }, + ); + } + return { positions, dangling }; +} + +/** One `sys_position` / `sys_permission_set` row as the deactivation conversion reads it. */ +export interface ActivatableCatalogRow extends CatalogRowForConversion { + readonly active?: unknown; +} + +/** + * An activation-ledger row (`sys_metadata_activation`, ADR-0126 §4) the + * conversion answers. The applier supplies `package_id`, which the ledger + * object requires and the catalog rows do not carry. + */ +export interface CatalogLedgerRow { + readonly metadata_type: 'position' | 'permission'; + readonly name: string; + readonly active: false; +} + +export interface CatalogDeactivationConversion { + /** The names to switch off in the ledger, one row each. */ + readonly ledger: readonly CatalogLedgerRow[]; + /** Names some organizations' rows switch off and others' keep on — reported, never guessed (D10 fate 4). */ + readonly conflicting: ReadonlyArray<{ metadata_type: 'position' | 'permission'; name: string; byOrganization: Readonly> }>; +} + +const rowIsOff = (value: unknown): boolean => + value === false || value === 0 || value === '0' || value === 'false'; + +/** + * [ADR-0131 D3, ADR-0126 §4] The catalog rows' ADR-0049 `active` flags, + * turned into activation-ledger rows. The resolver reads deactivation from the + * ledger, never from the row; the upgrade ceremony applies this with + * {@link convertPositionBindingRows}. ⛔ Never at boot. + * + * A name whose every row is off gets one ledger row. A name some + * organizations switched off and others did not is `conflicting`: the ledger + * is deployment-wide, so switching it off would revoke it from organizations + * that kept it. + */ +export function convertDeactivatedCatalogRows(input: { + readonly positions: readonly ActivatableCatalogRow[]; + readonly permissionSets: readonly ActivatableCatalogRow[]; +}): CatalogDeactivationConversion { + const ledger: CatalogLedgerRow[] = []; + const conflicting: Array<{ metadata_type: 'position' | 'permission'; name: string; byOrganization: Record }> = []; + for (const [metadataType, rows] of [['position', input.positions], ['permission', input.permissionSets]] as const) { + const byName = new Map>(); + for (const row of rows) { + const name = text(row.name); + if (!name) continue; + const states = byName.get(name) ?? {}; + states[organizationKey(row.organization_id)] = !rowIsOff(row.active); + byName.set(name, states); + } + for (const [name, states] of byName) { + const values = Object.values(states); + if (values.every((on) => on)) continue; + if (values.every((on) => !on)) ledger.push({ metadata_type: metadataType, name, active: false }); + else conflicting.push({ metadata_type: metadataType, name, byOrganization: states }); + } + } + return { ledger, conflicting }; +} diff --git a/packages/core/src/security/resolve-authz-context.batch-equivalence.golden.json b/packages/core/src/security/resolve-authz-context.batch-equivalence.golden.json index 0884edd1989..0b1b68539f3 100644 --- a/packages/core/src/security/resolve-authz-context.batch-equivalence.golden.json +++ b/packages/core/src/security/resolve-authz-context.batch-equivalence.golden.json @@ -40,15 +40,21 @@ "isSystem": true }, { - "object": "sys_position", + "object": "sys_metadata_activation", "where": { + "metadata_type": { + "$in": [ + "position", + "permission" + ] + }, "name": { "$in": [ "everyone" ] } }, - "limit": 200, + "limit": 2, "isSystem": true }, { @@ -115,8 +121,14 @@ "isSystem": true }, { - "object": "sys_position", + "object": "sys_metadata_activation", "where": { + "metadata_type": { + "$in": [ + "position", + "permission" + ] + }, "name": { "$in": [ "org_owner", @@ -124,7 +136,7 @@ ] } }, - "limit": 200, + "limit": 4, "isSystem": true }, { @@ -188,15 +200,21 @@ "isSystem": true }, { - "object": "sys_position", + "object": "sys_metadata_activation", "where": { + "metadata_type": { + "$in": [ + "position", + "permission" + ] + }, "name": { "$in": [ "everyone" ] } }, - "limit": 200, + "limit": 2, "isSystem": true }, { @@ -272,47 +290,29 @@ "isSystem": true }, { - "object": "sys_position", + "object": "sys_metadata_activation", "where": { + "metadata_type": { + "$in": [ + "position", + "permission" + ] + }, "name": { "$in": [ "org_member", "contributor", "auditor", "retired", - "everyone" - ] - } - }, - "limit": 200, - "isSystem": true - }, - { - "object": "sys_position_permission_set", - "where": { - "position_id": { - "$in": [ - "p_contrib", - "p_auditor", - "p_everyone" - ] - } - }, - "limit": 500, - "isSystem": true - }, - { - "object": "sys_permission_set", - "where": { - "id": { - "$in": [ - "ps_write", - "ps_read", - "ps_base" + "everyone", + "write_all", + "read_all", + "admin_full_access", + "base_access" ] } }, - "limit": 500, + "limit": 18, "isSystem": true }, { @@ -386,51 +386,33 @@ "isSystem": true }, { - "object": "sys_position", + "object": "sys_metadata_activation", "where": { - "name": { + "metadata_type": { "$in": [ - "org_member", - "everyone" + "position", + "permission" ] - } - }, - "limit": 200, - "isSystem": true - }, - { - "object": "sys_user", - "where": { - "id": "u_ps" - }, - "limit": 1, - "isSystem": true - }, - { - "object": "sys_permission_set", - "where": { + }, "name": { "$in": [ + "org_member", + "everyone", "admin_full_access", "org_tools", "dead_set" ] - }, - "organization_id": null + } }, - "limit": 500, + "limit": 10, "isSystem": true }, { - "object": "sys_permission_set", + "object": "sys_user", "where": { - "name": { - "$in": [ - "org_tools" - ] - } + "id": "u_ps" }, - "limit": 500, + "limit": 1, "isSystem": true } ] @@ -493,29 +475,24 @@ "isSystem": true }, { - "object": "sys_position", + "object": "sys_metadata_activation", "where": { - "name": { + "metadata_type": { "$in": [ - "org_admin", - "everyone" + "position", + "permission" ] - } - }, - "limit": 200, - "isSystem": true - }, - { - "object": "sys_position_permission_set", - "where": { - "position_id": { + }, + "name": { "$in": [ - "p_orgadmin", - "p_everyone" + "org_admin", + "everyone", + "low", + "organization_admin" ] } }, - "limit": 500, + "limit": 8, "isSystem": true }, { @@ -525,31 +502,6 @@ }, "limit": 1, "isSystem": true - }, - { - "object": "sys_permission_set", - "where": { - "name": { - "$in": [ - "low" - ] - }, - "organization_id": null - }, - "limit": 500, - "isSystem": true - }, - { - "object": "sys_permission_set", - "where": { - "id": { - "$in": [ - "ps_oa" - ] - } - }, - "limit": 500, - "isSystem": true } ] }, @@ -615,8 +567,14 @@ "isSystem": true }, { - "object": "sys_position", + "object": "sys_metadata_activation", "where": { + "metadata_type": { + "$in": [ + "position", + "permission" + ] + }, "name": { "$in": [ "org_member", @@ -624,7 +582,7 @@ ] } }, - "limit": 200, + "limit": 4, "isSystem": true } ] @@ -678,15 +636,21 @@ "isSystem": true }, { - "object": "sys_position", + "object": "sys_metadata_activation", "where": { + "metadata_type": { + "$in": [ + "position", + "permission" + ] + }, "name": { "$in": [ "everyone" ] } }, - "limit": 200, + "limit": 2, "isSystem": true } ] @@ -748,29 +712,23 @@ "isSystem": true }, { - "object": "sys_position", + "object": "sys_metadata_activation", "where": { - "name": { + "metadata_type": { "$in": [ - "org_member", - "everyone" + "position", + "permission" ] - } - }, - "limit": 200, - "isSystem": true - }, - { - "object": "sys_permission_set", - "where": { + }, "name": { "$in": [ + "org_member", + "everyone", "extra" ] - }, - "organization_id": null + } }, - "limit": 500, + "limit": 6, "isSystem": true } ] @@ -2025,8 +1983,14 @@ "isSystem": true }, { - "object": "sys_position", + "object": "sys_metadata_activation", "where": { + "metadata_type": { + "$in": [ + "position", + "permission" + ] + }, "name": { "$in": [ "org_member", @@ -2034,7 +1998,7 @@ ] } }, - "limit": 200, + "limit": 4, "isSystem": true }, { @@ -2094,8 +2058,14 @@ "isSystem": true }, { - "object": "sys_position", + "object": "sys_metadata_activation", "where": { + "metadata_type": { + "$in": [ + "position", + "permission" + ] + }, "name": { "$in": [ "org_owner", @@ -2105,7 +2075,7 @@ ] } }, - "limit": 200, + "limit": 8, "isSystem": true }, { diff --git a/packages/core/src/security/resolve-authz-context.batch-equivalence.test.ts b/packages/core/src/security/resolve-authz-context.batch-equivalence.test.ts index 297e0b23446..ad03dc227ac 100644 --- a/packages/core/src/security/resolve-authz-context.batch-equivalence.test.ts +++ b/packages/core/src/security/resolve-authz-context.batch-equivalence.test.ts @@ -47,6 +47,28 @@ * sequential capture. The fixtures' grants carry the name the platform writers * store beside the id. The by-name read joins the `sys_position` read's leg, so * a principal whose sets are all held directly resolves in two legs, not three. + * + * ⚠️ A second recorded move ([ADR-0131] D3/D4, C3 stage 1): a position's sets + * are the `permissionSets` its catalog definition names, and every set body is + * its catalog definition. The recording double carries that catalog: the + * definitions the fixture's own rows convert to, the junction rows through the + * upgrade ceremony's `convertPositionBindingRows`. In the two fixtures whose + * positions bind sets (`position-derived-grants`, `tenant-admin-via-position`), + * the `sys_position_permission_set` read and the `sys_permission_set` read by + * the junction's ids are gone. Every `grants` envelope is byte-identical to the + * capture: the switch moved where a grant is read from, never what it grants. + * + * ⚠️ A third recorded move, in the same stage ([ADR-0049] as ADR-0131 D3 and + * ADR-0126 §4 place it): whether a position or a set is switched off is the + * activation ledger's answer, never the catalog row's `active` column. In + * every fixture the `sys_position` read, and every by-name `sys_permission_set` + * read, is replaced by ONE `sys_metadata_activation` read keyed by type + * (`position`, `permission`) and by every name the resolution may grant; its + * limit is two rows a name, the ledger being unique on (type, name). The + * recording double serves the ledger rows the fixture's `active: false` rows + * convert to (`convertDeactivatedCatalogRows`, the upgrade ceremony's), so the + * deactivated `retired` position and `dead_set` set still grant nothing. Every + * fixture now resolves in two legs. */ import { readFileSync } from 'node:fs'; @@ -84,21 +106,18 @@ const GOLDEN: Record = JSON.parse( * control, written out per fixture rather than derived, so a regression that * re-serialises one read shows up as a number rather than as a slower suite. * - * Four is the floor, not three: wave 1 (every independent read) → wave 2 - * (`sys_position`, needs the position NAMES wave 1 produced) → wave 3 - * (`sys_position_permission_set`, needs the position IDS wave 2 produced) → - * wave 4 (`sys_permission_set` by the junction's ids). The directly granted - * sets are read by NAME in wave 2, beside `sys_position` ([ADR-0131] D4), so a - * principal with no `sys_position` row backing any of its position names skips - * waves 3 and 4 and lands in 2. + * Two is the floor: wave 1 (every independent read) → wave 2 (the activation + * ledger, which needs the position NAMES wave 1 produced, and the set names the + * grants and the positions' catalog definitions name — [ADR-0131] D3/D4). The + * definitions are read in-process, so no wave waits on a set row. */ const BATCHED_LEGS: Record = { 'empty-principal': 2, 'multi-org-membership': 2, 'lapsed-own-membership-among-active-peers': 2, - 'position-derived-grants': 4, + 'position-derived-grants': 2, 'permission-set-derived-grants': 2, - 'tenant-admin-via-position': 4, + 'tenant-admin-via-position': 2, 'ai-seat-and-email-from-sys-user': 2, 'ai-seat-denied': 2, 'seeded-permissions-and-email': 2, @@ -184,7 +203,7 @@ describe('[#10825] batched resolveUserAuthzGrants — equivalence with the seque expect(ql.calls[0]!.where).toEqual({ name: { $in: ['org_member', 'everyone'] } }); }); - it('records the real `sys_position` read without the `platform_admin` unshifted in afterwards', async () => { + it('records the real position read (the activation ledger) without the `platform_admin` unshifted in afterwards', async () => { const f = FIXTURES.find((x) => x.name === 'permission-set-derived-grants')!; const ql = makeRecordingQl(f.tables); const grants = (await resolveUserAuthzGrants(ql, f.userId, f.opts)) as { positions: string[] }; @@ -193,9 +212,14 @@ describe('[#10825] batched resolveUserAuthzGrants — equivalence with the seque expect(grants.positions[0]).toBe(BUILTIN_IDENTITY_PLATFORM_ADMIN); // The defect was recording that name into the QUERY, which was issued // before the unshift ran and therefore never carried it. - const positionReads = ql.calls.filter((c) => c.object === 'sys_position'); + // [ADR-0131 D3] The held positions are read by name from the activation + // ledger now, beside the sets the resolution may grant. + const positionReads = ql.calls.filter((c) => c.object === 'sys_metadata_activation'); expect(positionReads).toHaveLength(1); - expect(positionReads[0]!.where).toEqual({ name: { $in: ['org_member', 'everyone'] } }); + expect(positionReads[0]!.where).toEqual({ + metadata_type: { $in: ['position', 'permission'] }, + name: { $in: ['org_member', 'everyone', 'admin_full_access', 'org_tools', 'dead_set'] }, + }); expect(JSON.stringify(positionReads[0]!.where)).not.toContain(BUILTIN_IDENTITY_PLATFORM_ADMIN); }); }); @@ -234,7 +258,7 @@ describe('[#10825] batched resolveUserAuthzGrants — equivalence with the seque expect(wave1).toEqual( asMultiset(GOLDEN[name].queries) .map((s) => JSON.parse(s) as RecordedCall) - .filter((c) => c.object !== 'sys_position' && c.object !== 'sys_position_permission_set' && c.object !== 'sys_permission_set') + .filter((c) => c.object !== 'sys_metadata_activation') .map((c) => c.object) .sort(), ); diff --git a/packages/core/src/security/resolve-authz-context.grant-set-by-name.test.ts b/packages/core/src/security/resolve-authz-context.grant-set-by-name.test.ts index 9561ebd5cf9..c9ffc353324 100644 --- a/packages/core/src/security/resolve-authz-context.grant-set-by-name.test.ts +++ b/packages/core/src/security/resolve-authz-context.grant-set-by-name.test.ts @@ -1,23 +1,25 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. /** - * [ADR-0131 D4] `resolveUserAuthzGrants` §6 finds a user grant's permission - * set BY NAME — `sys_user_permission_set.permission_set` — on the grant's own - * organization's row, else the organization-less row. Its - * `permission_set_id` is not read. + * [ADR-0131 D3/D4] `resolveUserAuthzGrants` §6 resolves a user grant's + * permission set BY NAME — `sys_user_permission_set.permission_set` — in the + * security catalog: the set exists, and grants what it grants, because the + * catalog holds a definition of that name. Its `permission_set_id` is not read. + * No catalog row is read at all: whether a set or a position is switched off + * (ADR-0049) is the activation ledger's answer (`sys_metadata_activation`, + * ADR-0126 §4), deployment-wide. * * What is pinned here, on the recording double the batch-equivalence suite - * drives (the real-engine half, over the SQL driver and the real write hooks, - * is `plugin-security`'s `resolve-authz-grant-set-by-name.test.ts`): + * drives (with an explicit catalog bound over it): * - * - a grant whose id names another organization's set confers nothing — the - * set row the id points at is never read; - * - a grant whose NAME is carried only by another organization's row confers - * nothing either; + * - the body is the catalog definition, whichever organization's row carries + * the name — and a name only a row carries (no definition) confers nothing; * - a grant that names nothing confers nothing, platform standing included; - * - an organization's own row wins over the organization-less one, and a - * deactivated own row is the answer — it does not fall through; - * - position-bound sets are still reached through the junction's id. + * - platform standing stands on an organization-less grant only; + * - deactivation: a ledger row switches a name off in every organization, and + * a catalog row's `active` column is not read; + * - position-bound sets are the ones the position's definition names, and the + * junction rows are not read at all. */ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; @@ -25,6 +27,7 @@ import { ADMIN_FULL_ACCESS } from '@objectstack/spec'; import { resetPlatformAdminEmailMemo } from './platform-admin.js'; import { resolveUserAuthzGrants } from './resolve-authz-context.js'; import { makeRecordingQl } from './__tests__/resolve-authz-context.batch-equivalence.testkit.js'; +import { bindStaticSecurityCatalog } from './__tests__/security-catalog.testkit.js'; const ORG_A = 'org_a'; const ORG_B = 'org_b'; @@ -46,20 +49,38 @@ afterAll(() => { resetPlatformAdminEmailMemo(); }); -/** The catalog: one name per organization, plus the organization-less platform rows. */ +/** The catalog rows: one name per organization, plus the organization-less platform rows. */ const catalog = () => [ - { id: 'ps_admin', name: ADMIN_FULL_ACCESS, organization_id: null, system_permissions: ['manage_users'] }, - { id: 'ps_admin_b', name: ADMIN_FULL_ACCESS, organization_id: ORG_B, system_permissions: ['manage_users'] }, - { id: 'ps_tools_a', name: 'tools', organization_id: ORG_A, system_permissions: ['cap_a'] }, - { id: 'ps_tools_b', name: 'tools', organization_id: ORG_B, system_permissions: ['cap_b'] }, - { id: 'ps_tools', name: 'tools', organization_id: null, system_permissions: ['cap_global'] }, - { id: 'ps_b_only', name: 'b_only', organization_id: ORG_B, system_permissions: ['cap_b_only'] }, - { id: 'ps_shared', name: 'shared', organization_id: null, system_permissions: ['cap_shared'] }, - { id: 'ps_off_a', name: 'switched', organization_id: ORG_A, active: false, system_permissions: ['cap_off'] }, - { id: 'ps_on', name: 'switched', organization_id: null, active: true, system_permissions: ['cap_on'] }, - { id: 'ps_bound', name: 'bound_by_position', organization_id: null, system_permissions: ['cap_bound'] }, + { id: 'ps_admin', name: ADMIN_FULL_ACCESS, organization_id: null }, + { id: 'ps_admin_b', name: ADMIN_FULL_ACCESS, organization_id: ORG_B }, + { id: 'ps_tools_a', name: 'tools', organization_id: ORG_A }, + { id: 'ps_tools_b', name: 'tools', organization_id: ORG_B }, + { id: 'ps_tools', name: 'tools', organization_id: null }, + { id: 'ps_row_only', name: 'row_only', organization_id: ORG_B }, + { id: 'ps_off_a', name: 'switched', organization_id: ORG_A }, + { id: 'ps_on', name: 'switched', organization_id: null }, + { id: 'ps_bound', name: 'bound_by_position', organization_id: null }, ]; +/** The definitions the catalog holds — the only place a set's body lives. */ +const definitions = { + permissions: [ + { name: ADMIN_FULL_ACCESS, systemPermissions: ['manage_users'] }, + { name: 'tools', systemPermissions: ['cap_tools'] }, + { name: 'switched', systemPermissions: ['cap_switched'] }, + { name: 'bound_by_position', systemPermissions: ['cap_bound'] }, + { name: 'no_row', systemPermissions: ['cap_no_row'] }, + ], + positions: [] as Array>, +}; + +/** The same catalog, with the `everyone` anchor distributing one set. */ +const withEveryone = { ...definitions, positions: [{ name: 'everyone', permissionSets: ['bound_by_position'] }] }; + +/** One activation-ledger row switching `name` off. */ +const off = (metadata_type: 'position' | 'permission', name: string) => + ({ metadata_type, name, package_id: null, active: false }); + function tables(grants: Array>, extra: Record = {}) { return { sys_user: [{ id: 'u1', email: 'u1@example.com' }], @@ -69,85 +90,121 @@ function tables(grants: Array>, extra: Record ({ user_id: 'u1', ...g })), sys_permission_set: catalog(), + // Stated, never derived from the rows: the ledger is the only switch. + sys_metadata_activation: [], ...extra, }; } -async function resolve(grants: Array>, tenantId?: string, extra?: Record) { - const ql = makeRecordingQl(tables(grants, extra)); +async function resolve( + grants: Array>, + tenantId?: string, + extra?: Record, + catalogDefinitions: typeof definitions = definitions, +) { + const ql = bindStaticSecurityCatalog(makeRecordingQl(tables(grants, extra)), catalogDefinitions); const out = await resolveUserAuthzGrants(ql, 'u1', tenantId ? { tenantId } : {}); return { out, ql }; } -describe('[ADR-0131 D4] a grant whose id names another organization’s set confers nothing', () => { - it('an unnamed organization-less grant pointing at another organization’s set: nothing, and that row is never read', async () => { - for (const tenantId of [undefined, ORG_A, ORG_B]) { - const { out, ql } = await resolve([{ permission_set_id: 'ps_b_only', organization_id: null }], tenantId); - expect(out.permissions, String(tenantId)).not.toContain('b_only'); - expect(out.systemPermissions, String(tenantId)).not.toContain('cap_b_only'); - expect(JSON.stringify(ql.calls), String(tenantId)).not.toContain('ps_b_only'); +describe('[ADR-0131 D3/D4] a grant names its set; the catalog says what the set is', () => { + it('the body is the definition, whichever organization\'s row carries the name', async () => { + for (const [grantOrg, tenantId] of [[ORG_A, ORG_A], [null, ORG_A], [ORG_B, ORG_B], [null, undefined]] as const) { + const { out } = await resolve([{ permission_set_id: 'ps_tools_a', permission_set: 'tools', organization_id: grantOrg }], tenantId); + expect(out.permissions, `${grantOrg} in ${tenantId}`).toEqual(['tools']); + expect(out.systemPermissions, `${grantOrg} in ${tenantId}`).toEqual(['cap_tools']); } }); - it('a grant named after a set only another organization carries: nothing, in that organization or out of it', async () => { - const orgLess = await resolve([{ permission_set_id: 'ps_b_only', permission_set: 'b_only', organization_id: null }], ORG_B); - expect(orgLess.out.permissions).not.toContain('b_only'); - const ownOrg = await resolve([{ permission_set_id: 'ps_b_only', permission_set: 'b_only', organization_id: ORG_A }], ORG_A); - expect(ownOrg.out.permissions).not.toContain('b_only'); - expect(ownOrg.out.systemPermissions).not.toContain('cap_b_only'); + it('a name only a ROW carries — no definition — confers nothing, in its organization or out of it', async () => { + for (const [grantOrg, tenantId] of [[ORG_B, ORG_B], [null, ORG_B], [null, undefined]] as const) { + const { out } = await resolve([{ permission_set_id: 'ps_row_only', permission_set: 'row_only', organization_id: grantOrg }], tenantId); + expect(out.permissions, `${grantOrg} in ${tenantId}`).toEqual([]); + } }); - it('CONTROL — the same name on a row the grant may name confers it', async () => { - const own = await resolve([{ permission_set_id: 'ps_b_only', permission_set: 'b_only', organization_id: ORG_B }], ORG_B); - expect(own.out.permissions).toContain('b_only'); - expect(own.out.systemPermissions).toContain('cap_b_only'); + it('CONTROL — a definition with no row at all confers its body: no row carries no flag to read', async () => { + const { out } = await resolve([{ permission_set: 'no_row', organization_id: null }], ORG_A); + expect(out.permissions).toEqual(['no_row']); + expect(out.systemPermissions).toEqual(['cap_no_row']); }); - it('an organization-less grant named after admin_full_access stands on the organization-less row only', async () => { - const only = (rows: any[]) => ({ sys_permission_set: rows }); - const onCopy = await resolve( - [{ permission_set_id: 'ps_admin_b', permission_set: ADMIN_FULL_ACCESS, organization_id: null }], - ORG_B, - only(catalog().filter((r) => r.id !== 'ps_admin')), - ); - expect(onCopy.out.posture).not.toBe('PLATFORM_ADMIN'); - expect(onCopy.out.permissions).not.toContain(ADMIN_FULL_ACCESS); + it('the id does not decide: a grant named `tools` whose id points at another set resolves `tools`', async () => { + const { out } = await resolve([{ permission_set_id: 'ps_admin', permission_set: 'tools', organization_id: ORG_A }], ORG_A); + expect(out.permissions).toEqual(['tools']); + expect(out.posture).not.toBe('PLATFORM_ADMIN'); + }); - const onPlatformRow = await resolve( - [{ permission_set_id: 'ps_admin_b', permission_set: ADMIN_FULL_ACCESS, organization_id: null }], - ORG_B, - ); - expect(onPlatformRow.out.posture).toBe('PLATFORM_ADMIN'); + it('the junction, the id bridge and the catalog rows are never read', async () => { + const { ql } = await resolve([{ permission_set_id: 'ps_tools_a', permission_set: 'tools', organization_id: ORG_A }], ORG_A); + const objects = ql.calls.map((c) => c.object); + expect(objects).not.toContain('sys_position_permission_set'); + expect(objects).not.toContain('sys_permission_set'); + expect(objects).not.toContain('sys_position'); }); +}); - it('an ORGANIZATION’s admin_full_access grant is that organization’s set, never platform standing', async () => { +describe('[ADR-0068 D2] platform standing stands on an organization-less grant only', () => { + it('an organization-less grant named admin_full_access holds platform standing', async () => { + const { out } = await resolve([{ permission_set_id: 'ps_admin_b', permission_set: ADMIN_FULL_ACCESS, organization_id: null }], ORG_B); + expect(out.posture).toBe('PLATFORM_ADMIN'); + expect(out.positions[0]).toBe('platform_admin'); + }); + + it('an ORGANIZATION’s admin_full_access grant carries the set, never platform standing', async () => { const { out } = await resolve([{ permission_set_id: 'ps_admin_b', permission_set: ADMIN_FULL_ACCESS, organization_id: ORG_B }], ORG_B); expect(out.permissions).toContain(ADMIN_FULL_ACCESS); expect(out.posture).not.toBe('PLATFORM_ADMIN'); }); + + it('with no admin_full_access definition in the catalog, the grant confers nothing at all', async () => { + const { out } = await resolve( + [{ permission_set_id: 'ps_admin', permission_set: ADMIN_FULL_ACCESS, organization_id: null }], + undefined, + undefined, + { ...definitions, permissions: definitions.permissions.filter((d) => d.name !== ADMIN_FULL_ACCESS) }, + ); + expect(out.posture).not.toBe('PLATFORM_ADMIN'); + expect(out.permissions).toEqual([]); + }); + + it('admin_full_access switched off in the activation ledger takes the standing away', async () => { + const { out } = await resolve( + [{ permission_set_id: 'ps_admin', permission_set: ADMIN_FULL_ACCESS, organization_id: null }], + undefined, + { sys_metadata_activation: [off('permission', ADMIN_FULL_ACCESS)] }, + ); + expect(out.posture).not.toBe('PLATFORM_ADMIN'); + expect(out.permissions).toEqual([]); + }); }); -describe('[ADR-0131 D4] the row a name resolves to', () => { - it('an organization’s grant reads its own row first, and the organization-less row only where it has none', async () => { - const own = await resolve([{ permission_set_id: 'ps_tools_a', permission_set: 'tools', organization_id: ORG_A }], ORG_A); - expect(own.out.systemPermissions).toEqual(['cap_a']); - const fallback = await resolve([{ permission_set_id: 'ps_shared', permission_set: 'shared', organization_id: ORG_A }], ORG_A); - expect(fallback.out.systemPermissions).toEqual(['cap_shared']); - // An organization-less grant of the same name reads the organization-less row, inside an organization too. - const global = await resolve([{ permission_set_id: 'ps_tools', permission_set: 'tools', organization_id: null }], ORG_A); - expect(global.out.systemPermissions).toEqual(['cap_global']); +describe('[ADR-0049, ADR-0131 D3] the activation ledger switches a set off', () => { + it('a ledger row switches the name off in every organization: no set, no capability', async () => { + for (const [grantOrg, tenantId] of [[ORG_A, ORG_A], [null, ORG_A], [null, undefined]] as const) { + const { out } = await resolve( + [{ permission_set_id: 'ps_off_a', permission_set: 'switched', organization_id: grantOrg }], + tenantId, + { sys_metadata_activation: [off('permission', 'switched')] }, + ); + expect(out.permissions, `${grantOrg} in ${tenantId}`).toEqual([]); + expect(out.systemPermissions, `${grantOrg} in ${tenantId}`).toEqual([]); + } }); - it('a deactivated own row is the answer: it confers nothing and does not fall through to the organization-less row', async () => { + it('CONTROL — with no ledger row the same grant resolves', async () => { const { out } = await resolve([{ permission_set_id: 'ps_off_a', permission_set: 'switched', organization_id: ORG_A }], ORG_A); - expect(out.permissions).not.toContain('switched'); - expect(out.systemPermissions).toEqual([]); + expect(out.permissions).toEqual(['switched']); + expect(out.systemPermissions).toEqual(['cap_switched']); }); - it('the id does not decide: a grant named `tools` whose id points at another set resolves `tools`', async () => { - const { out } = await resolve([{ permission_set_id: 'ps_shared', permission_set: 'tools', organization_id: ORG_A }], ORG_A); - expect(out.permissions).toEqual(['tools']); - expect(out.systemPermissions).toEqual(['cap_a']); + it('a catalog row\'s `active: false` is not read — no row fallback', async () => { + const { out } = await resolve( + [{ permission_set_id: 'ps_off_a', permission_set: 'switched', organization_id: ORG_A }], + ORG_A, + { sys_permission_set: [{ id: 'ps_off_a', name: 'switched', organization_id: ORG_A, active: false }] }, + ); + expect(out.permissions).toEqual(['switched']); }); }); @@ -167,19 +224,24 @@ describe('[ADR-0131 D4] a grant that names nothing confers nothing', () => { expect(out.positions).not.toContain('platform_admin'); expect(out.permissions).not.toContain(ADMIN_FULL_ACCESS); }); +}); - it('CONTROL — the same grant carrying its name holds platform standing', async () => { - const { out } = await resolve([{ permission_set_id: 'ps_admin', permission_set: ADMIN_FULL_ACCESS, organization_id: null }], undefined); - expect(out.posture).toBe('PLATFORM_ADMIN'); - expect(out.positions[0]).toBe('platform_admin'); - }); - - it('position-bound sets are still read through the junction’s id (ADR-0131 C3)', async () => { - const { out } = await resolve([], ORG_A, { +describe('[ADR-0131 D3/D4] position-bound sets are the ones the position definition names', () => { + it('the `everyone` definition\'s set resolves; a junction row binding another set confers nothing', async () => { + const { out, ql } = await resolve([], ORG_A, { sys_position: [{ id: 'p_everyone', name: 'everyone', organization_id: ORG_A }], - sys_position_permission_set: [{ position_id: 'p_everyone', permission_set_id: 'ps_bound' }], - }); + sys_position_permission_set: [{ position_id: 'p_everyone', permission_set_id: 'ps_tools_a' }], + }, withEveryone); expect(out.permissions).toEqual(['bound_by_position']); expect(out.systemPermissions).toEqual(['cap_bound']); + expect(ql.calls.map((c) => c.object)).not.toContain('sys_position_permission_set'); + }); + + it('a position switched off in the activation ledger drops the sets its definition names', async () => { + const { out } = await resolve([], ORG_A, { + sys_metadata_activation: [off('position', 'everyone')], + }, withEveryone); + expect(out.permissions).toEqual([]); + expect(out.positions).not.toContain('everyone'); }); }); diff --git a/packages/core/src/security/resolve-authz-context.platform-admin-config.test.ts b/packages/core/src/security/resolve-authz-context.platform-admin-config.test.ts index f4d5e0abc57..16b409d85d8 100644 --- a/packages/core/src/security/resolve-authz-context.platform-admin-config.test.ts +++ b/packages/core/src/security/resolve-authz-context.platform-admin-config.test.ts @@ -44,6 +44,7 @@ import { resolveAuthzContext, resolveUserAuthzGrants, } from './resolve-authz-context.js'; +import { bindCatalogFromTables } from './__tests__/security-catalog.testkit.js'; const ENV = 'OS_PLATFORM_OWNER_EMAIL'; /** @@ -105,14 +106,14 @@ function makeQl(tables: Record>>) { if (v === null) return (row[k] ?? null) === null; return row[k] === v; }); - return { + return bindCatalogFromTables({ calls, async find(object: string, opts: any) { calls.push({ object, where: structuredClone(opts?.where ?? null) }); const rows = (tables[object] ?? []).filter((r) => matches(r, opts?.where)); return typeof opts?.limit === 'number' ? rows.slice(0, opts.limit) : rows; }, - }; + }, tables); } /** A `sys_user`-only fixture: no grant rows anywhere, so standing can only be config-derived. */ diff --git a/packages/core/src/security/resolve-authz-context.request-grants-memo.test.ts b/packages/core/src/security/resolve-authz-context.request-grants-memo.test.ts index aa9f44f5d7f..f82e86d7d49 100644 --- a/packages/core/src/security/resolve-authz-context.request-grants-memo.test.ts +++ b/packages/core/src/security/resolve-authz-context.request-grants-memo.test.ts @@ -418,10 +418,10 @@ describe('request-scoped grants memo — the round-trip count', () => { const standaloneQl = makeQl(makeTables()); await resolveUserAuthzGrants(standaloneQl, 'u_member', { tenantId: 'org_a', seedEmail: 'member@x.com', nowMs: T0 }); const oneResolution = standaloneQl.calls.length; - // [ADR-0131 D4] Ten: the member's organization grant reads its set by name, - // the organization's own row and the organization-less one, where the id - // read was one. - expect(oneResolution).toBe(10); + // [ADR-0131 D3/D4] Six: the sets and the positions are read from the + // catalog in-process, and ONE activation-ledger read answers which of them + // are switched off — no `sys_position` or `sys_permission_set` read. + expect(oneResolution).toBe(6); const memoQl = await armedQl(makeTables()); const withMemo = await resolveAuthzContext({ ql: memoQl, headers: {}, getSession: sessionReadThatResolves(memoQl, member), nowMs: T0 }); @@ -447,10 +447,10 @@ describe('request-scoped grants memo — the round-trip count', () => { expect(ql.middlewareCount()).toBe(0); const first = await resolveAuthzContext({ ql, headers: {}, getSession: sessionReadThatResolves(ql, member), nowMs: T0 }); expect(ql.middlewareCount()).toBe(1); - expect(ql.calls.length).toBe(20); + expect(ql.calls.length).toBe(12); const before = ql.calls.length; const second = await resolveAuthzContext({ ql, headers: {}, getSession: sessionReadThatResolves(ql, member), nowMs: T0 }); - expect(ql.calls.length - before).toBe(10); + expect(ql.calls.length - before).toBe(6); expect(ql.middlewareCount()).toBe(1); expect(second).toEqual(first); }); @@ -488,9 +488,8 @@ describe('request-scoped grants memo — isolation', () => { expect(memberCtx.positions).toContain('org_owner'); // the member OWNS org_b expect(ownerCtx.org_user_ids).not.toContain('u_outsider'); expect(memberCtx.org_user_ids).toEqual(expect.arrayContaining(['u_member', 'u_outsider'])); - // Two requests, one resolution each — neither holds a user grant that - // applies where it operates, so neither reads a set by name. - expect(ql.calls.length).toBe(16); + // Two requests, one resolution each, six reads apiece. + expect(ql.calls.length).toBe(12); }); it('nothing outlives the request: the next request reads the grants as they are now', async () => { @@ -508,7 +507,7 @@ describe('request-scoped grants memo — isolation', () => { const second = await resolveAuthzContext({ ql, headers: {}, getSession: sessionReadThatResolves(ql, member), nowMs: T0, tenancyPosture: 'isolated' }); expect(second.positions).not.toContain('auditor'); expect(second.permissions).not.toContain('read_all'); - expect(ql.calls.length - before).toBe(10); + expect(ql.calls.length - before).toBe(6); }); it('a continuation the request started reads afresh once the request settled', async () => { @@ -533,7 +532,7 @@ describe('request-scoped grants memo — isolation', () => { const before = ql.calls.length; settled.resolve(); await late; - expect(ql.calls.length - before).toBe(10); + expect(ql.calls.length - before).toBe(6); }); it('a session read resolved against a SECOND engine serves nothing to the first engine\'s step 2', async () => { @@ -547,8 +546,8 @@ describe('request-scoped grants memo — isolation', () => { nowMs: T0, tenancyPosture: 'isolated', }); - expect(qlB.calls.length).toBe(10); - expect(qlA.calls.length).toBe(10); + expect(qlB.calls.length).toBe(6); + expect(qlA.calls.length).toBe(6); const baseline = await resolveAuthzContext({ ql: makeQl(makeTables()), headers: {}, getSession: sessionReadOnly(member), nowMs: T0, tenancyPosture: 'isolated' }); expect(ctx).toEqual(baseline); }); @@ -561,7 +560,7 @@ describe('request-scoped grants memo — isolation', () => { headers: {}, getSession: async () => { // The inner call resolves twice-over-once in ITS OWN scope: its session - // read's resolution serves its step 2 (10 reads), and that scope closes. + // read's resolution serves its step 2 (6 reads), and that scope closes. const inner = await resolveAuthzContext({ ql, headers: {}, @@ -576,7 +575,7 @@ describe('request-scoped grants memo — isolation', () => { tenancyPosture: 'isolated', }); // Inner 8, outer step 2 afresh 8. - expect(ql.calls.length).toBe(20); + expect(ql.calls.length).toBe(12); const baseline = await resolveAuthzContext({ ql: makeQl(makeTables()), headers: {}, getSession: sessionReadOnly(member), nowMs: T0, tenancyPosture: 'isolated' }); expect(ctx).toEqual(baseline); }); @@ -620,7 +619,7 @@ describe('request-scoped grants memo — step 2 reads afresh whenever a fresh re expect(ctx.positions).not.toContain('auditor'); expect(ctx).toEqual(await postWriteBaseline()); // …because step 2 read afresh after the landing. - expect(ql.calls.length).toBe(20); + expect(ql.calls.length).toBe(12); }); it('a write that bumped before the first resolution and is still in flight at step 2', async () => { @@ -632,7 +631,7 @@ describe('request-scoped grants memo — step 2 reads afresh whenever a fresh re tables.sys_user_position = withoutMembersAuditor(tables.sys_user_position); }); const ctx = await resolveAuthzContext({ ql, headers: {}, getSession: sessionReadThatResolves(ql, member), nowMs: T0, tenancyPosture: 'isolated' }); - expect(ql.calls.length).toBe(20); + expect(ql.calls.length).toBe(12); // Not landed yet: a fresh read still sees the row, and so did step 2. expect(ctx.positions).toContain('auditor'); landing.resolve(); @@ -657,7 +656,7 @@ describe('request-scoped grants memo — step 2 reads afresh whenever a fresh re tenancyPosture: 'isolated', }); expect(ctx.positions).not.toContain('auditor'); - expect(ql.calls.length).toBe(20); + expect(ql.calls.length).toBe(12); expect(ctx).toEqual(await postWriteBaseline()); }); @@ -675,7 +674,7 @@ describe('request-scoped grants memo — step 2 reads afresh whenever a fresh re nowMs: T0, tenancyPosture: 'isolated', }); - expect(ql.calls.length).toBe(20); + expect(ql.calls.length).toBe(12); }); it('a write that starts while the first resolution is still reading', async () => { @@ -684,19 +683,19 @@ describe('request-scoped grants memo — step 2 reads afresh whenever a fresh re let started = false; let w: Promise | undefined; ql.find = async (object: string, opts: any) => { - if (!started && object === 'sys_position') { started = true; w = ql.write('sys_audit_log', 'insert', async () => {}); } + if (!started && object === 'sys_metadata_activation') { started = true; w = ql.write('sys_audit_log', 'insert', async () => {}); } return find(object, opts); }; await resolveAuthzContext({ ql, headers: {}, getSession: sessionReadThatResolves(ql, member), nowMs: T0, tenancyPosture: 'isolated' }); await w; - expect(ql.calls.length).toBe(20); + expect(ql.calls.length).toBe(12); }); it('a step-2 clock later than the first resolution\'s, inside its validity window, is served', async () => { // temp_role is valid until T0 + 1 day: T0 + 1 hour is inside the window. const ql = await armedQl(makeTables()); const ctx = await resolveAuthzContext({ ql, headers: {}, getSession: sessionReadThatResolves(ql, member, T0), nowMs: T0 + HOUR, tenancyPosture: 'isolated' }); - expect(ql.calls.length).toBe(10); + expect(ql.calls.length).toBe(6); expect(ctx.positions).toContain('temp_role'); const baseline = await resolveAuthzContext({ ql: makeQl(makeTables()), headers: {}, getSession: sessionReadOnly(member), nowMs: T0 + HOUR, tenancyPosture: 'isolated' }); expect(ctx).toEqual(baseline); @@ -709,14 +708,14 @@ describe('request-scoped grants memo — step 2 reads afresh whenever a fresh re const ctx = await resolveAuthzContext({ ql, headers: {}, getSession: sessionReadThatResolves(ql, member, T0), nowMs: T0 + 2 * DAY, tenancyPosture: 'isolated' }); expect(ctx.positions).not.toContain('temp_role'); expect(ctx.permissions).not.toContain('temp_tools'); - expect(ql.calls.length).toBe(20); + expect(ql.calls.length).toBe(12); const baseline = await resolveAuthzContext({ ql: makeQl(makeTables()), headers: {}, getSession: sessionReadOnly(member), nowMs: T0 + 2 * DAY, tenancyPosture: 'isolated' }); expect(ctx).toEqual(baseline); // A step-2 clock EARLIER than the resolution it would be served. const ql2 = await armedQl(makeTables()); await resolveAuthzContext({ ql: ql2, headers: {}, getSession: sessionReadThatResolves(ql2, member, T0), nowMs: T0 - DAY, tenancyPosture: 'isolated' }); - expect(ql2.calls.length).toBe(20); + expect(ql2.calls.length).toBe(12); }); it('a bypassGrantsCache caller is never served from the memo', async () => { @@ -734,7 +733,7 @@ describe('request-scoped grants memo — step 2 reads afresh whenever a fresh re tenancyPosture: 'isolated', }); // session read 8 + the bypass caller's own 8 + step 2 served (0). - expect(ql.calls.length).toBe(20); + expect(ql.calls.length).toBe(12); }); it('a first resolution that failed is not remembered', async () => { @@ -759,7 +758,7 @@ describe('request-scoped grants memo — served values are clones', () => { const sink: { payload?: any; grants?: UserAuthzGrants } = {}; const member = sessionOf('u_member', 'member@x.com', 'org_a'); const ctx = await resolveAuthzContext({ ql, headers: {}, getSession: sessionReadThatResolves(ql, member, T0, sink), nowMs: T0, tenancyPosture: 'isolated' }); - expect(ql.calls.length).toBe(10); + expect(ql.calls.length).toBe(6); expect(ctx.positions).toEqual(sink.payload.user.positions); expect(ctx.positions).not.toBe(sink.payload.user.positions); ctx.positions.push('mutated_downstream'); diff --git a/packages/core/src/security/resolve-authz-context.test.ts b/packages/core/src/security/resolve-authz-context.test.ts index 4e5a7ed3b91..f61ef17cc3a 100644 --- a/packages/core/src/security/resolve-authz-context.test.ts +++ b/packages/core/src/security/resolve-authz-context.test.ts @@ -5,6 +5,7 @@ import { hasPlatformAdminStanding, resolveAuthzContext, resolveUserAuthzGrants, import { POSTURE_RANK } from './posture-ladder.js'; import { hashApiKey } from './api-key.js'; import type { AuthzPosture } from '@objectstack/spec/security'; +import { bindCatalogFromTables, bindStaticSecurityCatalog } from './__tests__/security-catalog.testkit.js'; /** * [#14273 A1] The refusal REASON is observable on exactly ONE surface: the @@ -54,7 +55,7 @@ function bounded(rows: T[], opts: any): T[] { } function makeQl(tables: Record) { - return { + return bindCatalogFromTables({ async find(object: string, opts: any) { const rows = tables[object] ?? []; const where = opts?.where ?? {}; @@ -70,7 +71,7 @@ function makeQl(tables: Record) { opts, ); }, - }; + }, tables); } const session = (userId: string, opts: { email?: string; org?: string } = {}) => async () => ({ user: { id: userId, email: opts.email }, session: { activeOrganizationId: opts.org ?? null } }); @@ -161,7 +162,7 @@ describe('resolveAuthzContext — single source of truth', () => { // assert the de-duplication of redundant authz/localization reads (#2409). function makeCountingQl(tables: Record) { const counts: Record = {}; - return { + return bindCatalogFromTables({ counts, async find(object: string, opts: any) { counts[object] = (counts[object] ?? 0) + 1; @@ -179,7 +180,7 @@ function makeCountingQl(tables: Record) { opts, ); }, - }; + }, tables); } describe('resolveAuthzContext — request-scoped read de-duplication (#2409)', () => { @@ -1332,20 +1333,27 @@ describe('resolveAuthzContext — API-key organization (#8287)', () => { * over-revoke a set the user also holds in their own right. * * The predicate is "explicitly deactivated", not "explicitly active": absent - * means ACTIVE, so a row that predates the column keeps working. Every fixture - * above this block carries no `active` key at all and is the pin for that + * means ACTIVE, so a name with no ledger row keeps working. Every fixture + * above this block carries no ledger row at all and is the pin for that * direction — requiring `true` would have turned this file red wholesale, which * is what it would do to deployed data. + * + * [ADR-0131 D3, ADR-0126 §4] The switch is the activation ledger + * (`sys_metadata_activation`, types `position` / `permission`), deployment-wide. + * The catalog ROW's `active` column is no longer read: the last case pins it. */ describe('[#8613] the `active` flag on the grant catalogues (ADR-0049)', () => { + const off = (metadata_type: 'position' | 'permission', name: string, active: unknown = false) => + ({ metadata_type, name, package_id: null, active }); const withActive = (v: unknown) => ({ sys_user: [{ id: 'u1' }], sys_member: [], sys_user_position: [{ user_id: 'u1', position: 'contributor', organization_id: null }], sys_user_permission_set: [], - sys_position: [{ id: 'r1', name: 'contributor', active: v }], + sys_position: [{ id: 'r1', name: 'contributor' }], sys_position_permission_set: [{ position_id: 'r1', permission_set_id: 'ps1' }], sys_permission_set: [{ id: 'ps1', name: 'contributor_ps', system_permissions: ['cap_x'] }], + sys_metadata_activation: v === undefined ? [] : [off('position', 'contributor', v)], }); // ── sys_position ────────────────────────────────────────────────────────── @@ -1385,7 +1393,7 @@ describe('[#8613] the `active` flag on the grant catalogues (ADR-0049)', () => { expect(ctx.systemPermissions).toContain('cap_x'); }); - it('an ABSENT `active` column grants — deployed rows are not mass-revoked', async () => { + it('an ABSENT ledger row grants — deployed positions are not mass-revoked', async () => { const ctx = await resolveAuthzContext({ ql: makeQl(withActive(undefined)), headers: H(), @@ -1410,15 +1418,15 @@ describe('[#8613] the `active` flag on the grant catalogues (ADR-0049)', () => { expect(on.permissions).toContain('contributor_ps'); }); - it('a position name with NO `sys_position` row is untouched (org roles, memberships)', async () => { + it('a position name with NO ledger row is untouched (org roles, memberships)', async () => { const ql = makeQl({ sys_user: [{ id: 'u1' }], sys_member: [{ user_id: 'u1', role: 'owner', organization_id: 'o1' }], sys_user_position: [], sys_user_permission_set: [], - // `org_owner` is projected from the membership and has no catalogue row — + // `org_owner` is projected from the membership and has no ledger row — // there is no flag to read, so nothing may be inferred from its absence. - sys_position: [{ id: 'r9', name: 'something_else', active: false }], + sys_metadata_activation: [off('position', 'something_else')], }); const ctx = await resolveAuthzContext({ ql, headers: H(), getSession: session('u1', { org: 'o1' }) }); expect(ctx.positions).toContain('org_owner'); @@ -1434,9 +1442,10 @@ describe('[#8613] the `active` flag on the grant catalogues (ADR-0049)', () => { ], sys_user_permission_set: [], sys_position: [ - { id: 'r1', name: 'contributor', active: false }, - { id: 'r2', name: 'reviewer', active: true }, + { id: 'r1', name: 'contributor' }, + { id: 'r2', name: 'reviewer' }, ], + sys_metadata_activation: [off('position', 'contributor'), off('position', 'reviewer', true)], sys_position_permission_set: [ { position_id: 'r1', permission_set_id: 'ps1' }, { position_id: 'r2', permission_set_id: 'ps2' }, @@ -1464,10 +1473,10 @@ describe('[#8613] the `active` flag on the grant catalogues (ADR-0049)', () => { sys_permission_set: [{ id: 'ps1', name: 'crm_full', - active: false, system_permissions: ['cap_x'], tab_permissions: { crm: 'visible' }, }], + sys_metadata_activation: [off('permission', 'crm_full')], }); const ctx = await resolveAuthzContext({ ql, headers: H(), getSession: session('u1') }); expect(ctx.permissions).not.toContain('crm_full'); @@ -1484,9 +1493,9 @@ describe('[#8613] the `active` flag on the grant catalogues (ADR-0049)', () => { sys_permission_set: [{ id: 'psA', name: 'admin_full_access', - active: false, system_permissions: ['manage_users'], }], + sys_metadata_activation: [off('permission', 'admin_full_access')], }); const ctx = await resolveAuthzContext({ ql, headers: H(), getSession: session('u1') }); // Dropped BEFORE the derivation, so the posture cannot be read off a set @@ -1508,9 +1517,10 @@ describe('[#8613] the `active` flag on the grant catalogues (ADR-0049)', () => { { user_id: 'u1', permission_set_id: 'ps2', permission_set: 'crm_read', organization_id: null }, ], sys_permission_set: [ - { id: 'ps1', name: 'crm_full', active: false }, - { id: 'ps2', name: 'crm_read', active: true }, + { id: 'ps1', name: 'crm_full' }, + { id: 'ps2', name: 'crm_read' }, ], + sys_metadata_activation: [off('permission', 'crm_full'), off('permission', 'crm_read', true)], }); const ctx = await resolveAuthzContext({ ql, headers: H(), getSession: session('u1') }); expect(ctx.permissions).not.toContain('crm_full'); @@ -1525,9 +1535,10 @@ describe('[#8613] the `active` flag on the grant catalogues (ADR-0049)', () => { sys_member: [], sys_user_position: [{ user_id: 'u1', position: 'contributor', organization_id: null }], sys_user_permission_set: [{ user_id: 'u1', permission_set_id: 'ps1', permission_set: 'crm_full', organization_id: null }], - sys_position: [{ id: 'r1', name: 'contributor', active: false }], + sys_position: [{ id: 'r1', name: 'contributor' }], sys_position_permission_set: [{ position_id: 'r1', permission_set_id: 'ps1' }], sys_permission_set: [{ id: 'ps1', name: 'crm_full' }], + sys_metadata_activation: [off('position', 'contributor')], }); const ctx = await resolveAuthzContext({ ql, headers: H(), getSession: session('u1') }); expect(ctx.permissions).toContain('crm_full'); @@ -1539,6 +1550,46 @@ describe('[#8613] the `active` flag on the grant catalogues (ADR-0049)', () => { expect(grants.permissions).not.toContain('contributor_ps'); expect(grants.positions).not.toContain('contributor'); }); + + it('[ADR-0131 D3] a composition whose registry has no ledger object issues no ledger read and resolves', async () => { + // The engine of such a composition answers "object not found" — a failed + // read, not a missing table — so asking it would be a 503 on every request. + const reads: string[] = []; + const tables = withActive(false); + const base = makeQl(tables); + const find = base.find.bind(base); + const ql = Object.assign(base, { + registry: { getObject: (name: string) => (name === 'sys_metadata_activation' ? undefined : { name }) }, + find: async (object: string, opts: any) => { + reads.push(object); + if (object === 'sys_metadata_activation') throw new Error("Object 'sys_metadata_activation' not found"); + return find(object, opts); + }, + }); + const grants = await resolveUserAuthzGrants(ql, 'u1'); + expect(reads).not.toContain('sys_metadata_activation'); + // No ledger in the composition: nothing is switched off. + expect(grants.permissions).toContain('contributor_ps'); + // CONTROL — the same engine with the ledger registered reads it, and honours it. + const registered = Object.assign(makeQl(tables), { registry: { getObject: (name: string) => ({ name }) } }); + expect((await resolveUserAuthzGrants(registered, 'u1')).permissions).not.toContain('contributor_ps'); + }); + + it('[ADR-0131 D3] a catalog ROW\'s `active: false` is not read — the ledger is the switch', async () => { + // The rows' flags are carried into the ledger by the upgrade ceremony + // (`convertDeactivatedCatalogRows`); the resolver never falls back to them. + const ctx = await resolveAuthzContext({ + ql: makeQl({ + ...withActive(undefined), + sys_position: [{ id: 'r1', name: 'contributor', active: false }], + sys_permission_set: [{ id: 'ps1', name: 'contributor_ps', active: false, system_permissions: ['cap_x'] }], + }), + headers: H(), + getSession: session('u1'), + }); + expect(ctx.positions).toContain('contributor'); + expect(ctx.permissions).toContain('contributor_ps'); + }); }); /** @@ -1940,14 +1991,15 @@ describe('[#20515] with no active organization, only global grants apply', () => }); /** - * §6a — position-bound sets. Under a walled posture the catalog holds one - * copy of each built-in position PER ORGANIZATION (`everyone`, `org_member`, - * …), and an organization binds its own sets to its own copies. With no - * tenant the `sys_position` read is installation-wide, so every - * organization's copy of a name the caller holds used to feed its bindings in. - * This double ignores the context's tenant exactly as that read does. + * [ADR-0131 D3/D4] The catalog has no organization level: a position's sets + * are the `permissionSets` its definition names, the same in every + * organization and with none. The organization-scoped junction rows below — + * alpha binding its own sets to its own copies of `org_member` and + * `everyone` — are not read, so a member removed from alpha cannot keep + * what alpha bound, in beta or with no tenant, and neither can alpha's own + * members: what an organization bound is not a grant any more (#15196 Q3 A). */ - describe('§6a: a position row scoped to an organization binds nothing with no tenant', () => { + describe('§6a: a position binds what its definition names — no organization binds its own', () => { const catalog = () => ({ sys_position: [ { id: 'pos_member_alpha', name: 'org_member', organization_id: ALPHA }, @@ -1966,35 +2018,35 @@ describe('[#20515] with no active organization, only global grants apply', () => { id: 'ps_global_everyone', name: 'global_everyone_default' }, ], }); - /** Removed from alpha, still an `org_member` of beta — the role name alpha bound its set to. */ - const exMember = () => makeQl({ - sys_user: [{ id: 'u_ex' }], - sys_member: [{ user_id: 'u_ex', organization_id: BETA, role: 'member' }], + const definitions = { + positions: [{ name: 'everyone', permissionSets: ['global_everyone_default'] }], + permissions: [ + { name: 'alpha_member_tools', systemPermissions: ['manage_metadata'] }, + { name: 'alpha_everyone_extra' }, + { name: 'global_everyone_default' }, + ], + }; + const principal = (userId: string, organizationId: string) => bindStaticSecurityCatalog(makeQl({ + sys_user: [{ id: userId }], + sys_member: [{ user_id: userId, organization_id: organizationId, role: 'member' }], sys_user_position: [], sys_user_permission_set: [], ...catalog(), + }), definitions); + + it('a member removed from alpha: the definition\'s set and nothing alpha bound — with no tenant and in beta', async () => { + for (const tenantId of [undefined, BETA]) { + const grants = await resolveUserAuthzGrants(principal('u_ex', BETA), 'u_ex', tenantId ? { tenantId } : {}); + expect(grants.positions, String(tenantId)).toContain('org_member'); + expect([...grants.permissions].sort(), String(tenantId)).toEqual(['global_everyone_default']); + expect(grants.systemPermissions, String(tenantId)).not.toContain('manage_metadata'); + } }); - it('no tenant: neither alpha\'s org_member binding nor alpha\'s everyone binding applies; the global everyone binding does', async () => { - const grants = await resolveUserAuthzGrants(exMember(), 'u_ex', {}); - expect(grants.positions).toContain('org_member'); - expect([...grants.permissions].sort()).toEqual(['global_everyone_default']); - expect(grants.systemPermissions).not.toContain('manage_metadata'); - }); - - it('in beta: alpha\'s bindings still do not apply; in alpha (a current member there): they do', async () => { - const inBeta = await resolveUserAuthzGrants(exMember(), 'u_ex', { tenantId: BETA }); - expect([...inBeta.permissions].sort()).toEqual(['global_everyone_default']); - const alphaMember = makeQl({ - sys_user: [{ id: 'u_in' }], - sys_member: [{ user_id: 'u_in', organization_id: ALPHA, role: 'member' }], - sys_user_position: [], - sys_user_permission_set: [], - ...catalog(), - }); - const inAlpha = await resolveUserAuthzGrants(alphaMember, 'u_in', { tenantId: ALPHA }); - expect([...inAlpha.permissions].sort()).toEqual(['alpha_everyone_extra', 'alpha_member_tools', 'global_everyone_default']); - expect(inAlpha.systemPermissions).toContain('manage_metadata'); + it('a current alpha member gets the same: alpha\'s organization-scoped junction rows bind nothing', async () => { + const inAlpha = await resolveUserAuthzGrants(principal('u_in', ALPHA), 'u_in', { tenantId: ALPHA }); + expect([...inAlpha.permissions].sort()).toEqual(['global_everyone_default']); + expect(inAlpha.systemPermissions).not.toContain('manage_metadata'); }); }); diff --git a/packages/core/src/security/resolve-authz-context.ts b/packages/core/src/security/resolve-authz-context.ts index 561b08dadda..cc8039bc6f2 100644 --- a/packages/core/src/security/resolve-authz-context.ts +++ b/packages/core/src/security/resolve-authz-context.ts @@ -69,7 +69,8 @@ import { openUserGrantsCache } from './resolve-user-grants-cache.js'; import { openRequestGrantsMemo, withRequestGrantsMemo } from './request-grants-memo.js'; import { matchesConfiguredPlatformAdmin, resolvePlatformAdminEmails } from './platform-admin.js'; import { derivePosture } from './posture-ladder.js'; -import { isRowActive } from './row-active.js'; +import { securityCatalogReaderOf, type SecurityCatalogReader } from './security-catalog.js'; +import { METADATA_ACTIVATION_TABLE } from '../utils/metadata-activation-store.js'; /** The transport-agnostic authorization envelope produced from a request. */ export interface ResolvedAuthzContext { @@ -141,10 +142,6 @@ export interface ResolveAuthzInput { tenancyPosture?: TenancyPosture; } -function safeJsonParse(s: string, fallback: T): T { - try { return JSON.parse(s) as T; } catch { return fallback; } -} - /** * [#15256 — maintainer ruling 2026-09-04, decision 2A] Say a posture-conditional * API-key refusal OUT LOUD, on the SERVER side, where the refusal is decided. @@ -705,9 +702,6 @@ function grantAppliesInTenant(organizationId: unknown, tenantId: string | undefi return !organizationId || organizationId === tenantId; } -/** The catalog a user grant (§6) names its permission set in. */ -const PERMISSION_SET_CATALOG = 'sys_permission_set'; - /** * [ADR-0131 D4] The permission set a stored `sys_user_permission_set` grant * names: its `permission_set`, or `undefined` for a grant that names none — @@ -741,70 +735,100 @@ function grantOrganizationOf(row: unknown): string | null { return String(value); } +/** The activation ledger's catalog types the resolver honours (ADR-0126 §3 regime C, as ADR-0131 D6 amends it). */ +const LEDGER_CATALOG_TYPES = ['position', 'permission'] as const; + +/** Whether the engine's registry says the activation ledger is absent from this composition. */ +function ledgerUnregistered(ql: any): boolean { + const getObject = ql?.registry?.getObject; + if (typeof getObject !== 'function') return false; + try { + return !getObject.call(ql.registry, METADATA_ACTIVATION_TABLE); + } catch { + return false; // a registry that cannot answer is not an answer: read, and let the read decide + } +} + /** - * [ADR-0131 D4] The `sys_permission_set` row each user grant holds, found BY - * NAME: the row of the grant's OWN organization, else the organization-less - * row. A set row of another organization is never the answer, whatever the - * grant's `permission_set_id` points at. - * - * The catalog is still materialized per organization, so one name can carry - * several rows. A row of an organization applies to a grant of that same - * organization, an organization-less row to every grant — the grant rule - * {@link grantAppliesInTenant} asks of the grant itself, asked of the set row. - * So an organization-less grant reads organization-less rows only, and an - * organization's grant reads its organization's row first. + * [ADR-0049, ADR-0131 D3, ADR-0126 §4] Which of `positions` and `sets` are + * switched OFF for this deployment: the `sys_metadata_activation` rows of type + * `position` / `permission` whose `active` is false. ADR-0131 moves the + * active/inactive switch off the catalog row "into the environment-authored + * definition's lifecycle, since there is no row", and regime C's "disable is + * one `active` bit of deployment-level state in the tenant-less activation + * ledger". So the catalog ROW's `active` column is not read here at all. * - * Two reads, issued together: the organization-less rows for every name, and — - * only when an organization's grant is among `grants` — that organization's - * rows, with the organization threaded into the context so the driver's tenant - * scope (`applyTenantScope`, the one governed spelling of the wall) decides - * what is visible. `grants` are the ones {@link grantAppliesInTenant} already - * kept, so the only organization among them is `tenantId`. + * The ledger is deployment-level (no tenant column): a disabled set is + * disabled in every organization. Absence of a row means ACTIVE, and a driver + * `0` reads as false — the store's own reading + * (`ObjectStoreMetadataActivationStore`, `utils/metadata-activation-store.ts`). + * One read, keyed by type and name, so it is bounded by the names asked. * - * Which row a name resolves to is decided before the ADR-0049 `active` flag is - * read: an organization's own row that is deactivated is that grant's answer, - * and it confers nothing — it does not fall through to the organization-less - * row. - * - * A read that fails throws (`tryFind`'s `AuthzStoreUnavailableError`). + * A read that fails throws (`tryFind`'s `AuthzStoreUnavailableError`); a ledger + * table that was never provisioned reads as no row, as any authz table does. + * A composition that does not register the ledger object at all (no + * `PlatformObjectsPlugin`, its one registrant) holds no row either, and its + * engine answers "object not found" rather than a missing table — so the + * registry is asked first, as `ObjectQLPlugin`'s own ledger hydration does, + * and the read is not issued. An engine with no registry to ask is read. */ -async function readGrantSetRowsByName( +async function readDisabledCatalogNames( ql: any, - grants: readonly unknown[], - tenantId: string | undefined, -): Promise<(grant: unknown) => any | undefined> { - const allNames = new Set(); - const ownNames = new Set(); - for (const grant of grants) { - const name = grantSetNameOf(grant); - if (!name) continue; - allNames.add(name); - const organizationId = grantOrganizationOf(grant); - if (organizationId !== null && organizationId === tenantId) ownNames.add(name); + positions: readonly string[], + sets: readonly string[], +): Promise<{ positions: Set; permissions: Set }> { + const disabled = { positions: new Set(), permissions: new Set() }; + const names = [...new Set([...positions, ...sets])]; + if (names.length === 0) return disabled; + if (ledgerUnregistered(ql)) return disabled; + const rows = await tryFind( + ql, + METADATA_ACTIVATION_TABLE, + { metadata_type: { $in: [...LEDGER_CATALOG_TYPES] }, name: { $in: names } }, + names.length * LEDGER_CATALOG_TYPES.length, + ); + for (const row of rows) { + if (typeof row?.name !== 'string' || !(row.active === false || row.active === 0)) continue; + if (row.metadata_type === 'position') disabled.positions.add(row.name); + else if (row.metadata_type === 'permission') disabled.permissions.add(row.name); } - if (allNames.size === 0) return () => undefined; + return disabled; +} - const [organizationLessRows, ownRows] = await Promise.all([ - tryFind(ql, PERMISSION_SET_CATALOG, { name: { $in: Array.from(allNames) }, organization_id: null }, 500), - tenantId && ownNames.size > 0 - ? tryFind(ql, PERMISSION_SET_CATALOG, { name: { $in: Array.from(ownNames) } }, 500, tenantId) - : Promise.resolve([] as any[]), - ]); - const organizationLess = new Map(); - for (const row of organizationLessRows) { - if (typeof row?.name !== 'string' || grantOrganizationOf(row) !== null) continue; - if (!organizationLess.has(row.name)) organizationLess.set(row.name, row); - } - const own = new Map(); - for (const row of ownRows) { - if (typeof row?.name !== 'string' || grantOrganizationOf(row) !== tenantId) continue; - if (!own.has(row.name)) own.set(row.name, row); - } - return (grant: unknown) => { - const name = grantSetNameOf(grant); - if (!name) return undefined; - const organizationId = grantOrganizationOf(grant); - return (organizationId !== null ? own.get(name) : undefined) ?? organizationLess.get(name); +/** + * [ADR-0131 D3/D4] The permission sets a position definition names + * (`PositionSchema.permissionSets`), in declared order — the position → set + * binding, read from the definition and from no junction row. + */ +function positionSetNamesOf(definition: Readonly> | undefined): string[] { + const value = definition?.permissionSets; + if (!Array.isArray(value)) return []; + return value.filter((n): n is string => typeof n === 'string' && n !== ''); +} + +/** What §6b derives from one held set: its catalog body, read from the definition. */ +interface CatalogSetBody { + name: string; + systemPermissions: unknown; + tabPermissions: unknown; +} + +/** + * [ADR-0131 D3/D4] The body of the permission set `name` names, read from its + * catalog definition — or `undefined` when the catalog holds no set of that + * name (or no catalog is bound), which confers nothing. + */ +async function catalogSetBody( + catalog: SecurityCatalogReader | undefined, + name: string, +): Promise { + if (!catalog) return undefined; + const entry = await catalog.resolve('permission', name); + if (!entry) return undefined; + return { + name: entry.name, + systemPermissions: entry.definition.systemPermissions, + tabPermissions: entry.definition.tabPermissions, }; } @@ -813,9 +837,10 @@ async function readGrantSetRowsByName( * * Given a KNOWN user id, aggregate the authorization grants that user holds: * org-admin positions (`sys_member`), platform-RBAC positions - * (`sys_user_position`), user- and position-bound permission sets - * (`sys_user_permission_set` / `sys_position_permission_set` → - * `sys_permission_set`), the derived `platform_admin` built-in + posture rung, + * (`sys_user_position`), user- and position-bound permission sets (the + * `sys_user_permission_set` grants, and the sets each held position's catalog + * definition names — ADR-0131 D3/D4), the derived `platform_admin` built-in + + * posture rung, * fellow-org peers for identity-table RLS, and the env-side `ai_seat`. * * Factored out of `resolveAuthzContext` so a surface that already knows WHO the @@ -1027,23 +1052,28 @@ export async function resolveUserAuthzGrants( // Rows outside their validity window are dropped BEFORE any derivation, so // an expired admin_full_access grant cannot yield platform_admin either. // - // [ADR-0131 D4] A grant names its set BY NAME (`permission_set`), and the - // set row is found by that name — the grant's own organization's row, else - // the organization-less one ({@link readGrantSetRowsByName}). The grant's - // `permission_set_id` is not read here: a grant that names nothing, or - // whose name resolves only to another organization's set, confers nothing - // through this resolver. Issued now, so it shares the `sys_position` read's - // leg below; awaited at §6b. + // [ADR-0131 D3/D4] A grant names its set BY NAME (`permission_set`), and + // the set is the catalog's definition of that name ({@link catalogSetBody}) + // — the environment registry the security plugin binds to this engine + // ({@link securityCatalogReaderOf}). No row decides whether a set exists or + // what it grants. The grant's `permission_set_id` is not read: a grant + // that names nothing, or a name the catalog does not hold, confers nothing. + // + // [ADR-0049] Whether a set is switched off is the activation ledger's + // answer ({@link readDisabledCatalogNames}), read in §6a with the + // positions' — never a catalog row's `active` column. // (read in Leg 1 above) const upsRows = upsRowsAll.filter((r) => isGrantActive(r, nowMs)); const applyingGrants = upsRows.filter((r) => grantAppliesInTenant(grantOrganizationOf(r), tenantId)); - const grantSetRowsLeg = readGrantSetRowsByName(ql, applyingGrants, tenantId); - // Its failure is observed where it is awaited (§6b); this only keeps a throw - // from §6a, which runs first, from leaving the rejection unhandled. - grantSetRowsLeg.catch(() => undefined); - // Position-bound sets (§6a) are still reached through the junction's id — - // the binding relation is ADR-0131 C3's, kept until it retires. - const psIds = new Set(); + const catalog = securityCatalogReaderOf(ql); + const grantSetNames: string[] = []; + for (const grant of applyingGrants) { + const name = grantSetNameOf(grant); + if (name && !grantSetNames.includes(name)) grantSetNames.push(name); + } + // Position-bound sets (§6a), by name, in the order the held positions name them. + const boundSetNames: string[] = []; + let disabled = { positions: new Set(), permissions: new Set() }; // [#11663 L5] …and under a WALLED posture it no longer is. This is the EXIT of // the migration window L4 opened: the walled half of the legacy unscoped anchor // is RETIRED, so on a walled rig platform standing is config-derived @@ -1076,8 +1106,10 @@ export async function resolveUserAuthzGrants( // "only when the user has nothing else" cliff. if (!grants.positions.includes('everyone')) grants.positions.push('everyone'); - // 6a. Position-bound permission sets (sys_position_permission_set): a position - // carries its permission sets. + // 6a. Position-bound permission sets: a position carries its permission sets + // in its definition (`PositionSchema.permissionSets`, ADR-0131 D3/D4), + // read from the catalog by the position's NAME. No junction row is read. + // A held name the catalog has no position for distributes nothing. // // [ADR-0049] A DEACTIVATED position grants nothing — the `deactivate_position` // dialog's promise ("users keep their assignment but the position stops @@ -1091,62 +1123,29 @@ export async function resolveUserAuthzGrants( // read: `resolvePermissionSetsForContext` requests `positions` as // permission-set NAMES (position names are commonly reused as set names), // so a name left standing would resolve the same grant one layer down. - // Only a name whose row is explicitly deactivated is dropped — a name - // with no `sys_position` row at all (`org_owner`, a membership-derived - // role) has no flag to read and is untouched. + // Only a name the activation ledger switched off is dropped + // ({@link readDisabledCatalogNames}); a name with no ledger row is in + // effect, a membership-derived role (`org_owner`) included. if (grants.positions.length > 0) { - // [#10103] Scoped to the CALLER's organization. `sys_position` spells - // its name index `unique: 'organization'` and its rows are materialized - // per organization, so several organizations hold a row named - // `everyone` (and one named after every declared position). Swept by - // name alone, this read returned EVERY organization's rows, and the - // junction read below then collected another organization's bindings — - // a cross-organization grant bleed, measured reachable from one tenant's - // resolution to another tenant's `everyone` binding. It also made the - // sweep O(organizations) on a table that is read on every request. - // - // Scoped by threading the organization into the context rather than by - // adding an `organization_id` predicate here: the driver's - // `applyTenantScope` is the one governed spelling of this wall, and a - // bare equality written at this call site would be a second, ungoverned - // implementation of it — the exact shape that produced the defect this - // card repairs. Per-request cost stays O(the caller's own organization's - // catalog). - // - // Limit raised with it: the cap has to admit this organization's rows - // alongside any organization-less ones the driver's compatibility arm - // still returns, or a caller silently loses positions. Those - // organization-less rows stay REACHABLE on purpose — they are not - // reaped, and grants point at them by row id, so dropping them here - // would revoke standing access silently. - // - // [#20515] The same grant rule as §4 and §6 is then asked of each - // position row ({@link grantAppliesInTenant}). With a tenant it is a - // no-op — the driver's scope already returned only this organization's - // rows and the organization-less ones. With NO tenant the read above is - // installation-wide by design, so without it every organization's - // `everyone` row, and every organization's copy of a name the caller - // holds (the `sys_member` role projection's `org_member`, say), fed its - // bindings into an organization-less resolution: measured on a real - // `SqlDriver` over the shipped per-organization catalog, a member - // removed from an organization kept the `manage_metadata` set that - // organization had bound to its `org_member` position. This is the - // grant rule, not a second tenant wall: it decides which organization's - // bindings APPLY, after the driver decided which rows are visible. - const positionRows = (await tryFind(ql, 'sys_position', { name: { $in: grants.positions } }, 200, tenantId)) - .filter((r) => grantAppliesInTenant(r.organization_id, tenantId)); - const deactivatedNames = new Set( - positionRows.filter((r) => !isRowActive(r)).map((r) => r.name).filter(Boolean), + // The definitions first (in-process), so the ONE ledger read can ask about + // every position and every set this resolution may grant. + const namedByPosition = new Map(); + if (catalog) { + for (const positionName of grants.positions) { + namedByPosition.set(positionName, positionSetNamesOf((await catalog.resolve('position', positionName))?.definition)); + } + } + disabled = await readDisabledCatalogNames( + ql, + grants.positions, + [...grantSetNames, ...[...namedByPosition.values()].flat()], ); - if (deactivatedNames.size > 0) { - grants.positions = grants.positions.filter((n) => !deactivatedNames.has(n)); + if (disabled.positions.size > 0) { + grants.positions = grants.positions.filter((n) => !disabled.positions.has(n)); } - const positionIds = positionRows.filter((r) => isRowActive(r)).map((r) => r.id).filter(Boolean); - if (positionIds.length > 0) { - const rpsRows = await tryFind(ql, 'sys_position_permission_set', { position_id: { $in: positionIds } }, 500); - for (const r of rpsRows) { - const id = r.permission_set_id ?? r.permissionSetId; - if (id) psIds.add(id); + for (const positionName of grants.positions) { + for (const setName of namedByPosition.get(positionName) ?? []) { + if (!boundSetNames.includes(setName)) boundSetNames.push(setName); } } } @@ -1154,69 +1153,70 @@ export async function resolveUserAuthzGrants( // 6b. Resolve permission-set details (names → grants.permissions; system_permissions; // tab_permissions merged by highest visibility). // - // The user grants' rows, found by name (§6), come first, in grant order; - // the position-bound rows (§6a) follow, read by the junction's id. - const setRowOfGrant = await grantSetRowsLeg; - const psRowsAll: any[] = []; - const takenRows = new Set(); - const take = (row: any): void => { - const key = row?.id ?? row; - if (takenRows.has(key)) return; - takenRows.add(key); - psRowsAll.push(row); + // The user grants' sets (§6) come first, in grant order; the + // position-bound sets (§6a) follow, in the order the positions name them. + // Each body is its catalog definition; whether it is switched off is the + // activation ledger's answer (§6a). + const psRowsAll: Array = []; + const takenNames = new Set(); + const take = (body: CatalogSetBody, active: boolean): void => { + if (takenNames.has(body.name)) return; + takenNames.add(body.name); + psRowsAll.push({ ...body, active }); }; for (const grant of applyingGrants) { - const row = setRowOfGrant(grant); - if (!row) continue; - take(row); + const name = grantSetNameOf(grant); + if (!name) continue; + const body = await catalogSetBody(catalog, name); + if (!body) continue; + const ownGrant = grantOrganizationOf(grant) !== null; + const active = !disabled.permissions.has(name); + take(body, active); // platform_admin (ADR-0068 D2) is DERIVED from an UNSCOPED admin_full_access - // USER grant — the single source of truth (no trusted stored boolean). An - // unscoped grant reads the organization-less row only, so the set it holds - // is the platform's own `admin_full_access`, never an organization's copy. + // USER grant — the single source of truth (no trusted stored boolean). The + // set is the catalog's one `admin_full_access` (ADR-0131 D4); an + // organization's grant of it confers the set, never platform standing. // // [ADR-0049] A DEACTIVATED set confers no standing either: a deactivated // `admin_full_access` must not keep conferring PLATFORM_ADMIN. // // [#11663 L5] `legacyGrantAnchorRetired` gates the ANCHOR, not the grant: the // set's own name and capabilities are pushed below exactly as for any other - // held set, on every posture. What a walled rig stops deriving from the row is - // PLATFORM_ADMIN STANDING — the rung, the built-in `platform_admin` position - // and everything downstream of them. The row itself is untouched here; its - // ownership is ADR-0131 C3's, on the v18 line. + // held set, on every posture. What a walled rig stops deriving from the + // grant is PLATFORM_ADMIN STANDING — the rung, the built-in + // `platform_admin` position and everything downstream of them. if ( - grantOrganizationOf(grant) === null - && row.name === ADMIN_FULL_ACCESS - && isRowActive(row) + !ownGrant + && body.name === ADMIN_FULL_ACCESS + && active && !legacyGrantAnchorRetired ) { hasPlatformAdminGrant = true; } } - if (psIds.size > 0) { - for (const row of await tryFind(ql, PERMISSION_SET_CATALOG, { id: { $in: Array.from(psIds) } }, 500)) take(row); + for (const name of boundSetNames) { + const body = await catalogSetBody(catalog, name); + if (!body) continue; + take(body, !disabled.permissions.has(name)); } if (psRowsAll.length > 0) { // [ADR-0049] A DEACTIVATED permission set grants nothing — the // `deactivate_permission_set` dialog's promise ("existing assignments stay // in place but stop granting access"). Dropped BEFORE any derivation, the // same discipline the validity window gets at §6. - const psRows = psRowsAll.filter((r) => isRowActive(r)); + const psRows = psRowsAll.filter((r) => r.active); const tabRank: Record = { hidden: 0, default_off: 1, default_on: 2, visible: 3 }; const mergedTabs: Record = {}; for (const ps of psRows) { if (ps.name && !grants.permissions.includes(ps.name)) grants.permissions.push(ps.name); - const sysPerms = typeof ps.system_permissions === 'string' - ? safeJsonParse(ps.system_permissions, []) - : (ps.system_permissions ?? ps.systemPermissions); + const sysPerms = ps.systemPermissions; if (Array.isArray(sysPerms)) { for (const p of sysPerms) { if (typeof p === 'string' && !grants.systemPermissions.includes(p)) grants.systemPermissions.push(p); } } - const tabs = typeof ps.tab_permissions === 'string' - ? safeJsonParse(ps.tab_permissions, {}) - : (ps.tab_permissions ?? ps.tabPermissions); - if (tabs && typeof tabs === 'object') { + const tabs = ps.tabPermissions; + if (tabs && typeof tabs === 'object' && !Array.isArray(tabs)) { for (const [app, val] of Object.entries(tabs as Record)) { if (typeof val !== 'string' || !(val in tabRank)) continue; const cur = mergedTabs[app]; diff --git a/packages/core/src/security/resolve-user-grants-cache.test.ts b/packages/core/src/security/resolve-user-grants-cache.test.ts index 42167bc9f0c..1f3138c933d 100644 --- a/packages/core/src/security/resolve-user-grants-cache.test.ts +++ b/packages/core/src/security/resolve-user-grants-cache.test.ts @@ -36,6 +36,7 @@ import { makeRecordingQl, FIXTURES, } from './__tests__/resolve-authz-context.batch-equivalence.testkit.js'; +import { bindCatalogFromTables } from './__tests__/security-catalog.testkit.js'; // ── An engine double that carries the #11968 seams ────────────────────────── @@ -87,7 +88,7 @@ function makeSeamQl(tables: Record) { }; await run(0); } - return { + return bindCatalogFromTables({ calls, tables, writeEpoch: epoch, @@ -121,7 +122,7 @@ function makeSeamQl(tables: Record) { tables[object] = (tables[object] ?? []).filter((r) => !matches(r, where)); }); }, - }; + }, tables); } type SeamQl = ReturnType; diff --git a/packages/core/src/security/resolve-user-grants-cache.ts b/packages/core/src/security/resolve-user-grants-cache.ts index 8a3875a7b43..aead9a48791 100644 --- a/packages/core/src/security/resolve-user-grants-cache.ts +++ b/packages/core/src/security/resolve-user-grants-cache.ts @@ -122,6 +122,9 @@ export const GRANTS_CACHE_WATCHED_OBJECTS: ReadonlySet = new Set([ 'sys_position_permission_set', 'sys_permission_set', 'sys_user', + // [ADR-0131 D3, ADR-0126 §4] The activation ledger: a position or permission + // set switched off there stops granting (`readDisabledCatalogNames`). + 'sys_metadata_activation', ]); interface GrantsCacheEntry { diff --git a/packages/core/src/security/security-catalog.ts b/packages/core/src/security/security-catalog.ts index 7da2520996d..5fc24ee51be 100644 --- a/packages/core/src/security/security-catalog.ts +++ b/packages/core/src/security/security-catalog.ts @@ -10,9 +10,10 @@ * code-declared packages and by environment metadata a metadata author saved * (`sys_metadata`, hydrated into the same registry by `loadMetaFromDb`) — and * D4 makes every reference to a catalog item a NAME that "resolution reads the - * registry" for. This module is that read and nothing else. It is the seam the - * later stages of the C2 execution card switch readers onto; on its own it - * changes no grant, because nothing calls it yet. + * registry" for. This module is that read and nothing else. The authorization + * resolver (`resolve-authz-context.ts`) reads positions, their permission sets + * and the set bodies through it, from the reader the security plugin binds to + * its engine ({@link bindSecurityCatalogReader}). * * ## Two in-process readers, read in a fixed order * @@ -73,8 +74,10 @@ * caller that drops the row flag because it now reads definitions would let * a deactivated set grant again: the one widening this card must never * open. Read the flag with `isRowActive` (`row-active.ts`) as before. - * - **The position → permission-set binding.** It stays on its junction rows - * until the position definition carries it. + * - **The position → permission-set binding as a separate fact.** It is part + * of the position definition (`PositionSchema.permissionSets`, ADR-0131 + * D3/D4); the resolver reads it from the entry this module answers, and no + * junction row. * - **Organization scope.** The catalog is environment-level (ADR-0131 D3); * this read takes no organization and never filters by one. * @@ -358,3 +361,42 @@ export function createSecurityCatalogReader(sources: SecurityCatalogSources): Se }, }; } + +/** + * The reader bound to each engine. Keyed by the engine AND by its registry, so + * a caller that hands the resolver the engine, or a view of it that exposes the + * same per-engine registry, finds the same reader. + */ +const boundReaders = new WeakMap(); + +/** + * Bind the catalog read to an ObjectQL engine — the security plugin does it at + * `start()`, where it holds both the engine and the metadata service. Every + * authorization resolution over that engine then reads its catalog through + * `reader` ({@link securityCatalogReaderOf}). + * + * @returns whether anything was bound — `false` for a value that is not an + * object, which the caller reports (absence is loud). + */ +export function bindSecurityCatalogReader(engine: unknown, reader: SecurityCatalogReader): boolean { + if (!engine || typeof engine !== 'object') return false; + boundReaders.set(engine, reader); + const registry = (engine as { registry?: unknown }).registry; + if (registry && typeof registry === 'object') boundReaders.set(registry, reader); + return true; +} + +/** + * The catalog read bound to `engine`, or `undefined` when none was bound — an + * engine no security plugin started on. The resolver reads that as an empty + * catalog, exactly as it reads an engine with no permission tables + * provisioned: no position distributes a set and no set has a body, so nothing + * is granted through the catalog. It is never read as permission to grant. + */ +export function securityCatalogReaderOf(engine: unknown): SecurityCatalogReader | undefined { + if (!engine || typeof engine !== 'object') return undefined; + const own = boundReaders.get(engine); + if (own) return own; + const registry = (engine as { registry?: unknown }).registry; + return registry && typeof registry === 'object' ? boundReaders.get(registry) : undefined; +} diff --git a/packages/mcp/src/stdio-data-bridge.stored-content-hash.test.ts b/packages/mcp/src/stdio-data-bridge.stored-content-hash.test.ts index e956f69faf5..52d19f1f80f 100644 --- a/packages/mcp/src/stdio-data-bridge.stored-content-hash.test.ts +++ b/packages/mcp/src/stdio-data-bridge.stored-content-hash.test.ts @@ -65,7 +65,9 @@ function fakeEngine(opts: { owner?: string; provider?: boolean } = {}) { find: vi.fn(async (object: string, query?: any, options?: any) => { const context = options?.context ?? query?.context; if (object === 'sys_api_key') return [{ id: 'k1', user_id: owner, revoked: false }]; - if (object.startsWith('sys_metadata') && context?.userId !== ADMIN) { + // The two metadata tables this suite serves — not the activation ledger + // the authorization resolver reads under a system context (ADR-0131 D3). + if ((object === 'sys_metadata' || object === 'sys_metadata_history') && context?.userId !== ADMIN) { throw Object.assign(new Error(`Permission denied: cannot read '${object}'`), { code: 'PERMISSION_DENIED', status: 403 }); } const rows = (TABLES[object] ?? []).filter((row) => matches(row, query?.where)); diff --git a/packages/metadata-protocol/src/protocol.meta-types-degenerate-derivation.test.ts b/packages/metadata-protocol/src/protocol.meta-types-degenerate-derivation.test.ts index 3954d5dc631..d99eb33ca93 100644 --- a/packages/metadata-protocol/src/protocol.meta-types-degenerate-derivation.test.ts +++ b/packages/metadata-protocol/src/protocol.meta-types-degenerate-derivation.test.ts @@ -312,11 +312,13 @@ describe('#17501 — /meta/types serves a real schema for `action`, and moves no * deadline and when it is settled), and 77 → 78 when it gained * `conditionalFormatting` (#22228, a field's cell formatting rules). * `dashboard` moved 21 → 22 the same way when it gained - * `requiredPermissions` (#22611, the audience gate). + * `requiredPermissions` (#22611, the audience gate). `position` moved + * 12 → 13 the same way when it gained `permissionSets` (ADR-0131 D3/D4, + * the sets a position distributes). */ const CARD_PROPERTY_COUNTS: Record = { agent: 26, app: 30, dashboard: 22, dataset: 16, field: 78, flow: 23, - hook: 22, object: 45, page: 25, position: 12, report: 21, skill: 17, tool: 14, + hook: 22, object: 45, page: 25, position: 13, report: 21, skill: 17, tool: 14, }; it.each(Object.entries(CARD_PROPERTY_COUNTS))( diff --git a/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts index 1032ce29f19..d13777e9d56 100644 --- a/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts @@ -2351,7 +2351,7 @@ export const enMetadataForms: NonNullable = { sections: { position: { label: "Position", - description: "A position is a flat, assignable bundle of permission sets (e.g. sales_rep, sales_manager). Capability lives on permission sets; visibility depth lives on the business-unit tree." + description: "A position is a flat, assignable bundle of permission sets (e.g. sales_rep, sales_manager): it names the sets it distributes. Capability lives on permission sets; visibility depth lives on the business-unit tree." } }, fields: { @@ -2365,6 +2365,10 @@ export const enMetadataForms: NonNullable = { description: { label: "Description" }, + permissionSets: { + label: "Permission Sets", + helpText: "Permission sets this position distributes, by name (snake_case)." + }, delegatable: { label: "Delegatable", helpText: "Holders may delegate this position themselves, time-boxed and with a reason (ADR-0091 D3). Default false — delegation is admin-only." diff --git a/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts index a1e8f93ad4b..f56c2a8115c 100644 --- a/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts @@ -2365,6 +2365,10 @@ export const esESMetadataForms: NonNullable = description: { label: "Descripción" }, + permissionSets: { + label: "Conjuntos de permisos", + helpText: "Conjuntos de permisos que distribuye este puesto, por nombre (snake_case)." + }, delegatable: { label: "Delegable", helpText: "Quienes la ocupan pueden delegar esta posición por sí mismos, con un plazo y un motivo (ADR-0091 D3). false por defecto: la delegación es solo para administradores." diff --git a/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts index f073df2d6e4..92ce0d76a3e 100644 --- a/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts @@ -2365,6 +2365,10 @@ export const jaJPMetadataForms: NonNullable = description: { label: "説明" }, + permissionSets: { + label: "権限セット", + helpText: "この役職が配布する権限セット(名前で指定、snake_case)。" + }, delegatable: { label: "セルフ委任を許可", helpText: "保有者が自分でこのポジションを委任できるようにします。期間を区切り、理由の記入が必要です(ADR-0091 D3)。既定は false で、委任は管理者のみです。" diff --git a/packages/platform-objects/src/apps/translations/object-lifecycle-panel-echo-decisions.test.ts b/packages/platform-objects/src/apps/translations/object-lifecycle-panel-echo-decisions.test.ts index 338d229f583..eb64841c734 100644 --- a/packages/platform-objects/src/apps/translations/object-lifecycle-panel-echo-decisions.test.ts +++ b/packages/platform-objects/src/apps/translations/object-lifecycle-panel-echo-decisions.test.ts @@ -1180,7 +1180,10 @@ describe('#19403 round 10 — the verdicts, on the live bundles', () => { // 668 since the object form offers `enable.approvalsVisibleToReaders`, // the per-object opt-in for the read-only record-reader approval tier — // one new row label, authored in all three locales. - expect(translated.length, `${locale} positive control`).toBe(668); + // 669 since the position form offers `permissionSets` (ADR-0131 D3/D4), + // the sets a position distributes — one new row label, authored in all + // three locales. + expect(translated.length, `${locale} positive control`).toBe(669); } // ⭐ DARK — the blindness, executable. On a synthetic two-locale catalog the // all-three predicate returns 0 while the per-locale one returns 1, so the diff --git a/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts index 9a787cb22c9..6246dcb608f 100644 --- a/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts @@ -2365,6 +2365,10 @@ export const zhCNMetadataForms: NonNullable = description: { label: "描述" }, + permissionSets: { + label: "权限集", + helpText: "该岗位分配的权限集,按名称填写(snake_case)。" + }, delegatable: { label: "可自助委派", helpText: "持有者可自行委派该岗位,需限定时段并填写理由(ADR-0091 D3)。默认 false——委派仅限管理员。" diff --git a/packages/plugins/plugin-approvals/src/__tests__/security-catalog.testkit.ts b/packages/plugins/plugin-approvals/src/__tests__/security-catalog.testkit.ts new file mode 100644 index 00000000000..a4c173ebf20 --- /dev/null +++ b/packages/plugins/plugin-approvals/src/__tests__/security-catalog.testkit.ts @@ -0,0 +1,72 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Test scaffolding: bind a security catalog to a fake engine (ADR-0131 D3/D4). + * `resolveUserAuthzGrants` reads positions, their sets and the set bodies from + * the catalog the security plugin binds to its engine; a suite whose engine is + * a double binds one here. A row alone grants nothing: what a fixture grants + * must be declared. {@link catalogFromTables} declares a row-shaped fixture's + * catalog, its junction rows through `convertPositionBindingRows` — the + * conversion the upgrade ceremony applies. + */ + +import { bindSecurityCatalogReader, convertPositionBindingRows, createSecurityCatalogReader } from '@objectstack/core'; + +type Definition = Record; + +/** The definitions a suite declares, per catalog type. */ +export interface TestSecurityCatalog { + positions?: readonly Definition[]; + permissions?: readonly Definition[]; +} + +/** Bind a catalog over these definitions (read afresh per lookup when given a function); returns `engine`. */ +export function bindTestSecurityCatalog(engine: T, catalog: TestSecurityCatalog | (() => TestSecurityCatalog)): T { + const items = (type: string): readonly Definition[] => { + const c = typeof catalog === 'function' ? catalog() : catalog; + return (type === 'position' ? c.positions : type === 'permission' ? c.permissions : undefined) ?? []; + }; + bindSecurityCatalogReader(engine, createSecurityCatalogReader({ + registry: { getItem: (type, name) => items(type).find((d) => d.name === name), listItems: items, isPackageDisabled: () => false }, + metadata: { get: () => undefined, list: () => [] }, + })); + return engine; +} + +const parsed = (value: unknown): unknown => { + if (typeof value !== 'string') return value; + try { + return JSON.parse(value); + } catch { + return undefined; + } +}; + +/** The definitions a fixture's catalog rows convert to (first set row of a name wins; a `conflicting` position names no set). */ +export function catalogFromTables(tables: Record): TestSecurityCatalog { + const setRows = (tables.sys_permission_set ?? []) as Definition[]; + const permissions: Definition[] = []; + for (const row of setRows) { + if (typeof row.name !== 'string' || permissions.some((d) => d.name === row.name)) continue; + const definition: Definition = { name: row.name }; + for (const [column, key] of [['system_permissions', 'systemPermissions'], ['tab_permissions', 'tabPermissions'], ['object_permissions', 'objects']] as const) { + const value = parsed(row[column] ?? row[key]); + if (value !== undefined && value !== null) definition[key] = value; + } + permissions.push(definition); + } + const { positions } = convertPositionBindingRows({ + positions: (tables.sys_position ?? []) as Definition[], + permissionSets: setRows, + bindings: (tables.sys_position_permission_set ?? []) as Definition[], + }); + return { + permissions, + positions: positions.flatMap((f) => (f.fate === 'conflicting' ? [] : [{ name: f.name, permissionSets: [...f.permissionSets] }])), + }; +} + +/** Bind the catalog `tables` convert to, re-derived per lookup so a row written later is in it. */ +export function bindCatalogFromTables(engine: T, tables: Record): T { + return bindTestSecurityCatalog(engine, () => catalogFromTables(tables)); +} diff --git a/packages/plugins/plugin-approvals/src/approval-positions-name-authority.test.ts b/packages/plugins/plugin-approvals/src/approval-positions-name-authority.test.ts index 10c21d1a5db..2ece3a5c3f4 100644 --- a/packages/plugins/plugin-approvals/src/approval-positions-name-authority.test.ts +++ b/packages/plugins/plugin-approvals/src/approval-positions-name-authority.test.ts @@ -56,6 +56,7 @@ import { hasPlatformAdminStanding, resolveUserAuthzGrants } from '@objectstack/c import { assertEngineDeleteDispatch, assertEngineUpdateDispatch } from '@objectstack/metadata-core'; import type { ApprovalRequestRow } from '@objectstack/spec/contracts'; import { ApprovalService, type ApprovalNodeAutoOutcome } from './approval-service.js'; +import { bindCatalogFromTables } from './__tests__/security-catalog.testkit.js'; interface FakeRow { [k: string]: any } @@ -141,12 +142,12 @@ function makeAuthzQl(tables: Record>>) { if (v === null) return (row[k] ?? null) === null; return row[k] === v; }); - return { + return bindCatalogFromTables({ async find(object: string, opts: any) { const rows = (tables[object] ?? []).filter((r) => matches(r, opts?.where)); return typeof opts?.limit === 'number' ? rows.slice(0, opts.limit) : rows; }, - }; + }, tables); } type Shape = 'name-only' | 'genuine' | 'plain'; diff --git a/packages/plugins/plugin-approvals/src/approval-tenant-positions-name-authority.test.ts b/packages/plugins/plugin-approvals/src/approval-tenant-positions-name-authority.test.ts index ae455f41d09..c6861a57c21 100644 --- a/packages/plugins/plugin-approvals/src/approval-tenant-positions-name-authority.test.ts +++ b/packages/plugins/plugin-approvals/src/approval-tenant-positions-name-authority.test.ts @@ -75,6 +75,7 @@ import { resolveUserAuthzGrants } from '@objectstack/core'; import { assertEngineDeleteDispatch, assertEngineUpdateDispatch } from '@objectstack/metadata-core'; import type { ApprovalRequestRow } from '@objectstack/spec/contracts'; import { ApprovalService, type ApprovalNodeAutoOutcome } from './approval-service.js'; +import { bindCatalogFromTables } from './__tests__/security-catalog.testkit.js'; interface FakeRow { [k: string]: any } @@ -156,12 +157,12 @@ function makeAuthzQl(tables: Record>>) { if (v === null) return (row[k] ?? null) === null; return row[k] === v; }); - return { + return bindCatalogFromTables({ async find(object: string, opts: any) { const rows = (tables[object] ?? []).filter((r) => matches(r, opts?.where)); return typeof opts?.limit === 'number' ? rows.slice(0, opts.limit) : rows; }, - }; + }, tables); } type Shape = 'row-org-owner' | 'row-org-admin' | 'genuine' | 'plain'; diff --git a/packages/plugins/plugin-auth/src/__tests__/security-catalog.testkit.ts b/packages/plugins/plugin-auth/src/__tests__/security-catalog.testkit.ts new file mode 100644 index 00000000000..a4c173ebf20 --- /dev/null +++ b/packages/plugins/plugin-auth/src/__tests__/security-catalog.testkit.ts @@ -0,0 +1,72 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Test scaffolding: bind a security catalog to a fake engine (ADR-0131 D3/D4). + * `resolveUserAuthzGrants` reads positions, their sets and the set bodies from + * the catalog the security plugin binds to its engine; a suite whose engine is + * a double binds one here. A row alone grants nothing: what a fixture grants + * must be declared. {@link catalogFromTables} declares a row-shaped fixture's + * catalog, its junction rows through `convertPositionBindingRows` — the + * conversion the upgrade ceremony applies. + */ + +import { bindSecurityCatalogReader, convertPositionBindingRows, createSecurityCatalogReader } from '@objectstack/core'; + +type Definition = Record; + +/** The definitions a suite declares, per catalog type. */ +export interface TestSecurityCatalog { + positions?: readonly Definition[]; + permissions?: readonly Definition[]; +} + +/** Bind a catalog over these definitions (read afresh per lookup when given a function); returns `engine`. */ +export function bindTestSecurityCatalog(engine: T, catalog: TestSecurityCatalog | (() => TestSecurityCatalog)): T { + const items = (type: string): readonly Definition[] => { + const c = typeof catalog === 'function' ? catalog() : catalog; + return (type === 'position' ? c.positions : type === 'permission' ? c.permissions : undefined) ?? []; + }; + bindSecurityCatalogReader(engine, createSecurityCatalogReader({ + registry: { getItem: (type, name) => items(type).find((d) => d.name === name), listItems: items, isPackageDisabled: () => false }, + metadata: { get: () => undefined, list: () => [] }, + })); + return engine; +} + +const parsed = (value: unknown): unknown => { + if (typeof value !== 'string') return value; + try { + return JSON.parse(value); + } catch { + return undefined; + } +}; + +/** The definitions a fixture's catalog rows convert to (first set row of a name wins; a `conflicting` position names no set). */ +export function catalogFromTables(tables: Record): TestSecurityCatalog { + const setRows = (tables.sys_permission_set ?? []) as Definition[]; + const permissions: Definition[] = []; + for (const row of setRows) { + if (typeof row.name !== 'string' || permissions.some((d) => d.name === row.name)) continue; + const definition: Definition = { name: row.name }; + for (const [column, key] of [['system_permissions', 'systemPermissions'], ['tab_permissions', 'tabPermissions'], ['object_permissions', 'objects']] as const) { + const value = parsed(row[column] ?? row[key]); + if (value !== undefined && value !== null) definition[key] = value; + } + permissions.push(definition); + } + const { positions } = convertPositionBindingRows({ + positions: (tables.sys_position ?? []) as Definition[], + permissionSets: setRows, + bindings: (tables.sys_position_permission_set ?? []) as Definition[], + }); + return { + permissions, + positions: positions.flatMap((f) => (f.fate === 'conflicting' ? [] : [{ name: f.name, permissionSets: [...f.permissionSets] }])), + }; +} + +/** Bind the catalog `tables` convert to, re-derived per lookup so a row written later is in it. */ +export function bindCatalogFromTables(engine: T, tables: Record): T { + return bindTestSecurityCatalog(engine, () => catalogFromTables(tables)); +} diff --git a/packages/plugins/plugin-auth/src/auth-manager.test.ts b/packages/plugins/plugin-auth/src/auth-manager.test.ts index a8e0b9e5cd2..1892a42d630 100644 --- a/packages/plugins/plugin-auth/src/auth-manager.test.ts +++ b/packages/plugins/plugin-auth/src/auth-manager.test.ts @@ -35,6 +35,8 @@ vi.mock('better-auth/plugins/haveibeenpwned', () => ({ })); import { betterAuth } from 'better-auth'; +import { bindTestSecurityCatalog } from './__tests__/security-catalog.testkit'; +import { ADMIN_FULL_ACCESS, ADMIN_FULL_ACCESS_CAPABILITIES } from '@objectstack/spec'; describe('AuthManager', () => { let consoleSpy: ReturnType; @@ -3498,7 +3500,7 @@ describe('AuthManager', () => { // dataEngine stub: `adminLinks` controls whether the user resolves as a // platform admin (a sys_user_permission_set row pointing at the // admin_full_access permission set with no organization scope). - const makeDataEngine = (opts: { platformAdmin: boolean }) => ({ + const makeDataEngine = (opts: { platformAdmin: boolean }) => bindTestSecurityCatalog({ // Two parameters because the seam has two: `IDataEngine.find(objectName, // query?, options?)`, and every production read reaching this double goes // through `resolve-authz-context.ts` `tryFind`, which always calls @@ -3516,7 +3518,7 @@ describe('AuthManager', () => { return []; }), findOne: vi.fn(), - }); + }, { permissions: [{ name: ADMIN_FULL_ACCESS, ...ADMIN_FULL_ACCESS_CAPABILITIES }] }); const getSessionCallback = async (dataEngine: any) => { let capturedConfig: any; @@ -4795,7 +4797,7 @@ describe('isPlatformAdminUserId – the /sso/register criterion is platform-admi * criterion must now ignore entirely. `find` honours the `where` the judge * passes, so any org-scoping is the product's, not the fixture's. */ - const makeEngine = (opts: { members?: any[]; platformAdmin?: boolean; throws?: boolean; orgScopedGrant?: boolean } = {}) => ({ + const makeEngine = (opts: { members?: any[]; platformAdmin?: boolean; throws?: boolean; orgScopedGrant?: boolean } = {}) => bindTestSecurityCatalog({ find: vi.fn(async (object: string, query?: any) => { if (opts.throws) throw new Error('db down'); if (object === 'sys_user_permission_set') { @@ -4813,7 +4815,7 @@ describe('isPlatformAdminUserId – the /sso/register criterion is platform-admi return []; }), findOne: vi.fn(), - }); + }, { permissions: [{ name: ADMIN_FULL_ACCESS, ...ADMIN_FULL_ACCESS_CAPABILITIES }] }); /** The criterion, invoked exactly as the `/sso/register` hook invokes it. */ const judge = async (engine: any, userId = 'u-1'): Promise => { diff --git a/packages/plugins/plugin-auth/src/impersonation-bearer-rotation.test.ts b/packages/plugins/plugin-auth/src/impersonation-bearer-rotation.test.ts index 970ba4a70e5..5ac366786ab 100644 --- a/packages/plugins/plugin-auth/src/impersonation-bearer-rotation.test.ts +++ b/packages/plugins/plugin-auth/src/impersonation-bearer-rotation.test.ts @@ -32,6 +32,7 @@ import { ADMIN_SESSION_RECOVERY_REQUEST_HEADER, ADMIN_SESSION_RECOVERY_RESPONSE_HEADER, } from './impersonation-bearer-rotation'; +import { bindTestSecurityCatalog, catalogFromTables } from './__tests__/security-catalog.testkit'; /** * In-memory IDataEngine — same fake as the #4785 session-of-record harness, @@ -77,7 +78,8 @@ export const createMemoryEngine = () => { return out; }; let seq = 0; - return { + // [ADR-0131 D3/D4] The security catalog the resolver reads, as the rows convert to it. + return bindTestSecurityCatalog({ tables, async insert(name: string, data: any) { const row = { id: data.id ?? `row_${++seq}`, ...data }; @@ -128,7 +130,7 @@ export const createMemoryEngine = () => { tables.set(name, keep); return table.length - keep.length; }, - }; + }, () => catalogFromTables(Object.fromEntries(tables))); }; const SECRET = 'test-secret-at-least-32-chars-long!!'; diff --git a/packages/plugins/plugin-auth/src/last-admin-guard.activation-ledger.test.ts b/packages/plugins/plugin-auth/src/last-admin-guard.activation-ledger.test.ts new file mode 100644 index 00000000000..0d01ac9ae09 --- /dev/null +++ b/packages/plugins/plugin-auth/src/last-admin-guard.activation-ledger.test.ts @@ -0,0 +1,63 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [ADR-0131 D3, ADR-0126 §4] The last-administrator guard's ledger half. + * + * The authorization resolver reads a permission set's deactivation from the + * activation ledger (`sys_metadata_activation`), not from the set row, so a + * ledger row switching `admin_full_access` off un-makes every grant-derived + * platform administrator at once. `refuseLedgerSwitchingAdminOff` refuses that + * write and passes every other one. + */ + +import { describe, it, expect } from 'vitest'; +import { refuseLedgerSwitchingAdminOff } from './last-admin-guard.js'; + +const engineWith = (rows: Array>) => { + const reads: unknown[] = []; + return { + reads, + async find(_object: string, query: any) { + reads.push(query?.where); + const where: Record = query?.where ?? {}; + const matched = rows.filter((r) => Object.entries(where).every(([k, v]) => { + if (k.startsWith('$') || (v !== null && typeof v === 'object')) throw new Error(`fake engine: unsupported where '${k}'`); + return r[k] === v; + })); + return typeof query?.limit === 'number' ? matched.slice(0, query.limit) : matched; + }, + }; +}; + +describe('[ADR-0131 D3] refuseLedgerSwitchingAdminOff', () => { + it('refuses an insert switching admin_full_access off, the 0 spelling included', async () => { + for (const active of [false, 0]) { + await expect( + refuseLedgerSwitchingAdminOff(engineWith([]), { metadata_type: 'permission', name: 'admin_full_access', active }, undefined), + ).rejects.toMatchObject({ code: 'PERMISSION_DENIED', status: 403, object: 'sys_metadata_activation' }); + } + }); + + it('refuses an update by id whose stored row is admin_full_access', async () => { + const engine = engineWith([{ id: 'act_1', metadata_type: 'permission', name: 'admin_full_access', active: true }]); + await expect(refuseLedgerSwitchingAdminOff(engine, { active: false }, 'act_1')).rejects.toMatchObject({ code: 'PERMISSION_DENIED' }); + expect(engine.reads).toEqual([{ id: 'act_1' }]); + }); + + it('refuses a switch-off whose target row cannot be read — fail closed', async () => { + await expect(refuseLedgerSwitchingAdminOff(engineWith([]), { active: false }, 'act_gone')).rejects.toMatchObject({ code: 'PERMISSION_DENIED' }); + }); + + it('CONTROL — another set, a position, a flow, or switching admin_full_access ON pass with no read', async () => { + const engine = engineWith([]); + for (const data of [ + { metadata_type: 'permission', name: 'crm_full', active: false }, + { metadata_type: 'position', name: 'admin_full_access', active: false }, + { metadata_type: 'flow', name: 'admin_full_access', active: false }, + { metadata_type: 'permission', name: 'admin_full_access', active: true }, + ]) { + await expect(refuseLedgerSwitchingAdminOff(engine, data, undefined)).resolves.toBeUndefined(); + } + expect(engine.reads).toEqual([]); + }); +}); diff --git a/packages/plugins/plugin-auth/src/last-admin-guard.ts b/packages/plugins/plugin-auth/src/last-admin-guard.ts index 9c6e2313e8a..d57030a02ed 100644 --- a/packages/plugins/plugin-auth/src/last-admin-guard.ts +++ b/packages/plugins/plugin-auth/src/last-admin-guard.ts @@ -555,6 +555,46 @@ function refuse(message: string, object: string = SystemObjectName.USER): Error return err; } +/** + * [ADR-0131 D3, ADR-0126 §4] Refuse an activation-ledger write that would + * switch the `admin_full_access` permission set off. A write that does not set + * `active` false passes. An update that does not carry the row's type and name + * reads them back by `id`. One whose row cannot be read is refused: the + * failure mode is an installation-wide lockout. + */ +export async function refuseLedgerSwitchingAdminOff( + engine: { find: (object: string, query: EngineQueryOptions, options?: BaseEngineOptions) => Promise }, + data: Record, + id: unknown, +): Promise { + if (!(data.active === false || data.active === 0)) return; + let metadataType = data.metadata_type; + let name = data.name; + if (metadataType === undefined || name === undefined) { + const rows = id === undefined || id === null + ? [] + : await engine.find(METADATA_ACTIVATION, { where: { id }, limit: 1 } as EngineQueryOptions, SYSTEM_READ); + const row = Array.isArray(rows) ? (rows[0] as Record | undefined) : undefined; + if (!row) { + throw refuse( + `Refusing this '${METADATA_ACTIVATION}' write: it switches a row off and the row it targets could ` + + `not be read, so it may be the '${ADMIN_FULL_ACCESS}' permission set (${BREAK_GLASS_CITATION}).`, + METADATA_ACTIVATION, + ); + } + metadataType ??= row.metadata_type; + name ??= row.name; + } + if (metadataType === 'permission' && name === ADMIN_FULL_ACCESS) { + throw refuse( + `Refusing this '${METADATA_ACTIVATION}' write: switching the '${ADMIN_FULL_ACCESS}' permission set off ` + + 'un-makes every grant-derived platform administrator at once, with no identity table touched ' + + `(${BREAK_GLASS_CITATION}).`, + METADATA_ACTIVATION, + ); + } +} + /** Marker so the fail-closed wrapper re-throws a deliberate refusal unchanged. */ function isRefusal(err: unknown): boolean { return (err as { code?: string } | null)?.code === 'PERMISSION_DENIED'; @@ -780,6 +820,22 @@ export const PERMISSION_SET_STANDING_KEYS = ['name', 'active', 'organization_id' */ export const USER_STANDING_KEYS = ['email', 'email_verified'] as const; +/** [ADR-0131 D3, ADR-0126 §4] The activation ledger the resolver honours deactivation from. */ +export const METADATA_ACTIVATION = 'sys_metadata_activation'; + +/** + * [ADR-0131 D3, ADR-0126 §4] Same, for the activation ledger. Since the + * catalog switch the resolver reads a permission set's deactivation from the + * ledger, not from the `sys_permission_set` row, so a ledger row switching + * `admin_full_access` off un-makes every grant-derived platform administrator. + * + * Judged by {@link refuseLedgerSwitchingAdminOff}: a write that would switch + * it off is refused outright, with no enumeration. The ledger is engine-owned + * (the data API reads it only) and no enable/disable door accepts the + * `permission` type today, so the refusal binds the system-context writes alone. + */ +export const ACTIVATION_LEDGER_STANDING_KEYS = ['metadata_type', 'name', 'active'] as const; + /** * [commit f8eb73601] The three lists above, keyed by the table each one judges — the shape * the correspondence gate consumes. @@ -800,7 +856,7 @@ export const USER_STANDING_KEYS = ['email', 'email_verified'] as const; export const STANDING_KEYS_BY_TABLE: Readonly> = { [SystemObjectName.MEMBER]: MEMBER_STANDING_KEYS, [USER_PERMISSION_SET]: GRANT_STANDING_KEYS, - [SystemObjectName.PERMISSION_SET]: PERMISSION_SET_STANDING_KEYS, + [METADATA_ACTIVATION]: ACTIVATION_LEDGER_STANDING_KEYS, [SystemObjectName.USER]: USER_STANDING_KEYS, }; @@ -854,23 +910,7 @@ export const STANDING_KEY_EXCLUSIONS: Readonly => { + const ctx = ctxOf(rawCtx); + if (ctx.object !== METADATA_ACTIVATION) return; + const data = (ctx.input?.data ?? {}) as Record; + if (!touchesAny(data, ACTIVATION_LEDGER_STANDING_KEYS)) return; + await refuseLedgerSwitchingAdminOff(engine, data, ctx.input?.id); + }; + const guardPermissionSetDelete = async (rawCtx: unknown): Promise => { const ctx = ctxOf(rawCtx); if (ctx.object !== SystemObjectName.PERMISSION_SET) return; @@ -1744,11 +1796,22 @@ export function registerLastAdminGuard( priority: 20, packageId, }); + engine.registerHook('beforeInsert', guardActivationLedgerWrite, { + object: METADATA_ACTIVATION, + priority: 20, + packageId, + }); + engine.registerHook('beforeUpdate', guardActivationLedgerWrite, { + object: METADATA_ACTIVATION, + priority: 20, + packageId, + }); logger?.info( '[LastAdminGuard] last-administrator guard registered on sys_user (ban + delete, and the ' + 'email/email_verified pair the deployment-config anchor derives from), sys_member and ' + 'sys_user_permission_set (standing revocation), and sys_permission_set ' + - '(the admin_full_access row every platform admin is derived from) — ADR-0135 D5.2', + '(the admin_full_access row every platform admin is derived from), and sys_metadata_activation ' + + '(switching admin_full_access off) — ADR-0135 D5.2', ); } diff --git a/packages/plugins/plugin-auth/src/last-admin-standing-keys.test.ts b/packages/plugins/plugin-auth/src/last-admin-standing-keys.test.ts index 34664d8bc21..68bdfacb179 100644 --- a/packages/plugins/plugin-auth/src/last-admin-standing-keys.test.ts +++ b/packages/plugins/plugin-auth/src/last-admin-standing-keys.test.ts @@ -51,7 +51,7 @@ import { ADMIN_STANDING_SURFACE, adminStandingTables } from '@objectstack/core'; import { GRANT_STANDING_KEYS, MEMBER_STANDING_KEYS, - PERMISSION_SET_STANDING_KEYS, + ACTIVATION_LEDGER_STANDING_KEYS, STANDING_KEYS_BY_TABLE, STANDING_KEY_EXCLUSIONS, } from './last-admin-guard.js'; @@ -127,6 +127,8 @@ describe('[#8734] standing-key lists correspond to what resolveAuthzContext read // drift into a second copy that is checked while the guard uses another. expect(STANDING_KEYS_BY_TABLE.sys_member).toBe(MEMBER_STANDING_KEYS); expect(STANDING_KEYS_BY_TABLE.sys_user_permission_set).toBe(GRANT_STANDING_KEYS); - expect(STANDING_KEYS_BY_TABLE.sys_permission_set).toBe(PERMISSION_SET_STANDING_KEYS); + // [ADR-0131 D3] The resolver reads deactivation from the activation ledger, + // no longer from the `sys_permission_set` row (whose hooks stay registered). + expect(STANDING_KEYS_BY_TABLE.sys_metadata_activation).toBe(ACTIVATION_LEDGER_STANDING_KEYS); }); }); diff --git a/packages/plugins/plugin-auth/src/organization-add-member.test.ts b/packages/plugins/plugin-auth/src/organization-add-member.test.ts index 35ca9fd274b..8888a0238d5 100644 --- a/packages/plugins/plugin-auth/src/organization-add-member.test.ts +++ b/packages/plugins/plugin-auth/src/organization-add-member.test.ts @@ -37,6 +37,7 @@ import { AuthPlugin } from './auth-plugin'; import { runOrganizationAddMember } from './organization-add-member.js'; import type { PluginContext } from '@objectstack/core'; import { inviteForAudienceGate } from './audience-gate-test-support'; +import { bindTestSecurityCatalog, catalogFromTables } from './__tests__/security-catalog.testkit'; const SECRET = 'test-secret-at-least-32-chars-long!!'; const BASE = 'http://localhost:3000'; @@ -98,7 +99,8 @@ const createMemoryEngine = () => { return out; }; let seq = 0; - return { + // [ADR-0131 D3/D4] The security catalog the resolver reads, as the rows convert to it. + return bindTestSecurityCatalog({ tables, async insert(name: string, data: any) { const row = { id: data.id ?? `row_${++seq}`, ...data }; @@ -135,7 +137,7 @@ const createMemoryEngine = () => { tables.set(name, keep); return table.length - keep.length; }, - }; + }, () => catalogFromTables(Object.fromEntries(tables))); }; type MemoryEngine = ReturnType; diff --git a/packages/plugins/plugin-auth/src/platform-admin-standing.consolidation.test.ts b/packages/plugins/plugin-auth/src/platform-admin-standing.consolidation.test.ts index 274db7beb90..cbb90d7b27d 100644 --- a/packages/plugins/plugin-auth/src/platform-admin-standing.consolidation.test.ts +++ b/packages/plugins/plugin-auth/src/platform-admin-standing.consolidation.test.ts @@ -183,7 +183,10 @@ const impersonateVerdict = async (manager: AuthManager, bearer: string, userId: interface StandingShape { /** Rows written into `sys_permission_set` BEFORE `admin_full_access`. */ decoySets?: number; - /** ADR-0049: the `admin_full_access` catalogue row is switched off. */ + /** + * ADR-0049: `admin_full_access` is switched off — in the activation ledger, + * the switch the resolver honours (ADR-0131 D3, ADR-0126 §4). + */ deactivatedSet?: boolean; /** ADR-0091: the grant carries a `valid_until` already in the past. */ expiredGrant?: boolean; @@ -195,11 +198,15 @@ const seedPlatformAdmin = async (engine: any, userId: string, shape: StandingSha for (let i = 0; i < (shape.decoySets ?? 0); i += 1) { await engine.insert('sys_permission_set', { id: `ps_decoy_${i}`, name: `decoy_set_${i}` }); } - await engine.insert('sys_permission_set', { - id: PS_ADMIN, - name: ADMIN_FULL_ACCESS, - ...(shape.deactivatedSet ? { active: false } : {}), - }); + await engine.insert('sys_permission_set', { id: PS_ADMIN, name: ADMIN_FULL_ACCESS }); + if (shape.deactivatedSet) { + await engine.insert('sys_metadata_activation', { + metadata_type: 'permission', + name: ADMIN_FULL_ACCESS, + package_id: null, + active: false, + }); + } await engine.insert('sys_user_permission_set', { user_id: userId, permission_set_id: PS_ADMIN, diff --git a/packages/plugins/plugin-auth/src/session-grants-resolved-once.test.ts b/packages/plugins/plugin-auth/src/session-grants-resolved-once.test.ts index 78ee644fec9..94115d514c2 100644 --- a/packages/plugins/plugin-auth/src/session-grants-resolved-once.test.ts +++ b/packages/plugins/plugin-auth/src/session-grants-resolved-once.test.ts @@ -266,14 +266,15 @@ describe('the real customSession hook and resolveAuthzContext resolve the caller expect(grantOnlyReads(twice.reads).length).toBe(2 * grantOnlyReads(once.reads).length); }); - it('the request saves exactly one resolution\'s reads — eight for a caller in an organization', async () => { + it('the request saves exactly one resolution\'s reads — six for a caller in an organization', async () => { const a = await arrangeWarm(); const once = await resolveRequest(a, a.bearers.member); const twice = await resolveRequestWithoutMemo(a, a.bearers.member); // sys_user, sys_member (own), sys_user_position, sys_member (peers), - // sys_user_permission_set, sys_position, sys_position_permission_set, - // sys_permission_set. - expect(twice.reads.length - once.reads.length).toBe(8); + // sys_user_permission_set, sys_metadata_activation. [ADR-0131 D3/D4] The + // positions, their sets and the set bodies are read from the catalog + // in-process; the one ledger read answers which are switched off. + expect(twice.reads.length - once.reads.length).toBe(6); }); it('organization owner and platform operator: same envelope, one resolution', async () => { diff --git a/packages/plugins/plugin-auth/src/sso-register-platform-admin-gate.test.ts b/packages/plugins/plugin-auth/src/sso-register-platform-admin-gate.test.ts index 91ed9c85df6..9c39d82ef65 100644 --- a/packages/plugins/plugin-auth/src/sso-register-platform-admin-gate.test.ts +++ b/packages/plugins/plugin-auth/src/sso-register-platform-admin-gate.test.ts @@ -86,6 +86,8 @@ import { AuthPlugin } from './auth-plugin.js'; import { createTenancyService } from './tenancy-service.js'; import type { PluginContext } from '@objectstack/core'; import { inviteForAudienceGate } from './audience-gate-test-support'; +import { bindTestSecurityCatalog } from './__tests__/security-catalog.testkit'; +import { ADMIN_FULL_ACCESS_CAPABILITIES } from '@objectstack/spec'; const BASE = 'http://localhost:3000'; const AUTH = `${BASE}/api/v1/auth`; @@ -185,7 +187,9 @@ async function bootEngine(): Promise { engine.registry.registerObject(sysPosition as never, '@objectstack/plugin-auth'); engine.registry.registerObject(sysUserPosition as never, '@objectstack/plugin-auth'); await engine.syncSchemas(); - return engine; + // [ADR-0131 D3/D4] The platform's `admin_full_access` definition, which an + // org-less grant of it resolves through (no security plugin binds one here). + return bindTestSecurityCatalog(engine, { permissions: [{ name: ADMIN_FULL_ACCESS, ...ADMIN_FULL_ACCESS_CAPABILITIES }] }); } function makeManager(engine: ObjectQL): AuthManager { diff --git a/packages/plugins/plugin-hono-server/src/__tests__/security-catalog.testkit.ts b/packages/plugins/plugin-hono-server/src/__tests__/security-catalog.testkit.ts new file mode 100644 index 00000000000..a4c173ebf20 --- /dev/null +++ b/packages/plugins/plugin-hono-server/src/__tests__/security-catalog.testkit.ts @@ -0,0 +1,72 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Test scaffolding: bind a security catalog to a fake engine (ADR-0131 D3/D4). + * `resolveUserAuthzGrants` reads positions, their sets and the set bodies from + * the catalog the security plugin binds to its engine; a suite whose engine is + * a double binds one here. A row alone grants nothing: what a fixture grants + * must be declared. {@link catalogFromTables} declares a row-shaped fixture's + * catalog, its junction rows through `convertPositionBindingRows` — the + * conversion the upgrade ceremony applies. + */ + +import { bindSecurityCatalogReader, convertPositionBindingRows, createSecurityCatalogReader } from '@objectstack/core'; + +type Definition = Record; + +/** The definitions a suite declares, per catalog type. */ +export interface TestSecurityCatalog { + positions?: readonly Definition[]; + permissions?: readonly Definition[]; +} + +/** Bind a catalog over these definitions (read afresh per lookup when given a function); returns `engine`. */ +export function bindTestSecurityCatalog(engine: T, catalog: TestSecurityCatalog | (() => TestSecurityCatalog)): T { + const items = (type: string): readonly Definition[] => { + const c = typeof catalog === 'function' ? catalog() : catalog; + return (type === 'position' ? c.positions : type === 'permission' ? c.permissions : undefined) ?? []; + }; + bindSecurityCatalogReader(engine, createSecurityCatalogReader({ + registry: { getItem: (type, name) => items(type).find((d) => d.name === name), listItems: items, isPackageDisabled: () => false }, + metadata: { get: () => undefined, list: () => [] }, + })); + return engine; +} + +const parsed = (value: unknown): unknown => { + if (typeof value !== 'string') return value; + try { + return JSON.parse(value); + } catch { + return undefined; + } +}; + +/** The definitions a fixture's catalog rows convert to (first set row of a name wins; a `conflicting` position names no set). */ +export function catalogFromTables(tables: Record): TestSecurityCatalog { + const setRows = (tables.sys_permission_set ?? []) as Definition[]; + const permissions: Definition[] = []; + for (const row of setRows) { + if (typeof row.name !== 'string' || permissions.some((d) => d.name === row.name)) continue; + const definition: Definition = { name: row.name }; + for (const [column, key] of [['system_permissions', 'systemPermissions'], ['tab_permissions', 'tabPermissions'], ['object_permissions', 'objects']] as const) { + const value = parsed(row[column] ?? row[key]); + if (value !== undefined && value !== null) definition[key] = value; + } + permissions.push(definition); + } + const { positions } = convertPositionBindingRows({ + positions: (tables.sys_position ?? []) as Definition[], + permissionSets: setRows, + bindings: (tables.sys_position_permission_set ?? []) as Definition[], + }); + return { + permissions, + positions: positions.flatMap((f) => (f.fate === 'conflicting' ? [] : [{ name: f.name, permissionSets: [...f.permissionSets] }])), + }; +} + +/** Bind the catalog `tables` convert to, re-derived per lookup so a row written later is in it. */ +export function bindCatalogFromTables(engine: T, tables: Record): T { + return bindTestSecurityCatalog(engine, () => catalogFromTables(tables)); +} diff --git a/packages/plugins/plugin-hono-server/src/current-user-endpoints-additive-baseline.test.ts b/packages/plugins/plugin-hono-server/src/current-user-endpoints-additive-baseline.test.ts index de65c62e746..a06c7a1fcfa 100644 --- a/packages/plugins/plugin-hono-server/src/current-user-endpoints-additive-baseline.test.ts +++ b/packages/plugins/plugin-hono-server/src/current-user-endpoints-additive-baseline.test.ts @@ -61,6 +61,7 @@ import { describe, it, expect } from 'vitest'; import { Hono } from 'hono'; import { registerCurrentUserEndpoints } from './current-user-endpoints'; +import { bindCatalogFromTables } from './__tests__/security-catalog.testkit'; const ME_PERMISSIONS = '/api/v1/auth/me/permissions'; const ME_APPS = '/api/v1/me/apps'; @@ -87,14 +88,14 @@ function matches(row: Row, where: Row | undefined): boolean { } function makeQl(tables: Record) { - return { + return bindCatalogFromTables({ find: async (object: string, opts: any) => { const rows = (tables[object] ?? []).filter((r) => matches(r, opts?.where)); return typeof opts?.limit === 'number' ? rows.slice(0, opts.limit) : rows; }, registry: { getAllApps: () => tables.__apps ?? [], getAllObjects: () => [] }, getSchema: () => undefined, - }; + }, tables); } /** A `sys_permission_set` row, JSON columns stored as text like the real table. */ diff --git a/packages/plugins/plugin-hono-server/src/current-user-endpoints-position-grants.test.ts b/packages/plugins/plugin-hono-server/src/current-user-endpoints-position-grants.test.ts index 45197491409..8e37c51a1ca 100644 --- a/packages/plugins/plugin-hono-server/src/current-user-endpoints-position-grants.test.ts +++ b/packages/plugins/plugin-hono-server/src/current-user-endpoints-position-grants.test.ts @@ -38,6 +38,7 @@ import { describe, it, expect } from 'vitest'; import { Hono } from 'hono'; import { registerCurrentUserEndpoints } from './current-user-endpoints'; +import { bindCatalogFromTables } from './__tests__/security-catalog.testkit'; const ME_PERMISSIONS = '/api/v1/auth/me/permissions'; const ME_APPS = '/api/v1/me/apps'; @@ -71,14 +72,14 @@ function matches(row: Row, where: Row | undefined): boolean { * engine. */ function makeQl(tables: Record) { - return { + return bindCatalogFromTables({ find: async (object: string, opts: any, _ctx?: any) => { const rows = (tables[object] ?? []).filter((r) => matches(r, opts?.where)); return typeof opts?.limit === 'number' ? rows.slice(0, opts.limit) : rows; }, registry: { getAllApps: () => tables.__apps ?? [], getAllObjects: () => [] }, getSchema: () => undefined, - }; + }, tables); } /** A permission set as `sys_permission_set` stores it (JSON columns as text). */ diff --git a/packages/plugins/plugin-hono-server/src/server-timing-e2e.test.ts b/packages/plugins/plugin-hono-server/src/server-timing-e2e.test.ts index 87381544503..96c69a3fea0 100644 --- a/packages/plugins/plugin-hono-server/src/server-timing-e2e.test.ts +++ b/packages/plugins/plugin-hono-server/src/server-timing-e2e.test.ts @@ -17,13 +17,14 @@ import { describe, it, expect } from 'vitest'; import { HonoServerPlugin } from './hono-plugin'; import { registerCurrentUserEndpoints } from './current-user-endpoints'; import type { PluginContext } from '@objectstack/core'; +import { bindTestSecurityCatalog } from './__tests__/security-catalog.testkit'; /** * Fake data engine: an UNSCOPED `admin_full_access` grant for `admin1` (the * seeded platform admin) vs. a `member_default` grant for `member1`, plus the * `widget` rows the data route returns. Every other read resolves empty. */ -const makeQl = () => ({ +const makeQl = () => bindTestSecurityCatalog({ find: async (object: string, opts: any) => { const where = opts?.where ?? {}; if (object === 'sys_user_permission_set') { @@ -44,7 +45,7 @@ const makeQl = () => ({ // sys_member, sys_user — nothing to contribute. return []; }, -}); +}, { permissions: [{ name: 'admin_full_access' }, { name: 'member_default' }] }); /** Fake auth service: session keyed off the request's `cookie` header. */ const makeAuth = () => ({ diff --git a/packages/plugins/plugin-security/src/__tests__/security-catalog.testkit.ts b/packages/plugins/plugin-security/src/__tests__/security-catalog.testkit.ts new file mode 100644 index 00000000000..74913a4b457 --- /dev/null +++ b/packages/plugins/plugin-security/src/__tests__/security-catalog.testkit.ts @@ -0,0 +1,89 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Test scaffolding: bind a security catalog to a fake engine (ADR-0131 D3/D4). + * `resolveUserAuthzGrants` reads positions, their sets and the set bodies from + * the catalog the security plugin binds to its engine; a suite whose engine is + * a double binds one here. A row alone grants nothing: what a fixture grants + * must be declared. {@link catalogFromTables} declares a row-shaped fixture's + * catalog, its junction rows through `convertPositionBindingRows` — the + * conversion the upgrade ceremony applies. + */ + +import { + bindSecurityCatalogReader, + convertDeactivatedCatalogRows, + convertPositionBindingRows, + createSecurityCatalogReader, +} from '@objectstack/core'; + +type Definition = Record; + +/** The definitions a suite declares, per catalog type. */ +export interface TestSecurityCatalog { + positions?: readonly Definition[]; + permissions?: readonly Definition[]; +} + +/** Bind a catalog over these definitions (read afresh per lookup when given a function); returns `engine`. */ +export function bindTestSecurityCatalog(engine: T, catalog: TestSecurityCatalog | (() => TestSecurityCatalog)): T { + const items = (type: string): readonly Definition[] => { + const c = typeof catalog === 'function' ? catalog() : catalog; + return (type === 'position' ? c.positions : type === 'permission' ? c.permissions : undefined) ?? []; + }; + bindSecurityCatalogReader(engine, createSecurityCatalogReader({ + registry: { getItem: (type, name) => items(type).find((d) => d.name === name), listItems: items, isPackageDisabled: () => false }, + metadata: { get: () => undefined, list: () => [] }, + })); + return engine; +} + +const parsed = (value: unknown): unknown => { + if (typeof value !== 'string') return value; + try { + return JSON.parse(value); + } catch { + return undefined; + } +}; + +/** The definitions a fixture's catalog rows convert to (first set row of a name wins; a `conflicting` position names no set). */ +export function catalogFromTables(tables: Record): TestSecurityCatalog { + const setRows = (tables.sys_permission_set ?? []) as Definition[]; + const permissions: Definition[] = []; + for (const row of setRows) { + if (typeof row.name !== 'string' || permissions.some((d) => d.name === row.name)) continue; + const definition: Definition = { name: row.name }; + for (const [column, key] of [['system_permissions', 'systemPermissions'], ['tab_permissions', 'tabPermissions'], ['object_permissions', 'objects']] as const) { + const value = parsed(row[column] ?? row[key]); + if (value !== undefined && value !== null) definition[key] = value; + } + permissions.push(definition); + } + const { positions } = convertPositionBindingRows({ + positions: (tables.sys_position ?? []) as Definition[], + permissionSets: setRows, + bindings: (tables.sys_position_permission_set ?? []) as Definition[], + }); + return { + permissions, + positions: positions.flatMap((f) => (f.fate === 'conflicting' ? [] : [{ name: f.name, permissionSets: [...f.permissionSets] }])), + }; +} + +/** Bind the catalog `tables` convert to, re-derived per lookup so a row written later is in it. */ +export function bindCatalogFromTables(engine: T, tables: Record): T { + return bindTestSecurityCatalog(engine, () => catalogFromTables(tables)); +} + +/** + * The `sys_metadata_activation` rows a fixture's `active: false` catalog rows + * convert to (`convertDeactivatedCatalogRows`, the upgrade ceremony's): the + * resolver reads deactivation from the ledger, never from the row. + */ +export function ledgerFromTables(tables: Record): Definition[] { + return convertDeactivatedCatalogRows({ + positions: (tables.sys_position ?? []) as Definition[], + permissionSets: (tables.sys_permission_set ?? []) as Definition[], + }).ledger.map((row) => ({ ...row, package_id: null })); +} 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 5deaa488906..144125ec4ee 100644 --- a/packages/plugins/plugin-security/src/builtin-positions.boot.test.ts +++ b/packages/plugins/plugin-security/src/builtin-positions.boot.test.ts @@ -252,7 +252,10 @@ async function boot(scenario: Scenario): Promise { }; const metadata = { - get: async (_type: string, name: string) => engine.getSchema(name) ?? null, + get: async (type: string, name: string) => + type === 'position' || type === 'permission' + ? ((await metadata.list(type)) as Array<{ name?: string }>).find((d) => d.name === name) ?? null + : engine.getSchema(name) ?? null, list: async (type: string) => { if (type === 'position') return DECLARED_POSITIONS.map((p) => ({ ...p })); if (type === 'permission') return [...defaultPermissionSets, QA_ADMIN, APP_DEFAULT]; @@ -472,7 +475,7 @@ for (const name of Object.keys(SCENARIOS) as ScenarioName[]) { if (scenario.organizationAtBoot) { it('grants every principal what it was granted before the declarations', async () => { const { engine } = booted.get(name)!; - expect(await grantsByPrincipal(engine, scenario.posture)).toEqual(GRANT_GOLDEN[scenario.posture]); + 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 () => { @@ -700,12 +703,20 @@ const DOOR_AUTHORED_ROWS: Record<'single' | 'walled', { census: string[]; ledger }; /** - * Recorded before the declarations (module doc). `field_default` on every - * human principal is the `everyone` binding; `single` also binds the - * platform's `member_default` (its organization-less catalog holds that set), - * while the walled organization's catalog holds only its own copy of the app's - * set. The organization administrator's set is the walled `organization_admin` + * Recorded before the declarations (module doc). `field_default` and + * `member_default` on every human principal are the `everyone` anchor's sets. + * The organization administrator's set is the walled `organization_admin` * against the wall-less `organization_admin_no_bypass` (ADR-0105 D4). + * + * ⚠️ One recorded move ([ADR-0131] D3/D4, C3 stage 1): `everyone`'s sets are + * the baseline its definition names — the same in every posture. Before, they + * were junction rows the boot wrote per organization, and the walled + * organization's catalog held only its own copy of the app's set, so walled + * `everyone` never bound the organization-less `member_default`. It now + * names it under a wall too. `member_default` carries no system or tab + * permission, and enforcement applied it to every human as the additive + * baseline already (ADR-0090 D5), so the walled envelope gains its name and + * nothing it grants. */ const GRANT_GOLDEN: Record<'single' | 'isolated', unknown> = { single: { @@ -771,7 +782,7 @@ const GRANT_GOLDEN: Record<'single' | 'isolated', unknown> = { accessible_org_ids: ['org_eq'], email: 'member@eq.example', org_user_ids: ['usr_member', 'usr_orgadmin'], - permissions: ['field_default', 'viewer_readonly'], + permissions: ['field_default', 'member_default', 'viewer_readonly'], positions: ['everyone', 'org_member'], posture: 'MEMBER', systemPermissions: [], @@ -780,7 +791,7 @@ const GRANT_GOLDEN: Record<'single' | 'isolated', unknown> = { accessible_org_ids: ['org_eq'], email: 'orgadmin@eq.example', org_user_ids: ['usr_member', 'usr_orgadmin'], - permissions: ['field_default', 'organization_admin'], + permissions: ['field_default', 'member_default', 'organization_admin'], positions: ['everyone', 'org_owner'], posture: 'TENANT_ADMIN', systemPermissions: ['manage_org_users', 'setup.access', 'setup.write'], @@ -789,7 +800,7 @@ const GRANT_GOLDEN: Record<'single' | 'isolated', unknown> = { accessible_org_ids: [], email: 'admin@eq.example', org_user_ids: ['usr_admin', 'usr_member', 'usr_orgadmin'], - permissions: ['admin_full_access', 'field_default'], + permissions: ['admin_full_access', 'field_default', 'member_default'], positions: ['everyone', 'platform_admin'], posture: 'PLATFORM_ADMIN', systemPermissions: [ @@ -805,3 +816,26 @@ const GRANT_GOLDEN: Record<'single' | 'isolated', unknown> = { }, }, }; + +/** The `everyone` anchor's sets in {@link GRANT_GOLDEN} — the deployment's baseline. */ +const BASELINE_SETS = ['field_default', 'member_default']; + +/** + * {@link GRANT_GOLDEN} for a scenario. Under {@link SHADOWING_DEFINITIONS} a + * stored package-less `everyone` definition answers the catalog read ahead of + * the built-in (the registry's bare-slot precedence, ADR-0005) and names no + * set, so the envelope carries no baseline name — enforcement still applies the + * baseline additively (ADR-0090 D5). A v18 boot refuses such a stored + * definition under a packaged name (`os migrate security-catalog-overlays`). + */ +function grantGoldenFor(scenario: Scenario): unknown { + const golden = GRANT_GOLDEN[scenario.posture] as Record; + if (!scenario.shadowedBuiltins) return golden; + return Object.fromEntries( + Object.entries(golden).map(([who, grants]) => [ + who, + who === 'agent' ? grants : { ...grants, permissions: grants.permissions.filter((p) => !BASELINE_SETS.includes(p)) }, + ]), + ); +} + diff --git a/packages/plugins/plugin-security/src/builtin-positions.ts b/packages/plugins/plugin-security/src/builtin-positions.ts index 4e6064abbae..e7eb9539209 100644 --- a/packages/plugins/plugin-security/src/builtin-positions.ts +++ b/packages/plugins/plugin-security/src/builtin-positions.ts @@ -49,14 +49,21 @@ * * ## What a declaration here does NOT carry * - * Only identity and display: `name`, `label`, `description` — what - * `PositionSchema` declares and what the rows have always been stamped with. - * Activation (`active`), the default flag (`is_default`) and provenance - * (`managed_by`) stay on the catalog ROW; the six bind no permission set in - * their definition (the binding stays on its junction rows until the position - * definition can carry it). The sources of truth behind the identity names — - * `sys_member.role` for the `org_*` trio, the unscoped `admin_full_access` - * grant for `platform_admin` — are untouched: these remain a projection. + * Identity and display: `name`, `label`, `description` — what the rows have + * always been stamped with. Activation (`active`), the default flag + * (`is_default`) and provenance (`managed_by`) stay on the catalog ROW. The + * sources of truth behind the identity names — `sys_member.role` for the + * `org_*` trio, the unscoped `admin_full_access` grant for `platform_admin` — + * are untouched: these remain a projection. + * + * ## The one binding a declaration here carries: `everyone`'s baseline + * + * [ADR-0090 D5, ADR-0131 D3/D4] The `everyone` anchor distributes the + * deployment's baseline sets — the app's `isDefault` set composed with the + * platform's `member_default` — in its definition's `permissionSets`, which + * the authorization resolver reads. The plugin declares it at `kernel:ready` + * ({@link declareEveryoneBaseline}), once the stack's declared capabilities + * the high-privilege check reads are in; no other built-in names a set. */ import { @@ -136,3 +143,19 @@ export function registerBuiltinPositions(registry: unknown, packageId: string): } return securityBuiltinPositions.length; } + +/** + * [ADR-0090 D5, ADR-0131 D3/D4] Declare the `everyone` anchor again, owned by + * `packageId`, now naming `sets` — the deployment's baseline — in its + * `permissionSets` (module doc). The same package re-registering its own item + * replaces it. + * + * @returns whether it was declared — `false` when `registry` cannot register. + */ +export function declareEveryoneBaseline(registry: unknown, packageId: string, sets: readonly string[]): boolean { + const target = registry as Partial | null | undefined; + if (!target || typeof target.registerItem !== 'function') return false; + const everyone = securityBuiltinPositions.find((p) => p.name === EVERYONE_POSITION)!; + target.registerItem('position', { ...everyone, permissionSets: [...sets] }, 'name', packageId); + return true; +} diff --git a/packages/plugins/plugin-security/src/explain-enforce-parity.test.ts b/packages/plugins/plugin-security/src/explain-enforce-parity.test.ts index 5d3e0678d96..f9820cd3d23 100644 --- a/packages/plugins/plugin-security/src/explain-enforce-parity.test.ts +++ b/packages/plugins/plugin-security/src/explain-enforce-parity.test.ts @@ -283,7 +283,8 @@ async function bootRls(predicate: string, opts: { grantCrud?: boolean } = {}) { manifest: { register: vi.fn() }, objectql: engine, metadata: { - get: async (_type: string, name: string) => engine.getSchema(name) ?? null, + get: async (type: string, name: string) => + type === 'permission' ? [MEMBER_DEFAULT, set].find((s) => s.name === name) ?? null : engine.getSchema(name) ?? null, list: async () => [MEMBER_DEFAULT, set], }, }; @@ -345,7 +346,9 @@ const USER_REMOVED = 'usr_alpha_removed'; const READER_SET = 'qa_parity_reader'; /** * Authored only as a `sys_permission_set` row OF `org_alpha` (no metadata - * declaration), granted in `org_alpha`: it opens the global note object. + * declaration), granted in `org_alpha`. It would open the global note object, + * but a set the security catalog does not hold confers nothing (ADR-0131 + * D3/D4, #15196 Q3 A): `NOTES` is refused to every principal, on both faces. */ const ALPHA_ONLY_SET = 'qa_parity_alpha_notes'; @@ -364,7 +367,8 @@ type PostureSource = * organizations; * - `PROBE` — platform-global (`tenancy: { enabled: false }`), so only the * grants decide; - * - `NOTES` — platform-global, opened only by {@link ALPHA_ONLY_SET}. + * - `NOTES` — platform-global, granted only by {@link ALPHA_ONLY_SET}, which no + * definition backs — so refused to everyone. * * Enforcement's face is `resolveAuthzContext` with a session that claims * `org_alpha`, handed the posture admission hands it, assembled by @@ -438,7 +442,10 @@ async function bootPrincipal(source: PostureSource) { manifest: { register: vi.fn() }, objectql: engine, metadata: { - get: async (_type: string, name: string) => engine.getSchema(name) ?? null, + get: async (type: string, name: string) => + type === 'permission' + ? [MEMBER_DEFAULT, userAdminSet, readerSet].find((s) => s.name === name) ?? null + : engine.getSchema(name) ?? null, list: async () => [MEMBER_DEFAULT, userAdminSet, readerSet], }, ...('tenancy' in source ? { tenancy: { posture: source.tenancy } } : { 'org-scoping': {} }), @@ -572,7 +579,8 @@ async function bootSharing(opts: { faultReadFilter?: boolean } = {}) { manifest: { register: vi.fn() }, objectql: engine, metadata: { - get: async (_type: string, name: string) => engine.getSchema(name) ?? null, + get: async (type: string, name: string) => + type === 'permission' ? sets.find((s: { name?: string }) => s.name === name) ?? null : engine.getSchema(name) ?? null, list: async () => sets, }, get sharing() { return sharing; }, @@ -879,27 +887,27 @@ const TABLE: Row[] = [ // #20580 (the removed member) and #20604 position 3 (the current member), under each walled posture. ...principalRows('#20580 · #20604 P3', { tenancy: 'isolated' }, '`isolated`', { removed: { LEDGER: REFUSED_DENIED, PROBE: REFUSED_DENIED, NOTES: REFUSED_DENIED }, - member: { LEDGER: 'rows', PROBE: 'rows', NOTES: 'rows' }, + member: { LEDGER: 'rows', PROBE: 'rows', NOTES: REFUSED_DENIED }, }), ...principalRows('#20580 · #20604 P3', { tenancy: 'group' }, '`group`', { removed: { LEDGER: REFUSED_DENIED, PROBE: REFUSED_DENIED, NOTES: REFUSED_DENIED }, - member: { LEDGER: 'rows', PROBE: 'rows', NOTES: 'rows' }, + member: { LEDGER: 'rows', PROBE: 'rows', NOTES: REFUSED_DENIED }, }), ...principalRows('#20580 control · #20604 P3', { tenancy: 'single' }, '`single`', { - removed: { LEDGER: 'rows', PROBE: 'rows', NOTES: 'rows' }, - member: { LEDGER: 'rows', PROBE: 'rows', NOTES: 'rows' }, + removed: { LEDGER: 'rows', PROBE: 'rows', NOTES: REFUSED_DENIED }, + member: { LEDGER: 'rows', PROBE: 'rows', NOTES: REFUSED_DENIED }, }, { removed: { 'readFilter:LEDGER': NATIVE_SCOPING_UNDER_SINGLE, 'record:l_beta': NATIVE_SCOPING_UNDER_SINGLE }, member: { 'readFilter:LEDGER': NATIVE_SCOPING_UNDER_SINGLE, 'record:l_beta': NATIVE_SCOPING_UNDER_SINGLE }, }), // The posture-source asymmetry: the member's rows hold; the removed member's are enforcement's finding. ...principalRows('#20604 A4', { orgScopingOnly: true }, '`org-scoping` with no `tenancy` service', { - removed: { LEDGER: 'rows', PROBE: 'rows', NOTES: 'rows' }, - member: { LEDGER: 'rows', PROBE: 'rows', NOTES: 'rows' }, + removed: { LEDGER: 'rows', PROBE: 'rows', NOTES: REFUSED_DENIED }, + member: { LEDGER: 'rows', PROBE: 'rows', NOTES: REFUSED_DENIED }, }, { removed: Object.fromEntries( - ['sets', 'allowed:LEDGER', 'readFilter:LEDGER', 'record:l_alpha', 'allowed:PROBE', 'readFilter:PROBE', - 'allowed:NOTES', 'readFilter:NOTES'].map((k) => [k, CLAIM_KEPT_UNDER_A_WALL]), + ['sets', 'allowed:LEDGER', 'readFilter:LEDGER', 'record:l_alpha', 'allowed:PROBE', 'readFilter:PROBE'] + .map((k) => [k, CLAIM_KEPT_UNDER_A_WALL]), ), }), ]; diff --git a/packages/plugins/plugin-security/src/explain-engine.test.ts b/packages/plugins/plugin-security/src/explain-engine.test.ts index 38ad24d5002..743f5d43c80 100644 --- a/packages/plugins/plugin-security/src/explain-engine.test.ts +++ b/packages/plugins/plugin-security/src/explain-engine.test.ts @@ -9,6 +9,7 @@ import { explainAccess, buildContextForUser, resolveDelegatorContext, type Expla import { RLS_DENY_FILTER } from './rls-compiler'; import { unresolvedPostureRemedy } from './unresolved-posture'; import { assertEngineFindOnePredicate, type EngineFindOneQueryInput } from '@objectstack/metadata-core'; +import { bindCatalogFromTables, ledgerFromTables } from './__tests__/security-catalog.testkit.js'; // [commit a68c61267] `ExplainDecision.layers` is `ExplainLayer[]` — the z.INPUT shape // (ADR-0122), in which every `.default([])` member is OPTIONAL before a parse: @@ -869,10 +870,15 @@ type Rows = Record; /** Minimal `where`-honouring ObjectQL stand-in: scalar equality and `$in`. */ function makeGrantQl(tables: Rows) { - return { + return bindCatalogFromTables({ async find(object: string, opts: any) { const where = opts?.where ?? {}; - return (tables[object] ?? []).filter((row) => + // [ADR-0131 D3] The resolver reads deactivation from the activation + // ledger: a fixture's `active: false` rows reach it as the ledger rows the + // upgrade ceremony converts them to. The explainer's own "deactivated" + // annotation still reads the row (stage 2 moves it). + const stored = object === 'sys_metadata_activation' && !tables[object] ? ledgerFromTables(tables) : tables[object]; + return (stored ?? []).filter((row) => Object.entries(where).every(([key, cond]) => { if (key.startsWith('$')) throw new Error(`fake driver: unsupported operator ${key}`); const cell = row[key]; // `null` matches a null-valued AND a key-absent cell — the memory @@ -885,7 +891,7 @@ function makeGrantQl(tables: Rows) { }), ); }, - }; + }, tables); } const NOW = Date.parse('2026-07-10T12:00:00Z'); @@ -1457,11 +1463,11 @@ describe('buildContextForUser bypasses the #11971 grants cache (ruled bypass lis return () => { listeners.delete(l); }; }, }; - return { + return bindCatalogFromTables({ ...makeGrantQl(tables), writeEpoch: epoch, registerMiddleware(fn: Middleware): void { middlewares.push(fn); }, - }; + }, tables); } it('with the cache ON and provably stale, the explainer still observes the revocation immediately', async () => { @@ -1827,14 +1833,14 @@ describe('[#20515] buildContextForUser and resolveDelegatorContext resolve in an describe('resolveDelegatorContext: the delegator is resolved in the live principal\'s organization', () => { const delegatorQl = () => { const t = tables(); - return { + return bindCatalogFromTables({ ...makeGrantQl(t), async findOne(object: string, opts: EngineFindOneQueryInput) { assertEngineFindOnePredicate(object, opts); const id = (opts as any)?.where?.id; return (t[object] ?? []).find((r) => r.id === id) ?? null; }, - }; + }, t); }; it('live principal in org_alpha → the delegator\'s org_alpha grant applies, its org_beta grant does not', async () => { diff --git a/packages/plugins/plugin-security/src/explain-positions-name-authority.test.ts b/packages/plugins/plugin-security/src/explain-positions-name-authority.test.ts index 5cb58cf5a32..505f5628a4f 100644 --- a/packages/plugins/plugin-security/src/explain-positions-name-authority.test.ts +++ b/packages/plugins/plugin-security/src/explain-positions-name-authority.test.ts @@ -54,6 +54,7 @@ import { PermissionSetSchema } from '@objectstack/spec/security'; import { hasPlatformAdminStanding } from '@objectstack/core'; import { PermissionEvaluator } from './permission-evaluator'; import { explainAccess, buildContextForUser, type ExplainEngineDeps } from './explain-engine'; +import { bindCatalogFromTables } from './__tests__/security-catalog.testkit.js'; const ADMIN = PermissionSetSchema.parse({ name: 'admin_full_access', @@ -112,13 +113,13 @@ function makeAuthzQl(tables: Record>>) { if (v === null) return (row[k] ?? null) === null; return row[k] === v; }); - return { + return bindCatalogFromTables({ getSchema: () => PRIVATE_SCHEMA, async find(object: string, opts: any) { const rows = (tables[object] ?? []).filter((r) => matches(r, opts?.where)); return typeof opts?.limit === 'number' ? rows.slice(0, opts.limit) : rows; }, - }; + }, tables); } function authzTables(shape: 'name-only' | 'genuine') { diff --git a/packages/plugins/plugin-security/src/explain-removed-member-principal.test.ts b/packages/plugins/plugin-security/src/explain-removed-member-principal.test.ts index 7f7afd28383..f4af169fbb8 100644 --- a/packages/plugins/plugin-security/src/explain-removed-member-principal.test.ts +++ b/packages/plugins/plugin-security/src/explain-removed-member-principal.test.ts @@ -176,7 +176,10 @@ async function boot(makeDriver: () => Driver, posture: TenancyPosture) { manifest: { register: vi.fn() }, objectql: engine, metadata: { - get: async (_type: string, name: string) => engine.getSchema(name) ?? null, + get: async (type: string, name: string) => + type === 'permission' + ? [MEMBER_DEFAULT, userAdminSet, probeSet].find((s) => s.name === name) ?? null + : engine.getSchema(name) ?? null, list: async () => [MEMBER_DEFAULT, userAdminSet, probeSet], }, tenancy: { posture }, 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 63bf8516de5..f1d32e55865 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 @@ -139,7 +139,10 @@ async function boot(posture: Posture, opts: { ledger?: boolean } = {}): Promise< manifest: { register: vi.fn() }, objectql: engine, metadata: { - get: async (_type: string, name: string) => engine.getSchema(name) ?? null, + get: async (type: string, name: string) => + type === 'permission' + ? ([...defaultPermissionSets, QA_ADMIN] as Array<{ name?: string }>).find((s) => s?.name === name) ?? null + : engine.getSchema(name) ?? null, list: async () => [...defaultPermissionSets, QA_ADMIN], }, ...(posture === 'isolated' @@ -623,7 +626,10 @@ describe('[ADR-0131 D4] grant name backfill — on a real kernel boot', () => { expect((await grant(engine, 'g_late'))?.permission_set).toBeNull(); const metadata = { - get: async (_type: string, name: string) => engine.getSchema(name) ?? null, + get: async (type: string, name: string) => + type === 'permission' + ? ([...defaultPermissionSets] as Array<{ name?: string }>).find((s) => s?.name === name) ?? null + : engine.getSchema(name) ?? null, list: async () => [...defaultPermissionSets], }; const kernel = new LiteKernel({ logger: { level: 'silent' } }); diff --git a/packages/plugins/plugin-security/src/grant-permission-set-name.equivalence.test.ts b/packages/plugins/plugin-security/src/grant-permission-set-name.equivalence.test.ts index 05beb82fcbe..a880b75389a 100644 --- a/packages/plugins/plugin-security/src/grant-permission-set-name.equivalence.test.ts +++ b/packages/plugins/plugin-security/src/grant-permission-set-name.equivalence.test.ts @@ -121,7 +121,10 @@ async function grantsByPrincipal(posture: Posture): Promise engine.getSchema(name) ?? null, + get: async (type: string, name: string) => + type === 'permission' + ? ([...defaultPermissionSets, QA_ADMIN] as Array<{ name?: string }>).find((s) => s?.name === name) ?? null + : engine.getSchema(name) ?? null, list: async () => [...defaultPermissionSets, QA_ADMIN], }, ...(posture === 'isolated' diff --git a/packages/plugins/plugin-security/src/grant-readers-by-name.golden.test.ts b/packages/plugins/plugin-security/src/grant-readers-by-name.golden.test.ts index 65a0e71bd73..dec8f180673 100644 --- a/packages/plugins/plugin-security/src/grant-readers-by-name.golden.test.ts +++ b/packages/plugins/plugin-security/src/grant-readers-by-name.golden.test.ts @@ -137,7 +137,10 @@ async function readersByPrincipal(posture: Posture): Promise engine.getSchema(name) ?? null, + get: async (type: string, name: string) => + type === 'permission' + ? ([...defaultPermissionSets, QA_ADMIN] as Array<{ name?: string }>).find((s) => s?.name === name) ?? null + : engine.getSchema(name) ?? null, list: async () => [...defaultPermissionSets, QA_ADMIN], }, ...(walled diff --git a/packages/plugins/plugin-security/src/orgless-position-name-fold.test.ts b/packages/plugins/plugin-security/src/orgless-position-name-fold.test.ts index 5d69f4a6814..2b06764472b 100644 --- a/packages/plugins/plugin-security/src/orgless-position-name-fold.test.ts +++ b/packages/plugins/plugin-security/src/orgless-position-name-fold.test.ts @@ -160,6 +160,11 @@ async function boot(): Promise { id: 'ups_global_platform_ops', user_id: USER_GLOBAL, permission_set_id: 'ps_1_global_platform_ops', permission_set: 'platform_ops', organization_id: null, }, SYS); + // [ADR-0131 D3/D4] The two GLOBAL sets are declared in the catalog, which is + // what makes a grant of them confer anything; the sets ORG_OTHER authored are + // rows only, with no definition (#15196 Q3 A). + engine.registry.registerItem('permission', { name: 'ops_lead', systemPermissions: ['probe.global_ops_lead'] }, 'name', 'com.test.ops'); + engine.registry.registerItem('permission', { name: 'platform_ops', systemPermissions: ['probe.global_platform_ops'] }, 'name', 'com.test.ops'); return engine; } @@ -208,7 +213,7 @@ async function rig(): Promise { hook: () => { /* collected, never fired — see the file header */ }, getService: (n: string) => { if (n === 'objectql') return ql; - if (n === 'metadata') return { list: async () => [] }; + if (n === 'metadata') return { get: async () => undefined, list: async () => [] }; if (n === 'manifest') return { register: () => {} }; return services[n]; }, diff --git a/packages/plugins/plugin-security/src/per-organization-catalog.test.ts b/packages/plugins/plugin-security/src/per-organization-catalog.test.ts index 26efad2b171..7bdff2281e9 100644 --- a/packages/plugins/plugin-security/src/per-organization-catalog.test.ts +++ b/packages/plugins/plugin-security/src/per-organization-catalog.test.ts @@ -51,6 +51,7 @@ import { SysPositionPermissionSet } from './objects/sys-position-permission-set. import { SysUserPosition } from './objects/sys-user-position.object.js'; import { SysUserPermissionSet } from './objects/sys-user-permission-set.object.js'; import { SysOrganization, SysUser, SysMember } from '@objectstack/platform-objects/identity'; +import { bindTestSecurityCatalog } from './__tests__/security-catalog.testkit.js'; const ORG_JIA = 'org_jia'; const ORG_YI = 'org_yi'; @@ -299,7 +300,7 @@ describe('[#10103] per-organization RBAC catalog materialization', () => { expect(setAgain).toBe(setSnapshot); }); - it('6. the cross-organization grant bleed is closed: the REAL resolver no longer collects another organization’s binding', async () => { + it('6. the cross-organization grant bleed is closed: the REAL resolver collects no organization’s junction binding', async () => { const engine = await boot(); const { logger } = recordingLogger(); for (const org of ORGS) await seedCatalog(engine, logger, org); @@ -327,13 +328,25 @@ describe('[#10103] per-organization RBAC catalog materialization', () => { const yiGrants = await resolveUserAuthzGrants(engine as any, 'u_yi', { tenantId: ORG_YI }); expect(yiGrants.permissions).not.toContain('jia_wide_grant'); - // Positive control on the same resolver, from the same fixture: org_jia's - // own member DOES get it. Without this the assertion above would pass on a - // resolver that had simply stopped resolving anything. + // [ADR-0131 D3/D4] …and org_jia's own member does not get it either: the + // resolver reads a position's sets from its catalog definition, so a + // junction row binds nothing anywhere (#15196 Q3 A). await (engine as any).insert('sys_user_position', { id: 'up_jia', user_id: 'u_jia', position: 'everyone' }, { context: { isSystem: true, tenantId: ORG_JIA } }); const jiaGrants = await resolveUserAuthzGrants(engine as any, 'u_jia', { tenantId: ORG_JIA }); - expect(jiaGrants.permissions).toContain('jia_wide_grant'); + expect(jiaGrants.permissions).not.toContain('jia_wide_grant'); + + // Positive control on the same resolver, from the same fixture: a set the + // `everyone` DEFINITION names reaches both organizations' members. Without + // it the assertions above would pass on a resolver that had simply stopped + // resolving anything. + bindTestSecurityCatalog(engine, { + positions: [{ name: 'everyone', permissionSets: ['jia_wide_grant'] }], + permissions: [{ name: 'jia_wide_grant' }], + }); + for (const [user, org] of [['u_yi', ORG_YI], ['u_jia', ORG_JIA]] as const) { + expect((await resolveUserAuthzGrants(engine as any, user, { tenantId: org })).permissions).toContain('jia_wide_grant'); + } }); }); diff --git a/packages/plugins/plugin-security/src/position-catalog-refusal.test.ts b/packages/plugins/plugin-security/src/position-catalog-refusal.test.ts index c58d0d88d33..a8681ab8a59 100644 --- a/packages/plugins/plugin-security/src/position-catalog-refusal.test.ts +++ b/packages/plugins/plugin-security/src/position-catalog-refusal.test.ts @@ -32,6 +32,7 @@ import type { PermissionSet } from '@objectstack/spec/security'; import { SecurityPlugin } from './security-plugin.js'; import { SysUser, SysMember } from '@objectstack/platform-objects/identity'; +import { SysMetadataActivation } from '@objectstack/platform-objects/system'; import { SysPosition } from './objects/sys-position.object.js'; import { SysUserPosition } from './objects/sys-user-position.object.js'; import { SysPermissionSet } from './objects/sys-permission-set.object.js'; @@ -120,7 +121,8 @@ async function boot(opts: { walled?: boolean } = {}) { scope: 'system', // `sys_member` is provisioned: an organization-scoped assignment must name // a member of its organization (`grant-holder-membership-refusal.ts`). - objects: [SysPosition, SysUserPosition, SysPermissionSet, SysPositionPermissionSet, SysUserPermissionSet, SysMember, QA_INQUIRY], + // `sys_metadata_activation`: the ledger the resolver reads deactivation from (ADR-0131 D3). + objects: [SysPosition, SysUserPosition, SysPermissionSet, SysPositionPermissionSet, SysUserPermissionSet, SysMember, SysMetadataActivation, QA_INQUIRY], } as any); await engine.syncSchemas(); // [#21516] The authz resolver reads these on every grant resolution; in a @@ -139,7 +141,10 @@ async function boot(opts: { walled?: boolean } = {}) { manifest: { register: vi.fn() }, objectql: engine, metadata: { - get: async (_type: string, name: string) => engine.getSchema(name) ?? null, + get: async (type: string, name: string) => + type === 'permission' + ? ([MEMBER_DEFAULT, QA_ADMIN, QA_ORG_ADMIN_LIKE] as Array<{ name?: string }>).find((s) => s?.name === name) ?? null + : engine.getSchema(name) ?? null, list: async () => [MEMBER_DEFAULT, QA_ADMIN, QA_ORG_ADMIN_LIKE], }, ...(opts.walled @@ -186,11 +191,25 @@ async function boot(opts: { walled?: boolean } = {}) { { id: 'pps_auditor', position_id: AUDITOR_POSITION_ID, permission_set_id: 'ps_auditor' }, { id: 'pps_retired', position_id: 'pos_retired', permission_set_id: 'ps_auditor' }, ], { context: seed } as any); + // [ADR-0131 D3, ADR-0126 §4] The switch the resolver honours: the row's own + // `active: false` above is no longer read. + await engine.insert('sys_metadata_activation', { + metadata_type: 'position', name: 'qa_retired', package_id: 'com.objectstack.qa.position-catalog-refusal', active: false, + }, { context: seed } as any); await engine.insert('qa_inquiry', [ { id: 'inq_1', subject: 'one' }, { id: 'inq_2', subject: 'two' }, { id: 'inq_3', subject: 'three' }, ], { context: seed } as any); + // [ADR-0131 D3/D4] What the resolver reads: the set's definition, and each + // position's definition naming it — the junction rows above bind nothing. + const pkg = 'com.objectstack.qa.position-catalog-refusal'; + engine.registry.registerItem('permission', { + name: 'qa_auditor_set', label: 'QA Auditor', objects: { qa_inquiry: { allowRead: true, viewAllRecords: true } }, + } as any, 'name' as any, pkg); + for (const [name, label] of [['qa_auditor', 'Auditor'], ['qa_retired', 'Retired']]) { + engine.registry.registerItem('position', { name, label, permissionSets: ['qa_auditor_set'] } as any, 'name' as any, pkg); + } return { engine, warn }; } diff --git a/packages/plugins/plugin-security/src/position-name-fold-warning.test.ts b/packages/plugins/plugin-security/src/position-name-fold-warning.test.ts index 6e11a8d9a2c..be0d6a5541b 100644 --- a/packages/plugins/plugin-security/src/position-name-fold-warning.test.ts +++ b/packages/plugins/plugin-security/src/position-name-fold-warning.test.ts @@ -165,7 +165,10 @@ describe('[#13419] MUST FIRE — a position folded onto its own same-name set wi it('names the ungoverned grant and the two ways out, not just the collision', async () => { const { warn } = await resolveWith({ userId: 'u1', positions: ['sales_rep'], permissions: [] }); const message = warn.mock.calls.map((c) => String(c[0])).find((m) => m.includes(EVENT))!; - expect(message).toContain('sys_position_permission_set'); + // [ADR-0131 D3/D4] The governed way out is the position definition's + // `permissionSets` — a junction row no longer grants or governs anything. + expect(message).toContain("permissionSets: ['sales_rep']"); + expect(message).not.toContain('sys_position_permission_set'); expect(message).toContain('ungoverned'); expect(message).toMatch(/rename/i); }); diff --git a/packages/plugins/plugin-security/src/position-write-through.test.ts b/packages/plugins/plugin-security/src/position-write-through.test.ts index daeac0a8ad4..0cf67903d2e 100644 --- a/packages/plugins/plugin-security/src/position-write-through.test.ts +++ b/packages/plugins/plugin-security/src/position-write-through.test.ts @@ -150,7 +150,10 @@ async function boot(opts: { posture?: 'single' | 'isolated'; door?: boolean; led ? null : new ObjectStackProtocolImplementation(engine as never, () => new Map(), undefined); const metadata = { - get: async (_type: string, name: string) => engine.getSchema(name) ?? null, + get: async (type: string, name: string) => + type === 'permission' + ? ([...defaultPermissionSets, QA_ADMIN] as Array<{ name?: string }>).find((s) => s?.name === name) ?? null + : engine.getSchema(name) ?? null, list: async (type: string) => (type === 'position' ? [] : [...defaultPermissionSets, QA_ADMIN]), }; const services: Record = { @@ -246,7 +249,12 @@ describe('[ADR-0131 D3] position write-through under single — a Setup position expect(await b.catalogResolves('regional_lead')).toBe(true); }); - it('a user assigned to a Setup-created position is granted exactly what the row-only position granted', async () => { + // [ADR-0131 D3/D4] The resolver reads a position's sets from its definition's + // `permissionSets`, never from the junction: the binding Setup makes as a + // `sys_position_permission_set` row grants nothing, with the write-through or + // without it. Setup moves onto the definition with C9 and the write-through's + // retirement; until then this is the measured gap the release note names. + it('a user assigned to a Setup-created position is granted the same with or without the write-through — and a junction binding grants nothing', async () => { const grantsThrough = async (door: boolean) => { const b = await boot({ door }); await b.engine.insert('sys_user', { @@ -276,7 +284,26 @@ describe('[ADR-0131 D3] position write-through under single — a Setup position expect(before.defined).toBe(0); expect(after.defined).toBe(1); expect(after.grants).toEqual(before.grants); - expect(after.grants).toMatchObject({ positions: expect.arrayContaining(['report_reader']), permissions: ['pw_reports'] }); + expect(after.grants).toMatchObject({ positions: expect.arrayContaining(['report_reader']) }); + expect((after.grants as any).permissions).not.toContain('pw_reports'); + }); + + // [ADR-0131 D3/D4] `permissionSets` has no row column: a Setup edit of the + // row saves the definition again, and must carry the binding the metadata + // door declared rather than clear it. + it('a row edit keeps the `permissionSets` the stored definition names, and a rename carries them to the new name', async () => { + const b = await boot(); + const created: any = await create(b, { name: 'night_lead', label: 'Night lead' }); + await (b.protocol as any).saveMetaItem({ + type: 'position', name: 'night_lead', item: { name: 'night_lead', label: 'Night lead', permissionSets: ['night_tools'] }, + }); + expect((await b.envRows('night_lead')).map((r) => r.body.permissionSets)).toEqual([['night_tools']]); + await patch(b, created.id, { label: 'Night supervisor' }); + expect((await b.envRows('night_lead')).map((r) => r.body)).toEqual([ + { name: 'night_lead', label: 'Night supervisor', delegatable: false, permissionSets: ['night_tools'] }, + ]); + await patch(b, created.id, { name: 'night_supervisor' }); + expect((await b.envRows('night_supervisor')).map((r) => r.body.permissionSets)).toEqual([['night_tools']]); }); it('an edit keeps the row and the definition agreeing; a deactivation and is_default stay on the row', async () => { @@ -619,6 +646,21 @@ describe('[ADR-0131 D3] controls — what the write-through leaves exactly as it expect(await b.envRows('plant_lead')).toEqual([]); }); + // [ADR-0131 D3, #15196 Q3 = A] The promotion path the release note names: + // under a wall an organization's Setup position is a row with no definition, + // so it grants nothing; the metadata door takes an environment definition + // under the same name, and that definition is what the catalog then serves. + it('a walled posture: an organization\'s row-only position is promoted by an environment definition saved at the metadata door', async () => { + const b = await boot({ posture: 'isolated' }); + await create(b, { name: 'plant_auditor', label: 'Plant auditor' }); + expect(await b.catalogResolves('plant_auditor')).toBe(false); + await (b.protocol as any).saveMetaItem({ + type: 'position', name: 'plant_auditor', item: { name: 'plant_auditor', label: 'Plant auditor', permissionSets: ['plant_tools'] }, + }); + expect((await b.envRows('plant_auditor')).map((r) => r.body.permissionSets)).toEqual([['plant_tools']]); + expect(await b.catalogResolves('plant_auditor')).toBe(true); + }); + it('a system write is never translated', async () => { const b = await boot(); await b.engine.insert('sys_position', { id: 'pos_sys', name: 'seeded_lead', label: 'Seeded' }, { context: SYS } as any); diff --git a/packages/plugins/plugin-security/src/position-write-through.ts b/packages/plugins/plugin-security/src/position-write-through.ts index 17ac65e2093..ac350354b74 100644 --- a/packages/plugins/plugin-security/src/position-write-through.ts +++ b/packages/plugins/plugin-security/src/position-write-through.ts @@ -296,13 +296,26 @@ export function createPositionWriteThrough( const environmentOwns = (name: unknown): name is string => typeof name === 'string' && name !== '' && !packageHoldsPosition(ql, name); - const saveDefinition = (door: PositionMetadataDoor, row: Record, actor?: string) => - door.saveMetaItem({ + /** + * [ADR-0131 D3/D4] Save the definition a row carries. `permissionSets` has no + * row column, so the list the stored definition under `carriedFrom` names + * (the row's name before this write) travels into the saved body — a row + * edit never clears a binding the metadata door declared. A registry that + * cannot be read throws, and the caller undoes the row write. + */ + const saveDefinition = (door: PositionMetadataDoor, row: Record, actor?: string, carriedFrom?: unknown) => { + const item = positionBodyFromRow(row); + const stored = typeof carriedFrom === 'string' && carriedFrom !== '' + ? (ql?.registry?.getItem?.(POSITION_METADATA_TYPE, carriedFrom) as { permissionSets?: unknown } | undefined)?.permissionSets + : undefined; + if (Array.isArray(stored) && stored.length > 0) item.permissionSets = [...stored]; + return door.saveMetaItem({ type: POSITION_METADATA_TYPE, name: String(row.name), - item: positionBodyFromRow(row), + item, ...(actor ? { actor } : {}), }); + }; /** * Delete `name`'s environment definition once no row carries the name any @@ -460,7 +473,7 @@ export function createPositionWriteThrough( // stage and can have no definition — the edit stays a row write. if (!renamed && !metadataDoorAcceptsPositionName(post.name)) continue; try { - await saveDefinition(door, post, actor); + await saveDefinition(door, post, actor, pre.name); saved.push(pair); } catch (e) { await undoUpdate(targets, patch); @@ -501,7 +514,7 @@ export function createPositionWriteThrough( if (pre.name !== post.name) { await door.deleteMetaItem({ type: POSITION_METADATA_TYPE, name: String(post.name), ...(actor ? { actor } : {}) }); } else { - await saveDefinition(door, pre, actor); + await saveDefinition(door, pre, actor, pre.name); } } catch (e) { logError( diff --git a/packages/plugins/plugin-security/src/resolve-authz-grant-set-by-name.golden.test.ts b/packages/plugins/plugin-security/src/resolve-authz-grant-set-by-name.golden.test.ts index c642a3e396b..94f6a4a7edc 100644 --- a/packages/plugins/plugin-security/src/resolve-authz-grant-set-by-name.golden.test.ts +++ b/packages/plugins/plugin-security/src/resolve-authz-grant-set-by-name.golden.test.ts @@ -118,7 +118,10 @@ async function resolverByPrincipal(posture: Posture): Promise engine.getSchema(name) ?? null, + get: async (type: string, name: string) => + type === 'permission' + ? ([...defaultPermissionSets, QA_ADMIN] as Array<{ name?: string }>).find((s) => s?.name === name) ?? null + : engine.getSchema(name) ?? null, list: async () => [...defaultPermissionSets, QA_ADMIN], }, ...(walled diff --git a/packages/plugins/plugin-security/src/resolve-authz-grant-set-by-name.test.ts b/packages/plugins/plugin-security/src/resolve-authz-grant-set-by-name.test.ts index 3b051593cfd..0b35c694426 100644 --- a/packages/plugins/plugin-security/src/resolve-authz-grant-set-by-name.test.ts +++ b/packages/plugins/plugin-security/src/resolve-authz-grant-set-by-name.test.ts @@ -96,7 +96,10 @@ async function boot(opts: { walled?: boolean } = {}): Promise { manifest: { register: vi.fn() }, objectql: engine, metadata: { - get: async (_type: string, name: string) => engine.getSchema(name) ?? null, + get: async (type: string, name: string) => + type === 'permission' + ? ([...defaultPermissionSets] as Array<{ name?: string }>).find((s) => s?.name === name) ?? null + : engine.getSchema(name) ?? null, list: async () => [...defaultPermissionSets], }, ...(opts.walled @@ -175,12 +178,24 @@ describe('[ADR-0131 D4] a grant whose id names another organization’s set conf } }); - it('CONTROL — an organization’s own grant of that set confers it, in that organization only', async () => { + // [ADR-0131 D3/D4] The set is the catalog's definition of its name: a set + // only an organization's ROW carries confers nothing, to that organization's + // own grant too (#15196 Q3 A) — and declared, it confers. + it('an organization’s own grant of a set only its row carries confers nothing, even in that organization', async () => { const engine = await world(); await engine.insert('sys_user_permission_set', { id: 'g_own', user_id: 'usr_x', permission_set_id: 'ps_b_tools', organization_id: ORG_B }, { context: SYS } as any); expect((await grant(engine, 'g_own'))?.permission_set).toBe('b_tools'); + expect((await resolveUserAuthzGrants(engine, 'usr_x', { tenantId: ORG_B })).systemPermissions).not.toContain('cap_b'); + }); + + it('CONTROL — the same grant of a set the catalog declares confers it, in that organization only', async () => { + const engine = await world(); + engine.registry.registerItem('permission', { name: 'b_tools', systemPermissions: ['cap_b'] } as any, 'name' as any, CATALOG_PACKAGE); + await engine.insert('sys_user_permission_set', + { id: 'g_own', user_id: 'usr_x', permission_set_id: 'ps_b_tools', organization_id: ORG_B }, + { context: SYS } as any); expect((await resolveUserAuthzGrants(engine, 'usr_x', { tenantId: ORG_B })).systemPermissions).toContain('cap_b'); expect((await resolveUserAuthzGrants(engine, 'usr_x', { tenantId: ORG_A })).systemPermissions).not.toContain('cap_b'); }); @@ -220,7 +235,10 @@ describe('[ADR-0131 D4] the upgrade boot — no request reaches the resolver bef engine.registry.registerItem('permission', structuredClone(ps) as any, 'name' as any, CATALOG_PACKAGE); } engine.registry.registerItem( - 'permission', { name: 'upgrade_reviewer', label: 'Reviewer', objects: {} } as any, 'name' as any, CATALOG_PACKAGE, + 'permission', + { name: 'upgrade_reviewer', label: 'Reviewer', objects: {}, systemPermissions: ['cap_review'] } as any, + 'name' as any, + CATALOG_PACKAGE, ); // Stored before this boot: the set row, and a grant with no name. await engine.insert('sys_permission_set', @@ -234,7 +252,10 @@ describe('[ADR-0131 D4] the upgrade boot — no request reaches the resolver bef const seen: Record = {}; const metadata = { - get: async (_type: string, name: string) => engine.getSchema(name) ?? null, + get: async (type: string, name: string) => + type === 'permission' + ? ([...defaultPermissionSets] as Array<{ name?: string }>).find((s) => s?.name === name) ?? null + : engine.getSchema(name) ?? null, list: async () => [...defaultPermissionSets], }; const kernel = new LiteKernel({ logger: { level: 'silent' } }); diff --git a/packages/plugins/plugin-security/src/security-plugin.test.ts b/packages/plugins/plugin-security/src/security-plugin.test.ts index dd150c33196..a49f9ed47b3 100644 --- a/packages/plugins/plugin-security/src/security-plugin.test.ts +++ b/packages/plugins/plugin-security/src/security-plugin.test.ts @@ -3787,9 +3787,16 @@ describe('explainAccessForCaller (ADR-0090 D6/D12)', () => { return row[k] === v; }); const baseSchema: any = { name: 'task', fields: { id: { name: 'id' }, owner_id: { name: 'owner_id' }, name: { name: 'name' } } }; + // [ADR-0131 D3/D4] The engine registry the security catalog reads the sets from. + const catalogSets = () => [memberDefault, subAdmin, hrAdmin]; const ql = { registerMiddleware: vi.fn(), getSchema: () => baseSchema, + registry: { + getItem: (type: string, name: string) => (type === 'permission' ? catalogSets().find((d: any) => d.name === name) : undefined), + listItems: (type: string) => (type === 'permission' ? catalogSets() : []), + isPackageDisabled: () => false, + }, async find(object: string, opts: any) { const rows = (tables[object] ?? []).filter((r) => matches(r, opts?.where)); return typeof opts?.limit === 'number' ? rows.slice(0, opts.limit) : rows; @@ -3803,7 +3810,11 @@ describe('explainAccessForCaller (ADR-0090 D6/D12)', () => { const services: Record = { manifest: { register: vi.fn() }, objectql: ql, - metadata: { get: async () => baseSchema, list: async () => [memberDefault, subAdmin, hrAdmin] }, + metadata: { + get: async (type: string, name: string) => + type === 'permission' ? [memberDefault, subAdmin, hrAdmin].find((s: any) => s.name === name) ?? null : baseSchema, + list: async () => [memberDefault, subAdmin, hrAdmin], + }, }; const ctx: any = { logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, @@ -4411,7 +4422,21 @@ describe('audience-anchor bindings read the stack\'s declared capabilities (#185 sys_position_permission_set: [], }; const inserts: Array<{ object: string; data: any }> = []; + // [ADR-0131 D3/D4] The registry the built-in positions are declared to — + // where `everyone` names the baseline the boot binding above judges. + const items = new Map(); + const registry = { + // Stamped with `_packageId` as the engine registry stamps it: the declared-capability + // reader tells the platform's own capability items apart by it. + registerItem: (type: string, item: any, _key?: string, packageId?: string) => { + items.set(`${type}:${item.name}`, packageId ? { ...item, _packageId: packageId } : item); + }, + getItem: (type: string, name: string) => items.get(`${type}:${name}`), + listItems: (type: string) => [...items.entries()].filter(([k]) => k.startsWith(`${type}:`)).map(([, v]) => v), + isPackageDisabled: () => false, + }; const ql: any = { + registry, registerMiddleware: () => {}, find: async (object: string, opts?: any) => { const where = opts?.where ?? {}; @@ -4483,7 +4508,11 @@ describe('audience-anchor bindings read the stack\'s declared capabilities (#185 String(c[0]).includes('refusing to bind fallback set to everyone'), ); const bindings = inserts.filter((i) => i.object === 'sys_position_permission_set'); - return { plugin, ql, ctx, bindings, refusals }; + const declaredRefusals = ctx.logger.warn.mock.calls.filter((c: any[]) => + String(c[0]).includes('the everyone anchor does not name a baseline set'), + ); + const everyoneSets = registry.getItem('position', 'everyone')?.permissionSets; + return { plugin, ql, ctx, bindings, refusals, declaredRefusals, everyoneSets }; } it('binds the isDefault set when the stack declares its systemPermissions token', async () => { @@ -4495,6 +4524,18 @@ describe('audience-anchor bindings read the stack\'s declared capabilities (#185 expect(bindings[0].data).toMatchObject({ position_id: 'pos_everyone', permission_set_id: 'ps_app' }); }); + // [ADR-0131 D3/D4] The `everyone` definition names the same baseline the + // binding above writes, judged by the same check against the same context. + it('the everyone definition names the isDefault set when the stack declares its token, and not when it does not', async () => { + const declared = await boot('crm.export_pipeline', [{ name: 'crm.export_pipeline', label: 'Export Pipeline' }]); + expect(declared.declaredRefusals).toHaveLength(0); + expect(declared.everyoneSets).toEqual(['app_member_default', 'member_default']); + const undeclared = await boot('crm.settle_ledger', [{ name: 'crm.export_pipeline', label: 'Export Pipeline' }]); + expect(undeclared.declaredRefusals).toHaveLength(1); + expect(undeclared.declaredRefusals[0][1]).toMatchObject({ set: 'app_member_default', offending: 'system permissions' }); + expect(undeclared.everyoneSets).toEqual(['member_default']); + }); + it('still refuses an UNDECLARED token — the control for the case above', async () => { // Declarations exist and name a DIFFERENT capability: it is MEMBERSHIP that // excuses a token, not the presence of a declaration list. diff --git a/packages/plugins/plugin-security/src/security-plugin.ts b/packages/plugins/plugin-security/src/security-plugin.ts index 6ca6a117be7..6a52dcdbcac 100644 --- a/packages/plugins/plugin-security/src/security-plugin.ts +++ b/packages/plugins/plugin-security/src/security-plugin.ts @@ -100,7 +100,7 @@ import { PLATFORM_OWNER_WALL_BYPASS_EVENT, isVerifiedPlatformOwnerRow, } from './platform-owner-wall-bypass.js'; -import { isConfiguredPlatformAdminEmail, resolvePlatformAdminEmails, vetOrganizationClaim, createSecurityCatalogReader } from '@objectstack/core'; +import { isConfiguredPlatformAdminEmail, resolvePlatformAdminEmails, vetOrganizationClaim, createSecurityCatalogReader, bindSecurityCatalogReader } from '@objectstack/core'; import { isPlatformTenantPolicy, isAuthoredTenantPolicy } from './platform-tenant-policies.js'; import { isPlatformOwnershipFloorPolicy, @@ -178,7 +178,7 @@ import { securityPluginManifestHeader, SECURITY_PLUGIN_ID, } from './manifest.js'; -import { registerBuiltinPositions } from './builtin-positions.js'; +import { declareEveryoneBaseline, registerBuiltinPositions } from './builtin-positions.js'; import { registerBuiltinCapabilities, withoutPlatformCapabilityDeclarations } from './builtin-capabilities.js'; /** @@ -1785,14 +1785,53 @@ export class SecurityPlugin implements Plugin { // id — the provenance the manifest stamps on `permissions`. Here, on the // engine handle `start()` already holds, before `kernel:ready` runs the // seeders and before any request reaches the metadata door. Rows keep - // seeding from the same list (`bootstrapBuiltinRoles`); nothing here writes - // a row or moves a grant. - if (registerBuiltinPositions((ql as { registry?: unknown }).registry, SECURITY_PLUGIN_ID) === 0) { + // seeding from the same list (`bootstrapBuiltinRoles`). + const builtinRegistry = (ql as { registry?: unknown }).registry; + if (registerBuiltinPositions(builtinRegistry, SECURITY_PLUGIN_ID) === 0) { ctx.logger.warn( '[security] the built-in positions (platform_admin, org_owner, org_admin, org_member, everyone, guest) ' + 'were NOT declared as position metadata: the ObjectQL engine exposes no registry that can register ' - + 'them. Their sys_position rows still seed and no grant changes, but the security catalog read and ' - + 'GET /api/v1/meta/position will not list them.', + + 'them. The security catalog read and GET /api/v1/meta/position will not list them, and the ' + + '`everyone` baseline sets are granted through no position.', + ); + } + // [ADR-0090 D5, ADR-0131 D3/D4] `everyone` declares the deployment's + // baseline in its `permissionSets` — the binding the authorization resolver + // reads, in place of the junction rows the boot used to write. At + // `kernel:ready`, where that binding ran, because the high-privilege check + // reads the stack's declared capabilities (#18535) and they are all in by + // then; the check and its report are the binding's own. + if (typeof (ctx as any).hook === 'function') { + (ctx as any).hook('kernel:ready', async () => { + try { + const anchorContext = await readDeclaredCapabilityContext(ql, this.metadata); + const everyoneSets = this.baselinePermissionSets.filter((name) => { + const boot = this.bootstrapPermissionSets.find((p) => p.name === name); + const offending = boot ? describeHighPrivilegeBits(boot, anchorContext) : null; + if (offending) { + ctx.logger.warn('[security] the everyone anchor does not name a baseline set — high-privilege bits', { set: name, offending }); + } + return !offending; + }); + if (everyoneSets.length > 0) declareEveryoneBaseline(builtinRegistry, SECURITY_PLUGIN_ID, everyoneSets); + } catch (e) { + ctx.logger.warn('[security] everyone-anchor baseline declaration failed (non-fatal)', { error: (e as Error).message }); + } + }); + } + + // [ADR-0131 D3/D4] Bind the security catalog read to this engine: every + // authorization resolution over it (`resolveUserAuthzGrants`, core) reads + // positions, the sets they name and the set bodies through this reader. + // An engine with no reader bound resolves an empty catalog — no position + // distributes a set and no set grants — so a failure here is said at once. + try { + bindSecurityCatalogReader(ql, createSecurityCatalogReader({ registry: (ql as any).registry, metadata: metadata as any })); + } catch (e) { + ctx.logger.warn( + '[security] the security catalog read was NOT bound to the ObjectQL engine: ' + + `${(e as Error).message} Every permission set and every position-bound set resolves to nothing ` + + 'until the engine exposes its registry and the metadata service is registered.', ); } @@ -7128,8 +7167,9 @@ export class SecurityPlugin implements Plugin { * and app-showcase's `finance` / `legal`) are all of that kind. ⛔ A * false positive on a built-in identity is the most expensive failure * this warning has, and this clause is what prevents it; - * 2. `P` was NOT already requested through the governed channel. Junction - * rows arrive as permission-set NAMES in `context.permissions` + * 2. `P` was NOT already requested through the governed channel. The sets + * a position's definition names (`permissionSets`, ADR-0131 D3/D4) + * arrive as permission-set NAMES in `context.permissions` * (`resolve-authz-context.ts` §6b pushes `ps.name`), as do directly * assigned sets. Either way the grant does not depend on the fold, so * there is nothing ungoverned to report; @@ -7180,11 +7220,10 @@ export class SecurityPlugin implements Plugin { // who can resolve it is already looking. this.logger.warn?.( `[security] ${POSITION_NAME_FOLD_EVENT}: permission set '${position}' was granted ` + - `because a POSITION of the same name resolved by name — there is no ` + - `sys_position_permission_set row binding them. This grant is in force and ungoverned: ` + - `it appears in no junction table an operator can inspect. Bind it explicitly ` + - `(sys_position_permission_set: position '${position}' -> permission set '${position}') ` + - `or rename one of the two.`, + `because a POSITION of the same name resolved by name — the position's definition ` + + `does not name it in its permissionSets. This grant is in force and ungoverned: ` + + `no position declares it. Declare it explicitly (position '${position}': ` + + `permissionSets: ['${position}']) or rename one of the two.`, { event: POSITION_NAME_FOLD_EVENT, position, diff --git a/packages/plugins/plugin-sharing/src/__tests__/security-catalog.testkit.ts b/packages/plugins/plugin-sharing/src/__tests__/security-catalog.testkit.ts new file mode 100644 index 00000000000..a4c173ebf20 --- /dev/null +++ b/packages/plugins/plugin-sharing/src/__tests__/security-catalog.testkit.ts @@ -0,0 +1,72 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Test scaffolding: bind a security catalog to a fake engine (ADR-0131 D3/D4). + * `resolveUserAuthzGrants` reads positions, their sets and the set bodies from + * the catalog the security plugin binds to its engine; a suite whose engine is + * a double binds one here. A row alone grants nothing: what a fixture grants + * must be declared. {@link catalogFromTables} declares a row-shaped fixture's + * catalog, its junction rows through `convertPositionBindingRows` — the + * conversion the upgrade ceremony applies. + */ + +import { bindSecurityCatalogReader, convertPositionBindingRows, createSecurityCatalogReader } from '@objectstack/core'; + +type Definition = Record; + +/** The definitions a suite declares, per catalog type. */ +export interface TestSecurityCatalog { + positions?: readonly Definition[]; + permissions?: readonly Definition[]; +} + +/** Bind a catalog over these definitions (read afresh per lookup when given a function); returns `engine`. */ +export function bindTestSecurityCatalog(engine: T, catalog: TestSecurityCatalog | (() => TestSecurityCatalog)): T { + const items = (type: string): readonly Definition[] => { + const c = typeof catalog === 'function' ? catalog() : catalog; + return (type === 'position' ? c.positions : type === 'permission' ? c.permissions : undefined) ?? []; + }; + bindSecurityCatalogReader(engine, createSecurityCatalogReader({ + registry: { getItem: (type, name) => items(type).find((d) => d.name === name), listItems: items, isPackageDisabled: () => false }, + metadata: { get: () => undefined, list: () => [] }, + })); + return engine; +} + +const parsed = (value: unknown): unknown => { + if (typeof value !== 'string') return value; + try { + return JSON.parse(value); + } catch { + return undefined; + } +}; + +/** The definitions a fixture's catalog rows convert to (first set row of a name wins; a `conflicting` position names no set). */ +export function catalogFromTables(tables: Record): TestSecurityCatalog { + const setRows = (tables.sys_permission_set ?? []) as Definition[]; + const permissions: Definition[] = []; + for (const row of setRows) { + if (typeof row.name !== 'string' || permissions.some((d) => d.name === row.name)) continue; + const definition: Definition = { name: row.name }; + for (const [column, key] of [['system_permissions', 'systemPermissions'], ['tab_permissions', 'tabPermissions'], ['object_permissions', 'objects']] as const) { + const value = parsed(row[column] ?? row[key]); + if (value !== undefined && value !== null) definition[key] = value; + } + permissions.push(definition); + } + const { positions } = convertPositionBindingRows({ + positions: (tables.sys_position ?? []) as Definition[], + permissionSets: setRows, + bindings: (tables.sys_position_permission_set ?? []) as Definition[], + }); + return { + permissions, + positions: positions.flatMap((f) => (f.fate === 'conflicting' ? [] : [{ name: f.name, permissionSets: [...f.permissionSets] }])), + }; +} + +/** Bind the catalog `tables` convert to, re-derived per lookup so a row written later is in it. */ +export function bindCatalogFromTables(engine: T, tables: Record): T { + return bindTestSecurityCatalog(engine, () => catalogFromTables(tables)); +} diff --git a/packages/plugins/plugin-sharing/src/sharing-rule-positions-name-authority.test.ts b/packages/plugins/plugin-sharing/src/sharing-rule-positions-name-authority.test.ts index 53dcdfa41bd..fe3ca93f7eb 100644 --- a/packages/plugins/plugin-sharing/src/sharing-rule-positions-name-authority.test.ts +++ b/packages/plugins/plugin-sharing/src/sharing-rule-positions-name-authority.test.ts @@ -56,6 +56,7 @@ import { ADMIN_FULL_ACCESS } from '@objectstack/spec/identity'; import type { ExecutionContext } from '@objectstack/spec/kernel'; import { hasPlatformAdminStanding, resolveUserAuthzGrants } from '@objectstack/core'; import { SharingRuleService } from './sharing-rule-service.js'; +import { bindCatalogFromTables } from './__tests__/security-catalog.testkit.js'; const USER = 'usr_subject'; const OTHER_ORG = 'org_victim'; @@ -79,12 +80,12 @@ function makeAuthzQl(tables: Record>>) { if (v === null) return (row[k] ?? null) === null; return row[k] === v; }); - return { + return bindCatalogFromTables({ async find(object: string, opts: any) { const rows = (tables[object] ?? []).filter((r) => matches(r, opts?.where)); return typeof opts?.limit === 'number' ? rows.slice(0, opts.limit) : rows; }, - }; + }, tables); } /** The permission set that carries the ORG-scoped `manage_sharing` capability. */ diff --git a/packages/qa/dogfood/test/me-apps-and-everyone-baseline.dogfood.test.ts b/packages/qa/dogfood/test/me-apps-and-everyone-baseline.dogfood.test.ts index 5cc137795b6..4da3a10c1e7 100644 --- a/packages/qa/dogfood/test/me-apps-and-everyone-baseline.dogfood.test.ts +++ b/packages/qa/dogfood/test/me-apps-and-everyone-baseline.dogfood.test.ts @@ -109,16 +109,16 @@ describe('ADR-0090 D5 closures: /me/apps + anchor-bindable baseline', () => { expect(beforeNames, 'control: visible before any tab grant').toContain('showcase_app'); // Author the property: a set whose ONLY job is tabPermissions.hidden. - const hideSet = await ql.insert( - 'sys_permission_set', - { + // [ADR-0131 D3/D4] Authored through the data door, as Setup authors it: the + // write-through stores the definition the security catalog resolves. A + // system-context row insert has no definition and grants nothing. + const hideSet = await stack.apiAs(adminTok, 'POST', '/data/sys_permission_set', { name: 'tab_probe_hide', label: 'Tab probe — hide showcase', tab_permissions: JSON.stringify({ showcase_app: 'hidden' }), - }, - { context: SYS }, - ); - const hideSetId = hideSet?.id ?? (await ql.findOne('sys_permission_set', { where: { name: 'tab_probe_hide' }, context: SYS }))?.id; + }); + expect(hideSet.status, 'set authored through the data door').toBeLessThan(300); + const hideSetId = (await ql.findOne('sys_permission_set', { where: { name: 'tab_probe_hide' }, context: SYS }))?.id; expect(hideSetId, 'hide set stored').toBeTruthy(); await ql.insert('sys_user_permission_set', { user_id: tabUser.id, permission_set_id: hideSetId }, { context: SYS }); @@ -128,16 +128,13 @@ describe('ADR-0090 D5 closures: /me/apps + anchor-bindable baseline', () => { // Rank merge: hidden(0) loses to visible(3) from ANY other resolved set — // most-visible wins across the principal's sets, matching hono's tabRank. - const showSet = await ql.insert( - 'sys_permission_set', - { + const showSet = await stack.apiAs(adminTok, 'POST', '/data/sys_permission_set', { name: 'tab_probe_show', label: 'Tab probe — show showcase', tab_permissions: JSON.stringify({ showcase_app: 'visible' }), - }, - { context: SYS }, - ); - const showSetId = showSet?.id ?? (await ql.findOne('sys_permission_set', { where: { name: 'tab_probe_show' }, context: SYS }))?.id; + }); + expect(showSet.status, 'set authored through the data door').toBeLessThan(300); + const showSetId = (await ql.findOne('sys_permission_set', { where: { name: 'tab_probe_show' }, context: SYS }))?.id; await ql.insert('sys_user_permission_set', { user_id: tabUser.id, permission_set_id: showSetId }, { context: SYS }); const merged = await stack.apiAs(tabTok, 'GET', '/me/apps'); @@ -177,19 +174,15 @@ describe('ADR-0090 D5 closures: /me/apps + anchor-bindable baseline', () => { // some wildcard happened to grant create. const memberUser = await ql.findOne('sys_user', { where: { email: 'baseline-member@verify.test' }, context: SYS }); expect(memberUser?.id, 'baseline member resolved').toBeTruthy(); - const inquirySet = await ql.insert( - 'sys_permission_set', - { + const inquirySet = await stack.apiAs(adminTok, 'POST', '/data/sys_permission_set', { name: 'baseline_inquiry_probe', label: 'Baseline probe — inquiry create, deliberately no delete', object_permissions: JSON.stringify({ showcase_inquiry: { allowRead: true, allowCreate: true }, }), - }, - { context: SYS }, - ); - const inquirySetId = inquirySet?.id - ?? (await ql.findOne('sys_permission_set', { where: { name: 'baseline_inquiry_probe' }, context: SYS }))?.id; + }); + expect(inquirySet.status, 'set authored through the data door').toBeLessThan(300); + const inquirySetId = (await ql.findOne('sys_permission_set', { where: { name: 'baseline_inquiry_probe' }, context: SYS }))?.id; expect(inquirySetId, 'probe set stored').toBeTruthy(); await ql.insert( 'sys_user_permission_set', diff --git a/packages/qa/dogfood/test/showcase-fls-read-mask-strip.dogfood.test.ts b/packages/qa/dogfood/test/showcase-fls-read-mask-strip.dogfood.test.ts index 4de8520e3a3..fa73b5dee91 100644 --- a/packages/qa/dogfood/test/showcase-fls-read-mask-strip.dogfood.test.ts +++ b/packages/qa/dogfood/test/showcase-fls-read-mask-strip.dogfood.test.ts @@ -112,13 +112,14 @@ describe('showcase FLS read side: a readable:false field is STRIPPED, not masked // `showcase_project`, so this set contributes nothing but the field deny — // which is what makes the assertions below attributable to FLS alone. // - // `active: true` is load-bearing: the security plugin's DB loader filters - // deactivated rows out (`isRowActive`), and an inactive set grants nothing, - // including nothing to deny — the field would come back readable and every - // assertion here would fail as if the strip were broken. - await ql.insert( - 'sys_permission_set', - { + // A deactivated set grants nothing, including nothing to deny: the field + // would come back readable and every assertion here would fail as if the + // strip were broken. Deactivation is the activation ledger's (ADR-0131 D3); + // this set has no ledger row, so it is in effect. + // [ADR-0131 D3/D4] Authored through the data door, as Setup authors it: the + // write-through stores the definition the security catalog resolves. A + // system-context row insert has no definition and grants nothing. + const authored = await stack.apiAs(adminTok, 'POST', '/data/sys_permission_set', { name: SCRATCH_SET, label: 'Scratch: FLS read deny on showcase_project.budget', description: @@ -132,9 +133,8 @@ describe('showcase FLS read side: a readable:false field is STRIPPED, not masked }), system_permissions: JSON.stringify([]), active: true, - }, - { context: SYS }, - ); + }); + expect(authored.status, 'scratch set authored through the data door').toBeLessThan(300); const ps = await ql.findOne('sys_permission_set', { where: { name: SCRATCH_SET }, context: SYS }); expect(ps?.id, 'scratch permission set authored').toBeTruthy(); const memberId = (await ql.findOne('sys_user', { where: { email: MEMBER }, context: SYS }))?.id; diff --git a/packages/rest/src/__tests__/security-catalog.testkit.ts b/packages/rest/src/__tests__/security-catalog.testkit.ts new file mode 100644 index 00000000000..a4c173ebf20 --- /dev/null +++ b/packages/rest/src/__tests__/security-catalog.testkit.ts @@ -0,0 +1,72 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Test scaffolding: bind a security catalog to a fake engine (ADR-0131 D3/D4). + * `resolveUserAuthzGrants` reads positions, their sets and the set bodies from + * the catalog the security plugin binds to its engine; a suite whose engine is + * a double binds one here. A row alone grants nothing: what a fixture grants + * must be declared. {@link catalogFromTables} declares a row-shaped fixture's + * catalog, its junction rows through `convertPositionBindingRows` — the + * conversion the upgrade ceremony applies. + */ + +import { bindSecurityCatalogReader, convertPositionBindingRows, createSecurityCatalogReader } from '@objectstack/core'; + +type Definition = Record; + +/** The definitions a suite declares, per catalog type. */ +export interface TestSecurityCatalog { + positions?: readonly Definition[]; + permissions?: readonly Definition[]; +} + +/** Bind a catalog over these definitions (read afresh per lookup when given a function); returns `engine`. */ +export function bindTestSecurityCatalog(engine: T, catalog: TestSecurityCatalog | (() => TestSecurityCatalog)): T { + const items = (type: string): readonly Definition[] => { + const c = typeof catalog === 'function' ? catalog() : catalog; + return (type === 'position' ? c.positions : type === 'permission' ? c.permissions : undefined) ?? []; + }; + bindSecurityCatalogReader(engine, createSecurityCatalogReader({ + registry: { getItem: (type, name) => items(type).find((d) => d.name === name), listItems: items, isPackageDisabled: () => false }, + metadata: { get: () => undefined, list: () => [] }, + })); + return engine; +} + +const parsed = (value: unknown): unknown => { + if (typeof value !== 'string') return value; + try { + return JSON.parse(value); + } catch { + return undefined; + } +}; + +/** The definitions a fixture's catalog rows convert to (first set row of a name wins; a `conflicting` position names no set). */ +export function catalogFromTables(tables: Record): TestSecurityCatalog { + const setRows = (tables.sys_permission_set ?? []) as Definition[]; + const permissions: Definition[] = []; + for (const row of setRows) { + if (typeof row.name !== 'string' || permissions.some((d) => d.name === row.name)) continue; + const definition: Definition = { name: row.name }; + for (const [column, key] of [['system_permissions', 'systemPermissions'], ['tab_permissions', 'tabPermissions'], ['object_permissions', 'objects']] as const) { + const value = parsed(row[column] ?? row[key]); + if (value !== undefined && value !== null) definition[key] = value; + } + permissions.push(definition); + } + const { positions } = convertPositionBindingRows({ + positions: (tables.sys_position ?? []) as Definition[], + permissionSets: setRows, + bindings: (tables.sys_position_permission_set ?? []) as Definition[], + }); + return { + permissions, + positions: positions.flatMap((f) => (f.fate === 'conflicting' ? [] : [{ name: f.name, permissionSets: [...f.permissionSets] }])), + }; +} + +/** Bind the catalog `tables` convert to, re-derived per lookup so a row written later is in it. */ +export function bindCatalogFromTables(engine: T, tables: Record): T { + return bindTestSecurityCatalog(engine, () => catalogFromTables(tables)); +} diff --git a/packages/rest/src/execctx-authz-input-seam-reachability.test.ts b/packages/rest/src/execctx-authz-input-seam-reachability.test.ts index e14d7009334..0ba8b5c493f 100644 --- a/packages/rest/src/execctx-authz-input-seam-reachability.test.ts +++ b/packages/rest/src/execctx-authz-input-seam-reachability.test.ts @@ -102,6 +102,7 @@ import { import type { RouteHandler } from '@objectstack/spec/contracts'; import { registerPackageRoutes } from './package-routes.js'; import { RestServer } from './rest-server.js'; +import { bindCatalogFromTables } from './__tests__/security-catalog.testkit.js'; // [#17865] This file observes the REST fault log, so it declares the level it // asserts against instead of inheriting the suite's quiet one. 'info' is the @@ -434,12 +435,12 @@ function qlWith(opts: { memberships: Array<{ user_id: string; organization_id: s { id: 'ps_pkg', name: 'pkg_admin', system_permissions: ['manage_metadata', 'studio.access'] }, ], }; - return { + return bindCatalogFromTables({ find: async (object: string, q: any = {}) => { const rows = (tables[object] ?? []).filter((row: any) => matchesWhere(row, q?.where)); return typeof q?.limit === 'number' ? rows.slice(0, q.limit) : rows; }, - }; + }, tables); } /** A real kernel, as `kernelManager.getOrCreate` would hand back. */ diff --git a/packages/rest/src/external-datasource-routes-auth-guard.test.ts b/packages/rest/src/external-datasource-routes-auth-guard.test.ts index 591def599a2..d7010df8083 100644 --- a/packages/rest/src/external-datasource-routes-auth-guard.test.ts +++ b/packages/rest/src/external-datasource-routes-auth-guard.test.ts @@ -82,6 +82,7 @@ import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protoco // `direct-mount-introspection.test.ts`): under `moduleResolution: nodenext` an // extension-less one does not resolve and every symbol it names becomes `any`. import { createRestApiPlugin } from './rest-api-plugin.js'; +import { bindTestSecurityCatalog } from './__tests__/security-catalog.testkit.js'; const BASE = '/api/v1'; const DS = 'pg_main'; @@ -263,6 +264,9 @@ async function bootFederation( }, }; + // [ADR-0131 D3/D4] The set's definition, which the resolver reads its body from. + bindTestSecurityCatalog(engine, { permissions: [{ name: 'federation_caller', systemPermissions: [...grants] }] }); + const services: Record = { 'http.server': server, protocol: makeProtocol(), diff --git a/packages/rest/src/meta-type-read-capability.test.ts b/packages/rest/src/meta-type-read-capability.test.ts index b806d3c188f..7877b481885 100644 --- a/packages/rest/src/meta-type-read-capability.test.ts +++ b/packages/rest/src/meta-type-read-capability.test.ts @@ -30,6 +30,7 @@ import { PLATFORM_CAPABILITY_NAMES } from '@objectstack/spec/security'; // Explicit `.js` extension: NodeNext resolution (see the sibling meta tests). import { RestServer } from './rest-server.js'; import { META_TYPE_READ_CAPABILITIES, metaTypeReadRefusal } from './meta-item-read-gate.js'; +import { bindCatalogFromTables } from './__tests__/security-catalog.testkit.js'; // ── Fixtures ────────────────────────────────────────────────────────────────── @@ -357,7 +358,7 @@ function bounded(rows: T[], opts: any): T[] { return typeof opts?.limit === 'number' ? rows.slice(0, opts.limit) : rows; } -const grantsEngine = { +const grantsEngine = bindCatalogFromTables({ async find(object: string, opts: any) { const rows = GRANT_TABLES[object] ?? []; const where = opts?.where ?? {}; @@ -374,7 +375,7 @@ const grantsEngine = { opts, ); }, -}; +}, GRANT_TABLES); const authService = { api: { diff --git a/packages/rest/src/meta-type-write-capability.test.ts b/packages/rest/src/meta-type-write-capability.test.ts index 8af0220ae69..19460cb3915 100644 --- a/packages/rest/src/meta-type-write-capability.test.ts +++ b/packages/rest/src/meta-type-write-capability.test.ts @@ -37,6 +37,7 @@ import { metaTypeWriteRefusal, } from './meta-item-read-gate.js'; import { FEDERATION_WRITE_CAPABILITY } from './external-datasource-routes.js'; +import { bindCatalogFromTables } from './__tests__/security-catalog.testkit.js'; // ── Fixtures ────────────────────────────────────────────────────────────────── @@ -314,7 +315,7 @@ const GRANT_TABLES: Record = { })), }; -const grantsEngine = { +const grantsEngine = bindCatalogFromTables({ async find(object: string, opts: any) { const rows = GRANT_TABLES[object] ?? []; const where = opts?.where ?? {}; @@ -329,7 +330,7 @@ const grantsEngine = { ); return typeof opts?.limit === 'number' ? matched.slice(0, opts.limit) : matched; }, -}; +}, GRANT_TABLES); const authService = { api: { diff --git a/packages/rest/src/package-door-execctx-fault-reachability.test.ts b/packages/rest/src/package-door-execctx-fault-reachability.test.ts index 456e87a5a4a..881ba6839f6 100644 --- a/packages/rest/src/package-door-execctx-fault-reachability.test.ts +++ b/packages/rest/src/package-door-execctx-fault-reachability.test.ts @@ -150,6 +150,7 @@ import { import type { RouteHandler } from '@objectstack/spec/contracts'; import { registerPackageRoutes } from './package-routes.js'; import { RestServer } from './rest-server.js'; +import { bindTestSecurityCatalog } from './__tests__/security-catalog.testkit.js'; const PKGS = '/api/v1/packages'; /** @@ -257,15 +258,13 @@ const AUTH_OK = async () => ({ api: { getSession: async () => ({ user: { id: 'u_ * the SHIPPED aggregation (`sys_user_permission_set` → `sys_permission_set` * → `system_permissions`) rather than by handing the door a ready-made context. */ -const qlHealthy = () => ({ +const qlHealthy = () => bindTestSecurityCatalog({ find: async (object: string) => { if (object === 'sys_user_permission_set') return [{ permission_set_id: 'ps_pkg', permission_set: 'pkg_admin' }]; - if (object === 'sys_permission_set') { - return [{ id: 'ps_pkg', name: 'pkg_admin', system_permissions: ['manage_metadata', 'studio.access'] }]; - } + if (object === 'sys_permission_set') return [{ id: 'ps_pkg', name: 'pkg_admin' }]; return []; }, -}); +}, { permissions: [{ name: 'pkg_admin', systemPermissions: ['manage_metadata', 'studio.access'] }] }); /** The same store, unreachable — every read throws, as a driver outage does. */ const qlDown = () => ({ find: async () => { throw new Error('permission store unreachable'); } }); diff --git a/packages/rest/src/remote-tables-twin.equivalence.test.ts b/packages/rest/src/remote-tables-twin.equivalence.test.ts index 54d5e4e02d1..e3bc01b2ff0 100644 --- a/packages/rest/src/remote-tables-twin.equivalence.test.ts +++ b/packages/rest/src/remote-tables-twin.equivalence.test.ts @@ -59,6 +59,7 @@ import { } from '@objectstack/service-datasource'; import type { IntrospectedColumn, IntrospectedSchema } from '@objectstack/spec/contracts'; import { registerExternalDatasourceRoutes } from './external-datasource-routes.js'; +import { bindTestSecurityCatalog } from './__tests__/security-catalog.testkit.js'; const DS = 'demo_ext'; @@ -186,7 +187,7 @@ const HOLDS: Record = { // `u_twin_plain` holds nothing, deliberately — it is not listed. }; -const makeQl = () => ({ +const makeQl = () => bindTestSecurityCatalog({ find: async (object: string, opts: any) => { const where = opts?.where ?? {}; if (object === 'sys_user_permission_set') { @@ -216,7 +217,7 @@ const makeQl = () => ({ } return []; }, -}); +}, { permissions: Object.values(SETS) }); /** * One server, one service, both registrars — the point of the fixture. diff --git a/packages/rest/src/rest-api-plugin-objectql-provider-three-state.test.ts b/packages/rest/src/rest-api-plugin-objectql-provider-three-state.test.ts index 0b39a44876e..cafc1489668 100644 --- a/packages/rest/src/rest-api-plugin-objectql-provider-three-state.test.ts +++ b/packages/rest/src/rest-api-plugin-objectql-provider-three-state.test.ts @@ -69,6 +69,7 @@ import { import type { PluginContext } from '@objectstack/core'; import type { RouteHandler } from '@objectstack/spec/contracts'; import { registerPackageRoutes } from './package-routes.js'; +import { bindTestSecurityCatalog } from './__tests__/security-catalog.testkit.js'; const captured = vi.hoisted(() => ({ ctorArgs: [] as unknown[][] })); @@ -257,7 +258,7 @@ const AUTH_OK = async () => ({ api: { getSession: async () => ({ user: { id: 'u_ * `system_permissions`) — the healthy fixture * `package-door-execctx-fault-reachability.test.ts` established. */ -const engineGranting = () => ({ +const engineGranting = () => bindTestSecurityCatalog({ find: async (object: string) => { if (object === 'sys_user_permission_set') return [{ permission_set_id: 'ps_pkg', permission_set: 'pkg_admin' }]; if (object === 'sys_permission_set') { @@ -265,7 +266,7 @@ const engineGranting = () => ({ } return []; }, -}); +}, { permissions: [{ name: 'pkg_admin', systemPermissions: ['manage_metadata', 'studio.access'] }] }); /** A `RestServer` wired at its constructor seams — nothing private replaced. */ function serverWith(objectQLProvider: EngineProvider | undefined): InstanceType { diff --git a/packages/rest/src/rest-exec-ctx-principal-kind.test.ts b/packages/rest/src/rest-exec-ctx-principal-kind.test.ts index 6a1bcfec68c..79b1461df97 100644 --- a/packages/rest/src/rest-exec-ctx-principal-kind.test.ts +++ b/packages/rest/src/rest-exec-ctx-principal-kind.test.ts @@ -24,6 +24,7 @@ import { describe, it, expect, vi } from 'vitest'; import { hashApiKey } from '@objectstack/core'; import { runWithPerfDisclosure, type PerfDisclosureGate } from '@objectstack/observability'; import { RestServer } from './rest-server'; +import { bindTestSecurityCatalog } from './__tests__/security-catalog.testkit.js'; const TASK = { name: 'task', @@ -46,7 +47,7 @@ const RAW_API_KEY = 'osk_6071_rest_face'; * `admin_full_access` grant for `admin1` (→ PLATFORM_ADMIN). Everything else * resolves empty. */ -const makeQl = () => ({ +const makeQl = () => bindTestSecurityCatalog({ find: async (object: string, opts: any) => { const where = opts?.where ?? {}; if (object === 'sys_api_key') { @@ -70,7 +71,7 @@ const makeQl = () => ({ } return []; }, -}); +}, { permissions: [{ name: 'admin_full_access' }, { name: 'member_default' }] }); /** A fake auth service whose session is keyed off the request's `cookie` header. */ const makeAuth = () => ({ diff --git a/packages/rest/src/rest-server-timing.test.ts b/packages/rest/src/rest-server-timing.test.ts index f06390d79fb..e5d020bfe84 100644 --- a/packages/rest/src/rest-server-timing.test.ts +++ b/packages/rest/src/rest-server-timing.test.ts @@ -15,6 +15,7 @@ import { describe, it, expect, vi } from 'vitest'; import { RestServer } from './rest-server'; import { runWithPerfDisclosure, type PerfDisclosureGate } from '@objectstack/observability'; +import { bindTestSecurityCatalog } from './__tests__/security-catalog.testkit.js'; const makeServer = () => ({ get: vi.fn(), post: vi.fn(), put: vi.fn(), delete: vi.fn(), patch: vi.fn(), @@ -27,7 +28,7 @@ const makeServer = () => ({ * PLATFORM_ADMIN) and a plain `member_default` grant for `member1` (→ MEMBER). * Every other object read resolves empty. */ -const makeQl = () => ({ +const makeQl = () => bindTestSecurityCatalog({ find: async (object: string, opts: any) => { const where = opts?.where ?? {}; if (object === 'sys_user') return [{ id: where.id, email: `${where.id}@example.com` }]; @@ -49,7 +50,7 @@ const makeQl = () => ({ // sys_setting (localization) — nothing to contribute. return []; }, -}); +}, { permissions: [{ name: 'admin_full_access' }, { name: 'member_default' }] }); /** A fake auth service whose session is keyed off the request's `cookie` header. */ const makeAuth = () => ({ diff --git a/packages/runtime/src/domains/activation-gate-positions-name-authority.test.ts b/packages/runtime/src/domains/activation-gate-positions-name-authority.test.ts index 8ef1f3b9d35..6ca5b702e41 100644 --- a/packages/runtime/src/domains/activation-gate-positions-name-authority.test.ts +++ b/packages/runtime/src/domains/activation-gate-positions-name-authority.test.ts @@ -50,6 +50,7 @@ import { hasPlatformAdminStanding, resolveUserAuthzGrants } from '@objectstack/c import { HttpDispatcher } from '../http-dispatcher.js'; import type { HttpProtocolContext } from '../http-dispatcher.js'; +import { bindCatalogFromTables } from '../security/security-catalog.testkit.js'; const FLOW = 'vendor_lead_router'; const DEFINITION = { name: FLOW, label: 'Vendor Lead Router', type: 'autolaunched', nodes: [], edges: [] }; @@ -97,12 +98,12 @@ function makeAuthzQl(tables: Record>>) { if (v === null) return (row[k] ?? null) === null; return row[k] === v; }); - return { + return bindCatalogFromTables({ async find(object: string, opts: any) { const rows = (tables[object] ?? []).filter((r) => matches(r, opts?.where)); return typeof opts?.limit === 'number' ? rows.slice(0, opts.limit) : rows; }, - }; + }, tables); } type Shape = 'name-only' | 'genuine' | 'plain'; diff --git a/packages/runtime/src/domains/automation-run-lifecycle-deny-remedy.test.ts b/packages/runtime/src/domains/automation-run-lifecycle-deny-remedy.test.ts index d10115e3ce3..28545421823 100644 --- a/packages/runtime/src/domains/automation-run-lifecycle-deny-remedy.test.ts +++ b/packages/runtime/src/domains/automation-run-lifecycle-deny-remedy.test.ts @@ -45,6 +45,7 @@ import { PLATFORM_OWNER_EMAIL_ENV, resolveTenancyPosture } from '@objectstack/ty import { HttpDispatcher } from '../http-dispatcher.js'; import type { HttpProtocolContext } from '../http-dispatcher.js'; import { resolveExecutionContext } from '../security/resolve-execution-context.js'; +import { bindCatalogFromTables } from '../security/security-catalog.testkit.js'; /** * The environment inputs the derivation reads: the two posture variables in @@ -77,12 +78,12 @@ function makeQl(tables: Tables) { if (v === null) return (row[k] ?? null) === null; return row[k] === v; }); - return { + return bindCatalogFromTables({ async find(object: string, opts: any) { const rows = (tables[object] ?? []).filter((r) => matches(r, opts?.where)); return typeof opts?.limit === 'number' ? rows.slice(0, opts.limit) : rows; }, - }; + }, tables); } /** A verified `sys_user` row and nothing else — no membership, position or grant. */ diff --git a/packages/runtime/src/domains/meta-read-org-scope-parity.test.ts b/packages/runtime/src/domains/meta-read-org-scope-parity.test.ts index eb55bc153b0..ffc7348cc42 100644 --- a/packages/runtime/src/domains/meta-read-org-scope-parity.test.ts +++ b/packages/runtime/src/domains/meta-read-org-scope-parity.test.ts @@ -49,6 +49,7 @@ import { DEFAULT_METADATA_TYPE_REGISTRY } from '@objectstack/spec/kernel'; import { canonicalMetaUrlType } from '@objectstack/spec/shared'; import { RestServer } from '@objectstack/rest'; import { HttpDispatcher } from '../http-dispatcher.js'; +import { bindCatalogFromTables } from '../security/security-catalog.testkit.js'; // ── The metadata store: env-wide rows and each organization's overlays ──────── @@ -170,12 +171,12 @@ function makeQl() { if (cond === null) return (row[field] ?? null) === null; return row[field] === cond; }); - return { + return bindCatalogFromTables({ find: async (object: string, q: any = {}) => { const rows = (tables[object] ?? []).filter((row: any) => matches(row, q?.where)); return typeof q?.limit === 'number' ? rows.slice(0, q.limit) : rows; }, - }; + }, tables); } interface SessionRow { id: string; token: string; userId: string; activeOrganizationId: string | null } diff --git a/packages/runtime/src/domains/packages-orgless-grants-capability-gate.test.ts b/packages/runtime/src/domains/packages-orgless-grants-capability-gate.test.ts index dcefec280a7..627f58a6bcb 100644 --- a/packages/runtime/src/domains/packages-orgless-grants-capability-gate.test.ts +++ b/packages/runtime/src/domains/packages-orgless-grants-capability-gate.test.ts @@ -45,6 +45,7 @@ import { join } from 'node:path'; import { describe, it, expect, vi, beforeAll, afterAll, beforeEach, afterEach } from 'vitest'; import { SchemaRegistry } from '@objectstack/objectql'; import { HttpDispatcher } from '../http-dispatcher.js'; +import { bindCatalogFromTables } from '../security/security-catalog.testkit.js'; const PKG = 'com.acme.crm'; const ALPHA = 'org_alpha'; @@ -148,13 +149,13 @@ function rig() { }, }; const services: Record = { - objectql: { + objectql: bindCatalogFromTables({ registry, find: async (object: string, q: any = {}) => { const found = (TABLES[object] ?? []).filter((row: any) => matchesWhere(row, q?.where)); return typeof q?.limit === 'number' ? found.slice(0, q.limit) : found; }, - }, + }, TABLES), auth: { api: { getSession: async ({ headers }: any) => { @@ -260,11 +261,14 @@ describe('[#20515] what the rule leaves unchanged', () => { expect(switchedOff(r)).toBe(true); }); - it('CONTROL · the position-bound set: a current org_alpha member with org_alpha active passes: 200', async () => { + // [ADR-0131 D3/D4] A position's sets are the ones its catalog definition + // names; org_alpha's junction row on ITS copy of `org_member` binds nothing + // — for its current members as for the removed one (#15196 Q3 A). + it('the organization-scoped junction row binds nothing: a current org_alpha member holding org_member is refused: 403', async () => { const r = rig(); const answer = await r.call('DELETE', 'posmember', `/packages/${PKG}`); - expect(answer.status).toBe(200); - expect(r.deleteRequests).toEqual([{ packageId: PKG }]); + expect(answer.status).toBe(403); + expect(r.deleteRequests).toEqual([]); }); it('a GLOBAL grant still passes the gate for the removed member — the uninstall proceeds, naming no organization', async () => { diff --git a/packages/runtime/src/domains/packages-read-delete-response-conformance.test.ts b/packages/runtime/src/domains/packages-read-delete-response-conformance.test.ts index 7976e40b5ee..82debf602e4 100644 --- a/packages/runtime/src/domains/packages-read-delete-response-conformance.test.ts +++ b/packages/runtime/src/domains/packages-read-delete-response-conformance.test.ts @@ -88,6 +88,7 @@ import { } from '@objectstack/spec/api-assembled'; import { InstalledPackageSchema } from '@objectstack/spec/kernel'; import { HttpDispatcher, type HttpDispatcherResult } from '../http-dispatcher.js'; +import { bindCatalogFromTables } from '../security/security-catalog.testkit.js'; const PREFIX = '/api/v1'; @@ -168,13 +169,13 @@ function dispatcher(manifests: any[], protocol?: unknown): HttpDispatcher { (registry as any).logLevel = 'silent'; for (const m of manifests) registry.installPackage(m as any); - const ql = { + const ql = bindCatalogFromTables({ registry, find: async (object: string, q: any = {}) => { const rows = (TABLES[object] ?? []).filter((row: any) => matchesWhere(row, q?.where)); return typeof q?.limit === 'number' ? rows.slice(0, q.limit) : rows; }, - }; + }, TABLES); const auth = { api: { getSession: async () => ({ user: { id: 'u_admin' }, session: { activeOrganizationId: ORG } }) } }; const services: Record = { objectql: ql, auth, ...(protocol ? { protocol } : {}) }; return new HttpDispatcher({ diff --git a/packages/runtime/src/domains/packages-single-door.test.ts b/packages/runtime/src/domains/packages-single-door.test.ts index 7f78c710856..7407a2a06cd 100644 --- a/packages/runtime/src/domains/packages-single-door.test.ts +++ b/packages/runtime/src/domains/packages-single-door.test.ts @@ -60,6 +60,7 @@ import { describe, it, expect } from 'vitest'; import { SchemaRegistry } from '@objectstack/objectql'; import { HttpDispatcher, type HttpDispatcherResult } from '../http-dispatcher.js'; +import { bindCatalogFromTables } from '../security/security-catalog.testkit.js'; const PREFIX = '/api/v1'; const ENV_ID = 'env_alpha'; @@ -130,13 +131,13 @@ function registryWith(): SchemaRegistry { } function kernelWith(registry: SchemaRegistry): any { - const ql = { + const ql = bindCatalogFromTables({ registry, find: async (object: string, q: any = {}) => { const rows = (TABLES[object] ?? []).filter((row: any) => matchesWhere(row, q?.where)); return typeof q?.limit === 'number' ? rows.slice(0, q.limit) : rows; }, - }; + }, TABLES); const auth = { api: { getSession: async () => ({ user: { id: 'u_admin' } }) } }; const services: Record = { objectql: ql, auth }; return { diff --git a/packages/runtime/src/domains/packages-uninstall-refuse-before-mutate.test.ts b/packages/runtime/src/domains/packages-uninstall-refuse-before-mutate.test.ts index 78faea3e18c..a5dce100370 100644 --- a/packages/runtime/src/domains/packages-uninstall-refuse-before-mutate.test.ts +++ b/packages/runtime/src/domains/packages-uninstall-refuse-before-mutate.test.ts @@ -46,6 +46,7 @@ import { join } from 'node:path'; import { describe, it, expect, vi, beforeAll, afterAll, beforeEach, afterEach } from 'vitest'; import { SchemaRegistry } from '@objectstack/objectql'; import { HttpDispatcher } from '../http-dispatcher.js'; +import { bindCatalogFromTables } from '../security/security-catalog.testkit.js'; const PKG = 'com.acme.crm'; const ALPHA = 'org_alpha'; @@ -156,13 +157,13 @@ function rig(opts: { persistedHalf?: boolean } = {}): Rig { }; const services: Record = { - objectql: { + objectql: bindCatalogFromTables({ registry, find: async (object: string, q: any = {}) => { const found = (TABLES[object] ?? []).filter((row: any) => matchesWhere(row, q?.where)); return typeof q?.limit === 'number' ? found.slice(0, q.limit) : found; }, - }, + }, TABLES), auth: authService(), tenancy: { posture: 'isolated' }, ...(opts.persistedHalf === false ? {} : { protocol }), diff --git a/packages/runtime/src/domains/packages-vetted-org-source.test.ts b/packages/runtime/src/domains/packages-vetted-org-source.test.ts index 5b515e608c9..7f0a2051851 100644 --- a/packages/runtime/src/domains/packages-vetted-org-source.test.ts +++ b/packages/runtime/src/domains/packages-vetted-org-source.test.ts @@ -62,6 +62,7 @@ import { HonoServerPlugin } from '@objectstack/plugin-hono-server'; import type { IHttpServer } from '@objectstack/spec/contracts'; import { HttpDispatcher } from '../http-dispatcher.js'; import { createDispatcherPlugin } from '../dispatcher-plugin.js'; +import { bindCatalogFromTables } from '../security/security-catalog.testkit.js'; const PREFIX = '/api/v1'; const PKG = 'com.acme.crm'; @@ -206,7 +207,7 @@ function makeQl() { return row[field] === cond; }); const pkg = { manifest: { id: PKG, name: 'CRM', version: '1.0.0', scope: 'project' } }; - return { + return bindCatalogFromTables({ find: async (object: string, q: any = {}) => { const rows = (tables[object] ?? []).filter((row: any) => matches(row, q?.where)); return typeof q?.limit === 'number' ? rows.slice(0, q.limit) : rows; @@ -219,7 +220,7 @@ function makeQl() { // subject here is the organization the protocol call carries. uninstallPackage: () => false, }, - }; + }, tables); } interface SessionRow { id: string; token: string; userId: string; activeOrganizationId: string } diff --git a/packages/runtime/src/security/resolve-execution-context.test.ts b/packages/runtime/src/security/resolve-execution-context.test.ts index 83d506e84df..41005a0f00b 100644 --- a/packages/runtime/src/security/resolve-execution-context.test.ts +++ b/packages/runtime/src/security/resolve-execution-context.test.ts @@ -6,6 +6,7 @@ import { ObjectKernel, isAuthzStoreUnavailableError, isServiceNotRegisteredError import { resolveExecutionContext } from './resolve-execution-context.js'; import { hashApiKey } from './api-key.js'; +import { bindCatalogFromTables } from './security-catalog.testkit.js'; /** * Minimal ObjectQL stub. Only `sys_api_key` is populated; every other object @@ -329,7 +330,7 @@ describe('resolveExecutionContext — platform-scoped (null-org) grants (ADR-006 sys_position_permission_set: [], sys_user_position: [], }; - return { + return bindCatalogFromTables({ async find(object, opts) { const rows = tables[object] ?? []; const where = opts?.where ?? {}; @@ -344,7 +345,7 @@ describe('resolveExecutionContext — platform-scoped (null-org) grants (ADR-006 return true; }), opts); }, - }; + }, tables); } const opts = (ql) => ({ getService: async () => undefined, getQl: async () => ql, request: { headers: { 'x-api-key': RAW } } }); @@ -505,7 +506,7 @@ describe('resolveExecutionContext — ADR-0090 D10 agent principal (OAuth on /mc sys_permission_set: [{ id: 'ps_admin', name: 'admin_full_access', system_permissions: '["manage_users","manage_platform_settings"]', object_permissions: '{}' }], sys_position: [], sys_position_permission_set: [], sys_user_position: [], }; - return { + return bindCatalogFromTables({ async find(object: string, opts: any) { return bounded((tables[object] ?? []).filter((row) => Object.entries(opts?.where ?? {}).every(([k, v]) => @@ -515,7 +516,7 @@ describe('resolveExecutionContext — ADR-0090 D10 agent principal (OAuth on /mc ), ), opts); }, - }; + }, tables); }; const agentWithQl = (scopes: string[], ql: any) => ({ acceptOAuthAccessToken: true, diff --git a/packages/runtime/src/security/security-catalog.testkit.ts b/packages/runtime/src/security/security-catalog.testkit.ts new file mode 100644 index 00000000000..a4c173ebf20 --- /dev/null +++ b/packages/runtime/src/security/security-catalog.testkit.ts @@ -0,0 +1,72 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Test scaffolding: bind a security catalog to a fake engine (ADR-0131 D3/D4). + * `resolveUserAuthzGrants` reads positions, their sets and the set bodies from + * the catalog the security plugin binds to its engine; a suite whose engine is + * a double binds one here. A row alone grants nothing: what a fixture grants + * must be declared. {@link catalogFromTables} declares a row-shaped fixture's + * catalog, its junction rows through `convertPositionBindingRows` — the + * conversion the upgrade ceremony applies. + */ + +import { bindSecurityCatalogReader, convertPositionBindingRows, createSecurityCatalogReader } from '@objectstack/core'; + +type Definition = Record; + +/** The definitions a suite declares, per catalog type. */ +export interface TestSecurityCatalog { + positions?: readonly Definition[]; + permissions?: readonly Definition[]; +} + +/** Bind a catalog over these definitions (read afresh per lookup when given a function); returns `engine`. */ +export function bindTestSecurityCatalog(engine: T, catalog: TestSecurityCatalog | (() => TestSecurityCatalog)): T { + const items = (type: string): readonly Definition[] => { + const c = typeof catalog === 'function' ? catalog() : catalog; + return (type === 'position' ? c.positions : type === 'permission' ? c.permissions : undefined) ?? []; + }; + bindSecurityCatalogReader(engine, createSecurityCatalogReader({ + registry: { getItem: (type, name) => items(type).find((d) => d.name === name), listItems: items, isPackageDisabled: () => false }, + metadata: { get: () => undefined, list: () => [] }, + })); + return engine; +} + +const parsed = (value: unknown): unknown => { + if (typeof value !== 'string') return value; + try { + return JSON.parse(value); + } catch { + return undefined; + } +}; + +/** The definitions a fixture's catalog rows convert to (first set row of a name wins; a `conflicting` position names no set). */ +export function catalogFromTables(tables: Record): TestSecurityCatalog { + const setRows = (tables.sys_permission_set ?? []) as Definition[]; + const permissions: Definition[] = []; + for (const row of setRows) { + if (typeof row.name !== 'string' || permissions.some((d) => d.name === row.name)) continue; + const definition: Definition = { name: row.name }; + for (const [column, key] of [['system_permissions', 'systemPermissions'], ['tab_permissions', 'tabPermissions'], ['object_permissions', 'objects']] as const) { + const value = parsed(row[column] ?? row[key]); + if (value !== undefined && value !== null) definition[key] = value; + } + permissions.push(definition); + } + const { positions } = convertPositionBindingRows({ + positions: (tables.sys_position ?? []) as Definition[], + permissionSets: setRows, + bindings: (tables.sys_position_permission_set ?? []) as Definition[], + }); + return { + permissions, + positions: positions.flatMap((f) => (f.fate === 'conflicting' ? [] : [{ name: f.name, permissionSets: [...f.permissionSets] }])), + }; +} + +/** Bind the catalog `tables` convert to, re-derived per lookup so a row written later is in it. */ +export function bindCatalogFromTables(engine: T, tables: Record): T { + return bindTestSecurityCatalog(engine, () => catalogFromTables(tables)); +} diff --git a/packages/services/service-automation/src/__tests__/security-catalog.testkit.ts b/packages/services/service-automation/src/__tests__/security-catalog.testkit.ts new file mode 100644 index 00000000000..a4c173ebf20 --- /dev/null +++ b/packages/services/service-automation/src/__tests__/security-catalog.testkit.ts @@ -0,0 +1,72 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Test scaffolding: bind a security catalog to a fake engine (ADR-0131 D3/D4). + * `resolveUserAuthzGrants` reads positions, their sets and the set bodies from + * the catalog the security plugin binds to its engine; a suite whose engine is + * a double binds one here. A row alone grants nothing: what a fixture grants + * must be declared. {@link catalogFromTables} declares a row-shaped fixture's + * catalog, its junction rows through `convertPositionBindingRows` — the + * conversion the upgrade ceremony applies. + */ + +import { bindSecurityCatalogReader, convertPositionBindingRows, createSecurityCatalogReader } from '@objectstack/core'; + +type Definition = Record; + +/** The definitions a suite declares, per catalog type. */ +export interface TestSecurityCatalog { + positions?: readonly Definition[]; + permissions?: readonly Definition[]; +} + +/** Bind a catalog over these definitions (read afresh per lookup when given a function); returns `engine`. */ +export function bindTestSecurityCatalog(engine: T, catalog: TestSecurityCatalog | (() => TestSecurityCatalog)): T { + const items = (type: string): readonly Definition[] => { + const c = typeof catalog === 'function' ? catalog() : catalog; + return (type === 'position' ? c.positions : type === 'permission' ? c.permissions : undefined) ?? []; + }; + bindSecurityCatalogReader(engine, createSecurityCatalogReader({ + registry: { getItem: (type, name) => items(type).find((d) => d.name === name), listItems: items, isPackageDisabled: () => false }, + metadata: { get: () => undefined, list: () => [] }, + })); + return engine; +} + +const parsed = (value: unknown): unknown => { + if (typeof value !== 'string') return value; + try { + return JSON.parse(value); + } catch { + return undefined; + } +}; + +/** The definitions a fixture's catalog rows convert to (first set row of a name wins; a `conflicting` position names no set). */ +export function catalogFromTables(tables: Record): TestSecurityCatalog { + const setRows = (tables.sys_permission_set ?? []) as Definition[]; + const permissions: Definition[] = []; + for (const row of setRows) { + if (typeof row.name !== 'string' || permissions.some((d) => d.name === row.name)) continue; + const definition: Definition = { name: row.name }; + for (const [column, key] of [['system_permissions', 'systemPermissions'], ['tab_permissions', 'tabPermissions'], ['object_permissions', 'objects']] as const) { + const value = parsed(row[column] ?? row[key]); + if (value !== undefined && value !== null) definition[key] = value; + } + permissions.push(definition); + } + const { positions } = convertPositionBindingRows({ + positions: (tables.sys_position ?? []) as Definition[], + permissionSets: setRows, + bindings: (tables.sys_position_permission_set ?? []) as Definition[], + }); + return { + permissions, + positions: positions.flatMap((f) => (f.fate === 'conflicting' ? [] : [{ name: f.name, permissionSets: [...f.permissionSets] }])), + }; +} + +/** Bind the catalog `tables` convert to, re-derived per lookup so a row written later is in it. */ +export function bindCatalogFromTables(engine: T, tables: Record): T { + return bindTestSecurityCatalog(engine, () => catalogFromTables(tables)); +} diff --git a/packages/services/service-automation/src/runas-grant-resolution.integration.test.ts b/packages/services/service-automation/src/runas-grant-resolution.integration.test.ts index ac4865baea0..2d30596d215 100644 --- a/packages/services/service-automation/src/runas-grant-resolution.integration.test.ts +++ b/packages/services/service-automation/src/runas-grant-resolution.integration.test.ts @@ -19,6 +19,7 @@ import { LiteKernel } from '@objectstack/core'; import { AutomationServicePlugin } from './plugin.js'; import { AutomationEngine } from './engine.js'; import { assertEngineFindOnePredicate } from '@objectstack/metadata-core'; +import { bindCatalogFromTables } from './__tests__/security-catalog.testkit.js'; /** * A fake ObjectQL engine that both serves the authz tables (for grant @@ -42,6 +43,8 @@ function fakeObjectQl(tables: Record) { async update(object: string, _f: any, opts: any) { crud.push({ op: 'update', obj: object, ctx: opts?.context }); return { ok: true }; }, async delete(object: string, opts: any) { crud.push({ op: 'delete', obj: object, ctx: opts?.context }); return { ok: true }; }, }; + // [ADR-0131 D3/D4] The security catalog the resolver reads, as the rows convert to it. + bindCatalogFromTables(engine, tables); return { engine, crud }; } diff --git a/packages/services/service-datasource/src/__tests__/admin-routes-tenancy-posture-admission.test.ts b/packages/services/service-datasource/src/__tests__/admin-routes-tenancy-posture-admission.test.ts index b628991ecf6..1b71dda3190 100644 --- a/packages/services/service-datasource/src/__tests__/admin-routes-tenancy-posture-admission.test.ts +++ b/packages/services/service-datasource/src/__tests__/admin-routes-tenancy-posture-admission.test.ts @@ -54,6 +54,7 @@ import { ObjectKernel, hashApiKey } from '@objectstack/core'; import type { PluginContext } from '@objectstack/core'; import { HonoHttpServer } from '@objectstack/plugin-hono-server'; import { registerDatasourceAdminRoutes, DATASOURCE_ADMIN_CAPABILITY } from '../admin-routes.js'; +import { bindCatalogFromTables } from './security-catalog.testkit.js'; const RAW_MEMBER_KEY = 'osk_15350_member'; const RAW_EXMEMBER_KEY = 'osk_15350_exmember'; @@ -143,12 +144,12 @@ function makeQl() { }, ], }; - return { + return bindCatalogFromTables({ find: async (object: string, q: any = {}) => { const rows = (tables[object] ?? []).filter((row: any) => matchesWhere(row, q?.where)); return typeof q?.limit === 'number' ? rows.slice(0, q.limit) : rows; }, - }; + }, tables); } // --------------------------------------------------------------------------- diff --git a/packages/services/service-datasource/src/__tests__/entitled-caller.fixture.ts b/packages/services/service-datasource/src/__tests__/entitled-caller.fixture.ts index c6196924636..2be8019e0bc 100644 --- a/packages/services/service-datasource/src/__tests__/entitled-caller.fixture.ts +++ b/packages/services/service-datasource/src/__tests__/entitled-caller.fixture.ts @@ -30,6 +30,7 @@ * makes them worth having. */ +import { bindSecurityCatalogReader, createSecurityCatalogReader } from '@objectstack/core'; import { DATASOURCE_ADMIN_CAPABILITY } from '../admin-routes.js'; /** The credential that resolves to a caller holding the capability. */ @@ -92,7 +93,7 @@ export function createSessionAuthService() { * rather than a validity edge case that belongs to `@objectstack/core`'s suite. */ export function createGrantsEngine() { - return { + const engine = { find: async (object: string, opts: any) => { if (object === 'sys_user_permission_set') { return opts?.where?.user_id === ENTITLED_USER @@ -125,4 +126,16 @@ export function createGrantsEngine() { return []; }, }; + // [ADR-0131 D3/D4] The set as the security catalog declares it — the resolver + // reads a set's body from its definition, never from the row above. + const definition = { name: GRANT_SET_NAME, systemPermissions: [DATASOURCE_ADMIN_CAPABILITY] }; + bindSecurityCatalogReader(engine, createSecurityCatalogReader({ + registry: { + getItem: (type, name) => (type === 'permission' && name === GRANT_SET_NAME ? definition : undefined), + listItems: (type) => (type === 'permission' ? [definition] : []), + isPackageDisabled: () => false, + }, + metadata: { get: () => undefined, list: () => [] }, + })); + return engine; } diff --git a/packages/services/service-datasource/src/__tests__/security-catalog.testkit.ts b/packages/services/service-datasource/src/__tests__/security-catalog.testkit.ts new file mode 100644 index 00000000000..a4c173ebf20 --- /dev/null +++ b/packages/services/service-datasource/src/__tests__/security-catalog.testkit.ts @@ -0,0 +1,72 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Test scaffolding: bind a security catalog to a fake engine (ADR-0131 D3/D4). + * `resolveUserAuthzGrants` reads positions, their sets and the set bodies from + * the catalog the security plugin binds to its engine; a suite whose engine is + * a double binds one here. A row alone grants nothing: what a fixture grants + * must be declared. {@link catalogFromTables} declares a row-shaped fixture's + * catalog, its junction rows through `convertPositionBindingRows` — the + * conversion the upgrade ceremony applies. + */ + +import { bindSecurityCatalogReader, convertPositionBindingRows, createSecurityCatalogReader } from '@objectstack/core'; + +type Definition = Record; + +/** The definitions a suite declares, per catalog type. */ +export interface TestSecurityCatalog { + positions?: readonly Definition[]; + permissions?: readonly Definition[]; +} + +/** Bind a catalog over these definitions (read afresh per lookup when given a function); returns `engine`. */ +export function bindTestSecurityCatalog(engine: T, catalog: TestSecurityCatalog | (() => TestSecurityCatalog)): T { + const items = (type: string): readonly Definition[] => { + const c = typeof catalog === 'function' ? catalog() : catalog; + return (type === 'position' ? c.positions : type === 'permission' ? c.permissions : undefined) ?? []; + }; + bindSecurityCatalogReader(engine, createSecurityCatalogReader({ + registry: { getItem: (type, name) => items(type).find((d) => d.name === name), listItems: items, isPackageDisabled: () => false }, + metadata: { get: () => undefined, list: () => [] }, + })); + return engine; +} + +const parsed = (value: unknown): unknown => { + if (typeof value !== 'string') return value; + try { + return JSON.parse(value); + } catch { + return undefined; + } +}; + +/** The definitions a fixture's catalog rows convert to (first set row of a name wins; a `conflicting` position names no set). */ +export function catalogFromTables(tables: Record): TestSecurityCatalog { + const setRows = (tables.sys_permission_set ?? []) as Definition[]; + const permissions: Definition[] = []; + for (const row of setRows) { + if (typeof row.name !== 'string' || permissions.some((d) => d.name === row.name)) continue; + const definition: Definition = { name: row.name }; + for (const [column, key] of [['system_permissions', 'systemPermissions'], ['tab_permissions', 'tabPermissions'], ['object_permissions', 'objects']] as const) { + const value = parsed(row[column] ?? row[key]); + if (value !== undefined && value !== null) definition[key] = value; + } + permissions.push(definition); + } + const { positions } = convertPositionBindingRows({ + positions: (tables.sys_position ?? []) as Definition[], + permissionSets: setRows, + bindings: (tables.sys_position_permission_set ?? []) as Definition[], + }); + return { + permissions, + positions: positions.flatMap((f) => (f.fate === 'conflicting' ? [] : [{ name: f.name, permissionSets: [...f.permissionSets] }])), + }; +} + +/** Bind the catalog `tables` convert to, re-derived per lookup so a row written later is in it. */ +export function bindCatalogFromTables(engine: T, tables: Record): T { + return bindTestSecurityCatalog(engine, () => catalogFromTables(tables)); +} diff --git a/packages/services/service-settings/src/__tests__/security-catalog.testkit.ts b/packages/services/service-settings/src/__tests__/security-catalog.testkit.ts new file mode 100644 index 00000000000..a4c173ebf20 --- /dev/null +++ b/packages/services/service-settings/src/__tests__/security-catalog.testkit.ts @@ -0,0 +1,72 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Test scaffolding: bind a security catalog to a fake engine (ADR-0131 D3/D4). + * `resolveUserAuthzGrants` reads positions, their sets and the set bodies from + * the catalog the security plugin binds to its engine; a suite whose engine is + * a double binds one here. A row alone grants nothing: what a fixture grants + * must be declared. {@link catalogFromTables} declares a row-shaped fixture's + * catalog, its junction rows through `convertPositionBindingRows` — the + * conversion the upgrade ceremony applies. + */ + +import { bindSecurityCatalogReader, convertPositionBindingRows, createSecurityCatalogReader } from '@objectstack/core'; + +type Definition = Record; + +/** The definitions a suite declares, per catalog type. */ +export interface TestSecurityCatalog { + positions?: readonly Definition[]; + permissions?: readonly Definition[]; +} + +/** Bind a catalog over these definitions (read afresh per lookup when given a function); returns `engine`. */ +export function bindTestSecurityCatalog(engine: T, catalog: TestSecurityCatalog | (() => TestSecurityCatalog)): T { + const items = (type: string): readonly Definition[] => { + const c = typeof catalog === 'function' ? catalog() : catalog; + return (type === 'position' ? c.positions : type === 'permission' ? c.permissions : undefined) ?? []; + }; + bindSecurityCatalogReader(engine, createSecurityCatalogReader({ + registry: { getItem: (type, name) => items(type).find((d) => d.name === name), listItems: items, isPackageDisabled: () => false }, + metadata: { get: () => undefined, list: () => [] }, + })); + return engine; +} + +const parsed = (value: unknown): unknown => { + if (typeof value !== 'string') return value; + try { + return JSON.parse(value); + } catch { + return undefined; + } +}; + +/** The definitions a fixture's catalog rows convert to (first set row of a name wins; a `conflicting` position names no set). */ +export function catalogFromTables(tables: Record): TestSecurityCatalog { + const setRows = (tables.sys_permission_set ?? []) as Definition[]; + const permissions: Definition[] = []; + for (const row of setRows) { + if (typeof row.name !== 'string' || permissions.some((d) => d.name === row.name)) continue; + const definition: Definition = { name: row.name }; + for (const [column, key] of [['system_permissions', 'systemPermissions'], ['tab_permissions', 'tabPermissions'], ['object_permissions', 'objects']] as const) { + const value = parsed(row[column] ?? row[key]); + if (value !== undefined && value !== null) definition[key] = value; + } + permissions.push(definition); + } + const { positions } = convertPositionBindingRows({ + positions: (tables.sys_position ?? []) as Definition[], + permissionSets: setRows, + bindings: (tables.sys_position_permission_set ?? []) as Definition[], + }); + return { + permissions, + positions: positions.flatMap((f) => (f.fate === 'conflicting' ? [] : [{ name: f.name, permissionSets: [...f.permissionSets] }])), + }; +} + +/** Bind the catalog `tables` convert to, re-derived per lookup so a row written later is in it. */ +export function bindCatalogFromTables(engine: T, tables: Record): T { + return bindTestSecurityCatalog(engine, () => catalogFromTables(tables)); +} diff --git a/packages/services/service-settings/src/settings-admission-tenancy-posture.test.ts b/packages/services/service-settings/src/settings-admission-tenancy-posture.test.ts index a505a0918c5..c684fda6143 100644 --- a/packages/services/service-settings/src/settings-admission-tenancy-posture.test.ts +++ b/packages/services/service-settings/src/settings-admission-tenancy-posture.test.ts @@ -73,6 +73,7 @@ import { AUTHZ_STORE_UNAVAILABLE_STATUS, } from '@objectstack/core'; import type { SettingsContext } from './settings-service.types.js'; +import { bindCatalogFromTables } from './__tests__/security-catalog.testkit.js'; /** * The seam's own return value, captured through a PASS-THROUGH of the real @@ -340,6 +341,8 @@ async function mount(tenancy: Tenancy): Promise { // The caller's bound is held BY PRESENCE, never re-derived here. return typeof q?.limit === 'number' ? rows.slice(0, q.limit) : rows; }; + // [ADR-0131 D3/D4] The security catalog the resolver reads, as those rows convert to it. + bindCatalogFromTables(engine, PERMISSION_TABLES); const http = new MockHttp(); diff --git a/packages/spec/authorable-surface/identity.json b/packages/spec/authorable-surface/identity.json index 21cf207f7f5..05026288883 100644 --- a/packages/spec/authorable-surface/identity.json +++ b/packages/spec/authorable-surface/identity.json @@ -55,6 +55,7 @@ "identity/Position:description", "identity/Position:label", "identity/Position:name", + "identity/Position:permissionSets", "identity/Position:protection", "identity/SCIMAddress:country", "identity/SCIMAddress:formatted", diff --git a/packages/spec/liveness/position.json b/packages/spec/liveness/position.json index 20c60adfd6b..641aa3ae7c5 100644 --- a/packages/spec/liveness/position.json +++ b/packages/spec/liveness/position.json @@ -20,6 +20,12 @@ "evidence": "rendered on the sys_position record detail in Setup's generic object views", "note": "display." }, + "permissionSets": { + "status": "live", + "verifiedAt": "2026-10-10", + "evidence": "packages/core/src/security/resolve-authz-context.ts#positionSetNamesOf", + "note": "ADR-0131 D3/D4: the position -> permission-set binding. resolveUserAuthzGrants (section 6a) reads each held position's catalog definition and grants the sets its permissionSets names, by name; the sys_position_permission_set junction is no longer read. Pinned by resolve-authz-context.grant-set-by-name.test.ts and the batch-equivalence golden." + }, "delegatable": { "status": "live", "verifiedAt": "2026-07-30", diff --git a/packages/spec/liveness/state-counts/position.md b/packages/spec/liveness/state-counts/position.md index 1d47113ce27..362a194588e 100644 --- a/packages/spec/liveness/state-counts/position.md +++ b/packages/spec/liveness/state-counts/position.md @@ -12,4 +12,4 @@ committed anywhere: `check:liveness` sums the shards when it reads them. | Type | live | exp | elsewhere | dead | planned | classified | |---|---|---|---|---|---|---| -| `position` | 12 | 0 | 0 | 0 | 0 | 12 | +| `position` | 13 | 0 | 0 | 0 | 0 | 13 | diff --git a/packages/spec/scripts/render-projection-diff.test.ts b/packages/spec/scripts/render-projection-diff.test.ts index 550740f12c8..e095311e290 100644 --- a/packages/spec/scripts/render-projection-diff.test.ts +++ b/packages/spec/scripts/render-projection-diff.test.ts @@ -16,13 +16,16 @@ import path from 'node:path'; import { ARTIFACT_NAME, + BASE_REGISTRY, SUMMARY_DIFF_CAP, describeDeltas, fenceFor, + generateBaseRegistry, idDeltas, renderProjectionDiff, renderSummary, type Generate, + type RunScript, } from './render-projection-diff'; let tmp: string; @@ -149,6 +152,113 @@ describe('render-projection-diff — the generated diff on a pull request', () = }); }); +describe('render-projection-diff — a base whose migration registry is git-ignored', () => { + /** A base `packages/spec` laid out as its archive would be: the generator and/or the registry, or neither. */ + function baseSpecDir(has: { generator: boolean; registry: boolean }): string { + const dir = path.join(tmp, 'base-tree', 'packages', 'spec'); + fs.mkdirSync(path.join(dir, 'scripts'), { recursive: true }); + if (has.generator) fs.writeFileSync(path.join(dir, 'scripts', BASE_REGISTRY.generator), ''); + if (has.registry) { + fs.mkdirSync(path.dirname(path.join(dir, BASE_REGISTRY.file)), { recursive: true }); + fs.writeFileSync(path.join(dir, BASE_REGISTRY.file), '// committed\n'); + } + return dir; + } + + /** A fake runner that records each call and, unless told otherwise, writes the registry the way the generator does. */ + function recorder(result: { status: number; write: boolean; output?: string }) { + const calls: [string, string][] = []; + const run: RunScript = (dir, script) => { + calls.push([dir, script]); + if (result.write) fs.writeFileSync(path.join(dir, BASE_REGISTRY.file), '// generated\n'); + return { status: result.status, output: result.output ?? '' }; + }; + return { calls, run }; + } + + it('an archive with the generator but no registry runs the BASE generator in the base tree', () => { + const dir = baseSpecDir({ generator: true, registry: false }); + fs.mkdirSync(path.dirname(path.join(dir, BASE_REGISTRY.file)), { recursive: true }); + const { calls, run } = recorder({ status: 0, write: true }); + + expect(generateBaseRegistry(dir, run)).toBe(true); + expect(calls).toEqual([[dir, 'build-migration-registry.ts']]); + expect(fs.readFileSync(path.join(dir, BASE_REGISTRY.file), 'utf8')).toBe('// generated\n'); + }); + + it('a base that committed its registry keeps it, and a base without the generator is left as it is', () => { + for (const has of [{ generator: true, registry: true }, { generator: false, registry: true }, { generator: false, registry: false }]) { + fs.rmSync(path.join(tmp, 'base-tree'), { recursive: true, force: true }); + const dir = baseSpecDir(has); + const { calls, run } = recorder({ status: 0, write: true }); + + expect(generateBaseRegistry(dir, run)).toBe(false); + expect(calls).toEqual([]); + if (has.registry) expect(fs.readFileSync(path.join(dir, BASE_REGISTRY.file), 'utf8')).toBe('// committed\n'); + } + }); + + it('a base registry generator that fails, or exits 0 without writing, is thrown with its output', () => { + const dir = baseSpecDir({ generator: true, registry: false }); + + // First line names the generator and its exit; the generator's output follows it — the + // shape the failure summary splits on. + expect(() => generateBaseRegistry(dir, recorder({ status: 1, write: false, output: 'entry failed to parse' }).run)).toThrow( + /^build-migration-registry\.ts exited 1\b[^\n]*\nentry failed to parse$/, + ); + expect(() => generateBaseRegistry(dir, recorder({ status: 0, write: false }).run)).toThrow( + /^build-migration-registry\.ts exited 0\b[^\n]*src\/migrations\/registry\.ts/, + ); + }); + + it('a base whose registry cannot be generated is RED, named as the base', () => { + const dir = baseSpecDir({ generator: true, registry: false }); + const generate: Generate = (_dir, script, out) => { + fs.writeFileSync(out, script === 'build-spec-changes.ts' ? BASE.json : BASE.guide); + return { status: 0, output: '' }; + }; + const head = path.join(tmp, 'head-tree', 'packages', 'spec'); + fs.mkdirSync(head, { recursive: true }); + const outDir = path.join(tmp, 'out'); + const result = renderProjectionDiff({ + repoRoot: tmp, + baseSha: 'b'.repeat(40), + baseLabel: 'HEAD^1 = bbbbbbbbbb', + outDir, + headSpecDir: head, + generate, + materializeBase: () => { + generateBaseRegistry(dir, recorder({ status: 1, write: false, output: 'entry failed to parse' }).run); + return dir; + }, + }); + + expect(result.code).toBe(1); + expect(result.headline).toContain('base HEAD^1 = bbbbbbbbbb: build-migration-registry.ts exited 1'); + expect(result.summary).toContain('entry failed to parse'); + }); + + it('the default runner executes the base generator by its own path, with the base package as cwd', () => { + const dir = baseSpecDir({ generator: true, registry: false }); + // Resolves its package root from its own path, as the real generator does. + fs.writeFileSync( + path.join(dir, 'scripts', BASE_REGISTRY.generator), + [ + "import { mkdirSync, writeFileSync } from 'node:fs';", + "import { dirname, join } from 'node:path';", + "import { fileURLToPath } from 'node:url';", + "const pkgRoot = join(dirname(fileURLToPath(import.meta.url)), '..');", + "mkdirSync(join(pkgRoot, 'src', 'migrations'), { recursive: true });", + "writeFileSync(join(pkgRoot, 'src', 'migrations', 'registry.ts'), `// cwd ${process.cwd()}\\n`);", + '', + ].join('\n'), + ); + + expect(generateBaseRegistry(dir)).toBe(true); + expect(fs.readFileSync(path.join(dir, BASE_REGISTRY.file), 'utf8')).toBe(`// cwd ${fs.realpathSync(dir)}\n`); + }); +}); + describe('render-projection-diff — the pieces', () => { it('idDeltas names ids gained and lost per record, and skips records that did not move', () => { const deltas = idDeltas(manifest(['a', 'b'], ['c1']), manifest(['b', 'z'], ['c1', 'c2'])); diff --git a/packages/spec/scripts/render-projection-diff.ts b/packages/spec/scripts/render-projection-diff.ts index e299077fa41..07690f29a8e 100644 --- a/packages/spec/scripts/render-projection-diff.ts +++ b/packages/spec/scripts/render-projection-diff.ts @@ -27,6 +27,8 @@ * base ``'s own generators, run in a `git archive` of `` that * borrows this checkout's `node_modules` — so the base side is what the * base's code generated, never this change's generator applied to it. + * A base whose migration registry is git-ignored gets it written first, + * by its own generator (`generateBaseRegistry`). * * ⚠️ A base whose generators predate `--out` writes its committed path instead * (inside the throwaway archive, never this checkout). That is read in its @@ -86,8 +88,11 @@ export interface GeneratorRun { /** Runs `/scripts/