diff --git a/.changeset/15196-s5a-resolver-grant-name.md b/.changeset/15196-s5a-resolver-grant-name.md new file mode 100644 index 00000000000..490cb2e9006 --- /dev/null +++ b/.changeset/15196-s5a-resolver-grant-name.md @@ -0,0 +1,30 @@ +--- +'@objectstack/core': minor +'@objectstack/plugin-security': minor +'@objectstack/plugin-auth': minor +--- + +feat(core)!: the authorization resolver reads which permission set a user grant holds from the grant's name, `sys_user_permission_set.permission_set` (ADR-0131 D4) + +Clause-②: no (narrowing) + + + +**BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. + +**What the resolver reads now.** `resolveUserAuthzGrants` (and every surface built on it: `resolveAuthzContext`, `hasPlatformAdminStanding`, the explain engine, `runAs: 'user'` automation) finds a user grant's permission set by the grant's `permission_set` name. The set row is the grant's own organization's row of that name, or else the organization-less row of that name. `permission_set_id` is no longer read for this. Deactivation is still read from the set row. Sets a principal holds through a position are still reached through the position binding's id. + +- **Platform standing.** An unscoped grant is read against the organization-less `admin_full_access` row only. An organization's copy of `admin_full_access` gives that organization's grants its capabilities, never platform standing. +- **Nobody's resolved permissions change** where a grant's name and id agree. The platform's grant writers store both, and the one-time backfill names grants stored before the name column existed. Goldens for the platform administrator, an organization administrator, a member and an agent are unchanged in `single`, `group` and `isolated`. + +**What stops conferring.** + +- **A grant that names nothing.** This is a grant the backfill has not named yet, or one it could not name: its id names no set row, its id names another organization's set row, or the name is not in the security catalog. Such a grant now confers nothing through the resolver. The backfill runs at `kernel:bootstrapped`, before any server opens its socket, so on the first boot after upgrading no request is answered before the grants it can name are named. The grants it cannot name are listed in its boot report. +- **A grant whose id names another organization's set.** Before this change, such a grant conferred that set. It now confers nothing. +- **Remedy.** Read the backfill's boot report, which lists each grant by row id. Re-point each listed grant at a permission set of its own organization, or at an organization-less one, with an update of `permission_set_id`. The platform stamps the name, and the grant confers again. + +**What the grant name hook refuses now** (`@objectstack/plugin-security`). The name is taken only from a set row of the grant's own organization, or from an organization-less one. A tenant-less system writer can read every organization's sets, and it could name an organization-less grant after another organization's set. Now such a grant is stored without a name. A write that supplies that name is refused, from a system writer too, with the hook's usual `400 VALIDATION_FAILED` and `invalid_value` at `permission_set`. An update that re-points a grant at such a set, or moves a grant to an organization its set does not belong to, clears the name. + +**What the last-administrator guard judges now** (`@objectstack/plugin-auth`). `organization_id` on `sys_permission_set` is a standing column. Moving the organization-less `admin_full_access` row into an organization takes away every grant-anchored platform administrator, as deleting it does, so the guard refuses it when it would leave none. An organization's copy of `admin_full_access` no longer counts as the set being in effect. + +**Nothing to migrate** in code or metadata. diff --git a/packages/cloud-connection/src/install-local-principal.fixtures.ts b/packages/cloud-connection/src/install-local-principal.fixtures.ts index c79212fa438..9bd1cb27214 100644 --- a/packages/cloud-connection/src/install-local-principal.fixtures.ts +++ b/packages/cloud-connection/src/install-local-principal.fixtures.ts @@ -53,7 +53,7 @@ export function installerGrantRows(userId: string = INSTALLER_USER_ID): Record { sys_position: [], sys_position_permission_set: [], sys_user_permission_set: held.length - ? [{ id: 'ups1', user_id: `usr_${shape}`, permission_set_id: 'ps1', organization_id: null }] + ? [{ + id: 'ups1', user_id: `usr_${shape}`, permission_set_id: 'ps1', + permission_set: shape === 'capable' ? 'admin_full_access' : 'organization_admin', organization_id: null, + }] : [], sys_permission_set: held.length ? [{ id: 'ps1', name: shape === 'capable' ? 'admin_full_access' : 'organization_admin', system_permissions: held }] 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 dd9a2edba1c..f15e760c5d5 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 @@ -111,7 +111,10 @@ function grantRows(shape: Shape): Record { sys_position: [], sys_position_permission_set: [], sys_user_permission_set: held.length - ? [{ id: 'ups1', user_id: `usr_${shape}`, permission_set_id: 'ps1', organization_id: null }] + ? [{ + id: 'ups1', user_id: `usr_${shape}`, permission_set_id: 'ps1', + permission_set: shape === 'operator' ? 'admin_full_access' : 'organization_admin', organization_id: null, + }] : [], sys_permission_set: held.length ? [{ 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 d6fac67bced..55a1a6970ee 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 @@ -89,7 +89,7 @@ const MEMBER_GRANTS: Record = { sys_user_position: [], sys_position: [], sys_position_permission_set: [], - sys_user_permission_set: [{ id: 'ups_member', user_id: MEMBER_ID, permission_set_id: 'ps_member', organization_id: null }], + 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'] }], }; @@ -125,6 +125,8 @@ async function restartWith(manifests: Array<{ id: string; version: string }>, di if (k.startsWith('$') || (v !== null && typeof v === 'object')) { throw new Error(`only scalar equality is implemented here (got '${k}')`); } + // An absent column reads as NULL, as it does in SQL (`organization_id: null`). + if (v === null) return (row[k] ?? null) === null; return row[k] === v; })); return (typeof query?.limit === 'number' ? rows.slice(0, query.limit) : rows).map((row) => ({ ...row })); 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 e3bb6149bb4..30c9c4495b6 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 @@ -108,6 +108,11 @@ function matchesWhere(row: any, where: any): boolean { if (!(cond as any).$in.includes(row[field])) return false; continue; } + // An absent column reads as NULL, as it does in SQL (`organization_id: null`). + if (cond === null) { + if ((row[field] ?? null) !== null) return false; + continue; + } if (row[field] !== cond) return false; } return true; @@ -143,10 +148,10 @@ function permissionStore(opts: { syncSchemas: () => Promise }) { sys_position: [], sys_position_permission_set: [], sys_user_permission_set: [ - { id: 'ups_member', user_id: 'u_member', permission_set_id: 'ps_install', organization_id: null }, - { id: 'ups_exmember', user_id: 'u_exmember', permission_set_id: 'ps_install', organization_id: null }, - { id: 'ups_orgless', user_id: 'u_orgless', permission_set_id: 'ps_install', organization_id: null }, - { id: 'ups_session', user_id: 'u_session', permission_set_id: 'ps_install', organization_id: null }, + { id: 'ups_member', user_id: 'u_member', permission_set_id: 'ps_install', permission_set: 'admin_full_access', organization_id: null }, + { id: 'ups_exmember', user_id: 'u_exmember', permission_set_id: 'ps_install', permission_set: 'admin_full_access', organization_id: null }, + { id: 'ups_orgless', user_id: 'u_orgless', permission_set_id: 'ps_install', permission_set: 'admin_full_access', organization_id: null }, + { id: 'ups_session', user_id: 'u_session', permission_set_id: 'ps_install', permission_set: 'admin_full_access', organization_id: null }, ], sys_permission_set: [ { id: 'ps_install', name: 'admin_full_access', system_permissions: ['manage_metadata', 'studio.access'] }, 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 caaf4f8c2d3..18454b94128 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 @@ -58,6 +58,8 @@ export function makeRecordingQl(tables: Record) { Object.entries(where ?? {}).every(([k, v]) => { if (k.startsWith('$')) throw new Error(`fake driver: unsupported operator ${k}`); if (v && typeof v === 'object' && '$in' in (v as any)) return (v as any).$in.includes(row[k]); + // An absent column reads as NULL, as it does in SQL (`organization_id: null`). + if (v === null) return (row[k] ?? null) === null; return row[k] === v; }); return { @@ -253,11 +255,11 @@ export const FIXTURES: Fixture[] = [ sys_member: [{ user_id: 'u_ps', organization_id: 'org_a', role: 'member' }], sys_user_position: [], sys_user_permission_set: [ - { user_id: 'u_ps', permission_set_id: 'ps_admin', organization_id: null }, - { user_id: 'u_ps', permission_set_id: 'ps_org', organization_id: 'org_a' }, - { user_id: 'u_ps', permission_set_id: 'ps_other', organization_id: 'org_z' }, - { user_id: 'u_ps', permission_set_id: 'ps_lapsed', organization_id: null, valid_until: past }, - { user_id: 'u_ps', permission_set_id: 'ps_dead', organization_id: null }, + { user_id: 'u_ps', permission_set_id: 'ps_admin', permission_set: 'admin_full_access', organization_id: null }, + { user_id: 'u_ps', permission_set_id: 'ps_org', permission_set: 'org_tools', organization_id: 'org_a' }, + { user_id: 'u_ps', permission_set_id: 'ps_other', permission_set: 'other_org_tools', organization_id: 'org_z' }, + { user_id: 'u_ps', permission_set_id: 'ps_lapsed', permission_set: 'lapsed_set', organization_id: null, valid_until: past }, + { user_id: 'u_ps', permission_set_id: 'ps_dead', permission_set: 'dead_set', organization_id: null }, ], sys_permission_set: [ { id: 'ps_admin', name: 'admin_full_access', system_permissions: ['manage_users'] }, @@ -279,7 +281,7 @@ export const FIXTURES: Fixture[] = [ sys_user: [{ id: 'u_ta' }], sys_member: [{ user_id: 'u_ta', organization_id: 'org_a', role: 'admin' }], sys_user_position: [], - sys_user_permission_set: [{ user_id: 'u_ta', permission_set_id: 'ps_low', organization_id: null }], + sys_user_permission_set: [{ user_id: 'u_ta', permission_set_id: 'ps_low', permission_set: 'low', organization_id: null }], sys_position: [{ id: 'p_orgadmin', name: 'org_admin' }, { id: 'p_everyone', name: 'everyone' }], sys_position_permission_set: [{ position_id: 'p_orgadmin', permission_set_id: 'ps_oa' }], sys_permission_set: [ @@ -330,7 +332,7 @@ export const FIXTURES: Fixture[] = [ sys_user: [{ id: 'u_seed', email: 'ignored@x.com', ai_access: 1 }], sys_member: [{ user_id: 'u_seed', organization_id: 'org_a', role: 'member' }], sys_user_position: [], - sys_user_permission_set: [{ user_id: 'u_seed', permission_set_id: 'ps_x', organization_id: null }], + sys_user_permission_set: [{ user_id: 'u_seed', permission_set_id: 'ps_x', permission_set: 'extra', organization_id: null }], sys_permission_set: [{ id: 'ps_x', name: 'extra' }], sys_position: [], }, diff --git a/packages/core/src/security/admin-standing-surface.test.ts b/packages/core/src/security/admin-standing-surface.test.ts index 2bfdd40bfb4..21808eb0731 100644 --- a/packages/core/src/security/admin-standing-surface.test.ts +++ b/packages/core/src/security/admin-standing-surface.test.ts @@ -94,6 +94,8 @@ function makeRecordingQl(tables: Record>>, if ('$nin' in c) return !(c.$nin as unknown[]).includes(raw(row, key)); if ('$ne' in c) return raw(row, key) !== c.$ne; } + // An absent column reads as NULL, as it does in SQL (`organization_id: null`). + if (cond === null) return (raw(row, key) ?? null) === null; return raw(row, key) === cond; }), ); @@ -165,7 +167,7 @@ const VARIANTS: Record< { id: 'ups_1', user_id: 'usr_1', - permission_set_id: 'pst_1', + permission_set_id: 'pst_1', permission_set: 'admin_full_access', organization_id: null, valid_from: null, valid_until: null, @@ -241,7 +243,7 @@ const VARIANTS: Record< { id: 'ups_1', user_id: 'usr_1', - permission_set_id: 'pst_1', + permission_set_id: 'pst_1', permission_set: 'admin_full_access', organization_id: 'org_1', valid_from: new Date(NOW - HOUR).toISOString(), valid_until: new Date(NOW + HOUR).toISOString(), diff --git a/packages/core/src/security/admin-standing-surface.ts b/packages/core/src/security/admin-standing-surface.ts index e1adba746d3..bcef643b418 100644 --- a/packages/core/src/security/admin-standing-surface.ts +++ b/packages/core/src/security/admin-standing-surface.ts @@ -116,11 +116,17 @@ export const ADMIN_STANDING_SURFACE: Readonly + "`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.', + + '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.', columns: [ 'id', 'name', 'active', + 'organization_id', + 'organizationId', 'system_permissions', 'systemPermissions', 'tab_permissions', @@ -138,11 +144,12 @@ export const ADMIN_STANDING_SURFACE: Readonly + 'of this legacy anchor is retired, so standing there comes from `OS_PLATFORM_OWNER_EMAIL` ' + '(§6b-config) and from nothing else, and this row is read only for the permission set it ' + 'names. Under `single` — the default — the row is unchanged and still the anchor that ' - + "rig's zero-config first-user promotion mints.", + + "rig's zero-config first-user promotion mints. [ADR-0131 D4] The grant holds its set BY " + + 'NAME (`permission_set`): a grant that names nothing confers nothing, and its ' + + '`permission_set_id` is not read here.', columns: [ 'user_id', - 'permission_set_id', - 'permissionSetId', + 'permission_set', 'organization_id', 'organizationId', 'valid_from', diff --git a/packages/core/src/security/authz-store-unavailable.test.ts b/packages/core/src/security/authz-store-unavailable.test.ts index a6ffd344625..49f2e08b645 100644 --- a/packages/core/src/security/authz-store-unavailable.test.ts +++ b/packages/core/src/security/authz-store-unavailable.test.ts @@ -43,7 +43,7 @@ const qlEmpty = () => ({ find: async () => [] }); /** A store that actually grants something, so "resolves" is read against a real grant. */ const qlHealthy = () => ({ find: async (object: string) => { - if (object === 'sys_user_permission_set') return [{ permission_set_id: 'ps' }]; + 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'] }]; } 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 56119bd1819..0884edd1989 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 @@ -398,26 +398,39 @@ "limit": 200, "isSystem": true }, + { + "object": "sys_user", + "where": { + "id": "u_ps" + }, + "limit": 1, + "isSystem": true + }, { "object": "sys_permission_set", "where": { - "id": { + "name": { "$in": [ - "ps_admin", - "ps_org", - "ps_dead" + "admin_full_access", + "org_tools", + "dead_set" ] - } + }, + "organization_id": null }, "limit": 500, "isSystem": true }, { - "object": "sys_user", + "object": "sys_permission_set", "where": { - "id": "u_ps" + "name": { + "$in": [ + "org_tools" + ] + } }, - "limit": 1, + "limit": 500, "isSystem": true } ] @@ -505,25 +518,37 @@ "limit": 500, "isSystem": true }, + { + "object": "sys_user", + "where": { + "id": "u_ta" + }, + "limit": 1, + "isSystem": true + }, { "object": "sys_permission_set", "where": { - "id": { + "name": { "$in": [ - "ps_low", - "ps_oa" + "low" ] - } + }, + "organization_id": null }, "limit": 500, "isSystem": true }, { - "object": "sys_user", + "object": "sys_permission_set", "where": { - "id": "u_ta" + "id": { + "$in": [ + "ps_oa" + ] + } }, - "limit": 1, + "limit": 500, "isSystem": true } ] @@ -738,11 +763,12 @@ { "object": "sys_permission_set", "where": { - "id": { + "name": { "$in": [ - "ps_x" + "extra" ] - } + }, + "organization_id": null }, "limit": 500, "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 6a0e029ac0b..297e0b23446 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 @@ -35,6 +35,18 @@ * ⛔ These goldens are a record of what the sequential code DID. Never "fix" a * red one by re-capturing it: the whole value of the file is that it cannot be * satisfied by agreeing with the new implementation. + * + * ⚠️ One recorded move, written down here as the rule above requires + * ([ADR-0131] D4, C2 stage S5a): a user grant's permission set is read BY NAME + * — the organization-less rows for every name a grant carries, plus the active + * organization's own rows for the names its grants carry — where the + * sequential code read it by `permission_set_id`. In the three fixtures that + * hold a user grant, only the `sys_permission_set` entries of `queries` moved + * (the position-bound sets are still read by the junction's id); every other + * recorded read, and every `grants` envelope, is byte-identical to the + * 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. */ import { readFileSync } from 'node:fs'; @@ -75,20 +87,21 @@ const GOLDEN: Record = JSON.parse( * 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`, needs the union of directly- and - * position-granted ids). A principal with no `sys_position` row backing any of - * its position names skips wave 3 and lands in 3. + * 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. */ const BATCHED_LEGS: Record = { 'empty-principal': 2, 'multi-org-membership': 2, 'lapsed-own-membership-among-active-peers': 2, 'position-derived-grants': 4, - 'permission-set-derived-grants': 3, + 'permission-set-derived-grants': 2, 'tenant-admin-via-position': 4, 'ai-seat-and-email-from-sys-user': 2, 'ai-seat-denied': 2, - 'seeded-permissions-and-email': 3, + 'seeded-permissions-and-email': 2, 'read-limits-truncate': 2, 'no-active-org': 2, }; 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 new file mode 100644 index 00000000000..9561ebd5cf9 --- /dev/null +++ b/packages/core/src/security/resolve-authz-context.grant-set-by-name.test.ts @@ -0,0 +1,185 @@ +// 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. + * + * 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`): + * + * - 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; + * - 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. + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +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'; + +const ORG_A = 'org_a'; +const ORG_B = 'org_b'; + +const OWNER_ENV = 'OS_PLATFORM_OWNER_EMAIL'; +const POSTURE_ENV = 'OS_TENANCY_POSTURE'; +let ambient: Record = {}; +beforeAll(() => { + ambient = { [OWNER_ENV]: process.env[OWNER_ENV], [POSTURE_ENV]: process.env[POSTURE_ENV] }; + delete process.env[OWNER_ENV]; + delete process.env[POSTURE_ENV]; + resetPlatformAdminEmailMemo(); +}); +afterAll(() => { + for (const [k, v] of Object.entries(ambient)) { + if (v === undefined) delete process.env[k]; + else process.env[k] = v; + } + resetPlatformAdminEmailMemo(); +}); + +/** The catalog: 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'] }, +]; + +function tables(grants: Array>, extra: Record = {}) { + return { + sys_user: [{ id: 'u1', email: 'u1@example.com' }], + sys_member: [{ user_id: 'u1', organization_id: ORG_A, role: 'member' }], + sys_user_position: [], + sys_position: [], + sys_position_permission_set: [], + sys_user_permission_set: grants.map((g) => ({ user_id: 'u1', ...g })), + sys_permission_set: catalog(), + ...extra, + }; +} + +async function resolve(grants: Array>, tenantId?: string, extra?: Record) { + const ql = makeRecordingQl(tables(grants, extra)); + 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'); + } + }); + + 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('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('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); + + 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('an ORGANIZATION’s admin_full_access grant is that organization’s 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'); + }); +}); + +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']); + }); + + it('a deactivated own row is the answer: it confers nothing and does not fall through to the organization-less row', 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([]); + }); + + 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']); + }); +}); + +describe('[ADR-0131 D4] a grant that names nothing confers nothing', () => { + it('NULL or blank: no set, no capability, no tab', async () => { + for (const name of [null, '', ' ', undefined]) { + const { out } = await resolve([{ permission_set_id: 'ps_tools_a', permission_set: name, organization_id: ORG_A }], ORG_A); + expect(out.permissions, String(name)).toEqual([]); + expect(out.systemPermissions, String(name)).toEqual([]); + expect(out.tabPermissions, String(name)).toBeUndefined(); + } + }); + + it('an unnamed UNSCOPED grant on the platform admin_full_access row: no platform standing', async () => { + const { out } = await resolve([{ permission_set_id: 'ps_admin', organization_id: null }], undefined); + expect(out.posture).not.toBe('PLATFORM_ADMIN'); + 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, { + sys_position: [{ id: 'p_everyone', name: 'everyone', organization_id: ORG_A }], + sys_position_permission_set: [{ position_id: 'p_everyone', permission_set_id: 'ps_bound' }], + }); + expect(out.permissions).toEqual(['bound_by_position']); + expect(out.systemPermissions).toEqual(['cap_bound']); + }); +}); 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 ef2714b8d0f..f4d5e0abc57 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 @@ -101,6 +101,8 @@ function makeQl(tables: Record>>) { Object.entries(where ?? {}).every(([k, v]) => { if (k.startsWith('$')) throw new Error(`fake driver: unsupported operator ${k}`); if (v && typeof v === 'object' && '$in' in (v as any)) return (v as any).$in.includes(row[k]); + // An absent column reads as NULL, as it does in SQL (`organization_id: null`). + if (v === null) return (row[k] ?? null) === null; return row[k] === v; }); return { @@ -343,7 +345,7 @@ const legacyTables = () => ({ sys_position: [], sys_position_permission_set: [], sys_user_permission_set: [ - { id: 'ups_1', user_id: 'usr_1', permission_set_id: 'pst_1', organization_id: null }, + { id: 'ups_1', user_id: 'usr_1', permission_set_id: 'pst_1', permission_set: ADMIN_FULL_ACCESS, organization_id: null }, ], sys_permission_set: [{ id: 'pst_1', name: ADMIN_FULL_ACCESS, active: true }], }); 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 2e76142b67e..aa9f44f5d7f 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 @@ -110,8 +110,8 @@ function makeTables(): Record { { position_id: 'p_everyone', permission_set_id: 'ps_base' }, ], sys_user_permission_set: [ - { user_id: 'u_padmin', permission_set_id: 'ps_admin', organization_id: null }, - { user_id: 'u_member', permission_set_id: 'ps_tools', organization_id: 'org_a' }, + { user_id: 'u_padmin', permission_set_id: 'ps_admin', permission_set: 'admin_full_access', organization_id: null }, + { user_id: 'u_member', permission_set_id: 'ps_tools', permission_set: 'org_tools', organization_id: 'org_a' }, ], sys_permission_set: [ { id: 'ps_admin', name: 'admin_full_access', system_permissions: ['manage_users'] }, @@ -418,7 +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; - expect(oneResolution).toBe(8); + // [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); const memoQl = await armedQl(makeTables()); const withMemo = await resolveAuthzContext({ ql: memoQl, headers: {}, getSession: sessionReadThatResolves(memoQl, member), nowMs: T0 }); @@ -444,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(16); + expect(ql.calls.length).toBe(20); const before = ql.calls.length; const second = await resolveAuthzContext({ ql, headers: {}, getSession: sessionReadThatResolves(ql, member), nowMs: T0 }); - expect(ql.calls.length - before).toBe(8); + expect(ql.calls.length - before).toBe(10); expect(ql.middlewareCount()).toBe(1); expect(second).toEqual(first); }); @@ -485,7 +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. + // 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); }); @@ -504,7 +508,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(8); + expect(ql.calls.length - before).toBe(10); }); it('a continuation the request started reads afresh once the request settled', async () => { @@ -529,7 +533,7 @@ describe('request-scoped grants memo — isolation', () => { const before = ql.calls.length; settled.resolve(); await late; - expect(ql.calls.length - before).toBe(8); + expect(ql.calls.length - before).toBe(10); }); it('a session read resolved against a SECOND engine serves nothing to the first engine\'s step 2', async () => { @@ -543,8 +547,8 @@ describe('request-scoped grants memo — isolation', () => { nowMs: T0, tenancyPosture: 'isolated', }); - expect(qlB.calls.length).toBe(8); - expect(qlA.calls.length).toBe(8); + expect(qlB.calls.length).toBe(10); + expect(qlA.calls.length).toBe(10); const baseline = await resolveAuthzContext({ ql: makeQl(makeTables()), headers: {}, getSession: sessionReadOnly(member), nowMs: T0, tenancyPosture: 'isolated' }); expect(ctx).toEqual(baseline); }); @@ -557,7 +561,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 (8 reads), and that scope closes. + // read's resolution serves its step 2 (10 reads), and that scope closes. const inner = await resolveAuthzContext({ ql, headers: {}, @@ -572,7 +576,7 @@ describe('request-scoped grants memo — isolation', () => { tenancyPosture: 'isolated', }); // Inner 8, outer step 2 afresh 8. - expect(ql.calls.length).toBe(16); + expect(ql.calls.length).toBe(20); const baseline = await resolveAuthzContext({ ql: makeQl(makeTables()), headers: {}, getSession: sessionReadOnly(member), nowMs: T0, tenancyPosture: 'isolated' }); expect(ctx).toEqual(baseline); }); @@ -616,7 +620,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(16); + expect(ql.calls.length).toBe(20); }); it('a write that bumped before the first resolution and is still in flight at step 2', async () => { @@ -628,7 +632,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(16); + expect(ql.calls.length).toBe(20); // Not landed yet: a fresh read still sees the row, and so did step 2. expect(ctx.positions).toContain('auditor'); landing.resolve(); @@ -653,7 +657,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(16); + expect(ql.calls.length).toBe(20); expect(ctx).toEqual(await postWriteBaseline()); }); @@ -671,7 +675,7 @@ describe('request-scoped grants memo — step 2 reads afresh whenever a fresh re nowMs: T0, tenancyPosture: 'isolated', }); - expect(ql.calls.length).toBe(16); + expect(ql.calls.length).toBe(20); }); it('a write that starts while the first resolution is still reading', async () => { @@ -685,14 +689,14 @@ describe('request-scoped grants memo — step 2 reads afresh whenever a fresh re }; await resolveAuthzContext({ ql, headers: {}, getSession: sessionReadThatResolves(ql, member), nowMs: T0, tenancyPosture: 'isolated' }); await w; - expect(ql.calls.length).toBe(16); + expect(ql.calls.length).toBe(20); }); 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(8); + expect(ql.calls.length).toBe(10); 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); @@ -705,14 +709,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(16); + expect(ql.calls.length).toBe(20); 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(16); + expect(ql2.calls.length).toBe(20); }); it('a bypassGrantsCache caller is never served from the memo', async () => { @@ -730,7 +734,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(16); + expect(ql.calls.length).toBe(20); }); it('a first resolution that failed is not remembered', async () => { @@ -755,7 +759,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(8); + expect(ql.calls.length).toBe(10); 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 cdc349efcda..4e5a7ed3b91 100644 --- a/packages/core/src/security/resolve-authz-context.test.ts +++ b/packages/core/src/security/resolve-authz-context.test.ts @@ -62,6 +62,8 @@ function makeQl(tables: Record) { rows.filter((r) => Object.entries(where).every(([k, v]) => { if (k.startsWith('$')) throw new Error(`fake driver: unsupported operator ${k}`); if (v && typeof v === 'object' && '$in' in (v as any)) return (v as any).$in.includes(r[k]); + // An absent column reads as NULL, as it does in SQL (`organization_id: null`). + if (v === null) return (r[k] ?? null) === null; return r[k] === v; }), ), @@ -117,7 +119,7 @@ describe('resolveAuthzContext — single source of truth', () => { sys_user: [{ id: 'u1' }], sys_member: [], sys_user_position: [], - sys_user_permission_set: [{ user_id: 'u1', permission_set_id: 'psA', organization_id: null }], + sys_user_permission_set: [{ user_id: 'u1', permission_set_id: 'psA', permission_set: 'admin_full_access', organization_id: null }], sys_permission_set: [{ id: 'psA', name: 'admin_full_access' }], }); const ctx = await resolveAuthzContext({ ql, headers: H(), getSession: session('u1') }); @@ -129,7 +131,7 @@ describe('resolveAuthzContext — single source of truth', () => { sys_user: [{ id: 'u1' }], sys_member: [], sys_user_position: [], - sys_user_permission_set: [{ user_id: 'u1', permission_set_id: 'psA', organization_id: 'o1' }], + sys_user_permission_set: [{ user_id: 'u1', permission_set_id: 'psA', permission_set: 'admin_full_access', organization_id: 'o1' }], sys_permission_set: [{ id: 'psA', name: 'admin_full_access' }], }); const ctx = await resolveAuthzContext({ ql, headers: H(), getSession: session('u1', { org: 'o1' }) }); @@ -169,6 +171,8 @@ function makeCountingQl(tables: Record) { rows.filter((r) => Object.entries(where).every(([k, v]) => { if (k.startsWith('$')) throw new Error(`fake driver: unsupported operator ${k}`); if (v && typeof v === 'object' && '$in' in (v as any)) return (v as any).$in.includes(r[k]); + // An absent column reads as NULL, as it does in SQL (`organization_id: null`). + if (v === null) return (r[k] ?? null) === null; return r[k] === v; }), ), @@ -754,7 +758,7 @@ describe('grant validity windows (ADR-0091 D1/D2)', () => { sys_member: [], sys_user_position: [], sys_user_permission_set: [ - { user_id: 'u1', permission_set_id: 'psA', organization_id: null, valid_until: PAST }, + { user_id: 'u1', permission_set_id: 'psA', permission_set: 'admin_full_access', organization_id: null, valid_until: PAST }, ], sys_permission_set: [{ id: 'psA', name: 'admin_full_access' }], }); @@ -926,7 +930,7 @@ describe('grant validity windows (ADR-0091 D1/D2)', () => { sys_user: [{ id: 'esc2' }], sys_member: [{ user_id: 'esc2', role: 'owner', organization_id: 'o1', valid_until: PAST }], sys_user_position: [], - sys_user_permission_set: [{ user_id: 'esc2', permission_set_id: 'psO', organization_id: 'o1' }], + sys_user_permission_set: [{ user_id: 'esc2', permission_set_id: 'psO', permission_set: 'organization_admin', organization_id: 'o1' }], sys_permission_set: [{ id: 'psO', name: 'organization_admin' }], }); const ctx = await resolveAuthzContext({ ql, headers: H(), getSession: session('esc2', { org: 'o1' }), nowMs: NOW }); @@ -975,7 +979,7 @@ describe('resolveAuthzContext — posture ladder (ADR-0095 D2/D3)', () => { sys_user: [{ id: 'pa' }], sys_member: [], sys_user_position: [], - sys_user_permission_set: [{ user_id: 'pa', permission_set_id: 'psA', organization_id: null }], + sys_user_permission_set: [{ user_id: 'pa', permission_set_id: 'psA', permission_set: 'admin_full_access', organization_id: null }], sys_permission_set: [{ id: 'psA', name: 'admin_full_access' }], }), getSession: session('pa'), @@ -986,7 +990,7 @@ describe('resolveAuthzContext — posture ladder (ADR-0095 D2/D3)', () => { sys_user: [{ id: 'ta' }], sys_member: [{ user_id: 'ta', role: 'admin', organization_id: 'o1' }], sys_user_position: [], - sys_user_permission_set: [{ user_id: 'ta', permission_set_id: 'psO', organization_id: 'o1' }], + sys_user_permission_set: [{ user_id: 'ta', permission_set_id: 'psO', permission_set: 'organization_admin', organization_id: 'o1' }], sys_permission_set: [{ id: 'psO', name: 'organization_admin' }], }), getSession: session('ta', { org: 'o1' }), @@ -1049,8 +1053,8 @@ describe('resolveAuthzContext — posture ladder (ADR-0095 D2/D3)', () => { sys_member: [{ user_id: 'both', role: 'admin', organization_id: 'o1' }], sys_user_position: [], sys_user_permission_set: [ - { user_id: 'both', permission_set_id: 'psA', organization_id: null }, - { user_id: 'both', permission_set_id: 'psO', organization_id: 'o1' }, + { user_id: 'both', permission_set_id: 'psA', permission_set: 'admin_full_access', organization_id: null }, + { user_id: 'both', permission_set_id: 'psO', permission_set: 'organization_admin', organization_id: 'o1' }, ], sys_permission_set: [ { id: 'psA', name: 'admin_full_access' }, @@ -1080,7 +1084,7 @@ describe('resolveUserAuthzGrants — userId-driven authz for non-HTTP surfaces ( sys_user: [{ id: 'u1', email: 'ada@x.com' }], sys_member: [{ user_id: 'u1', role: 'admin', organization_id: 'o1' }], sys_user_position: [{ user_id: 'u1', position: 'approver', organization_id: null }], - sys_user_permission_set: [{ user_id: 'u1', permission_set_id: 'psA', organization_id: null }], + sys_user_permission_set: [{ user_id: 'u1', permission_set_id: 'psA', permission_set: 'ehr_all', organization_id: null }], sys_permission_set: [{ id: 'psA', name: 'ehr_all', system_permissions: ['cap_ehr'] }], }); const grants = await resolveUserAuthzGrants(ql, 'u1', { tenantId: 'o1' }); @@ -1097,7 +1101,7 @@ describe('resolveUserAuthzGrants — userId-driven authz for non-HTTP surfaces ( sys_user: [{ id: 'u1', email: 'ada@x.com' }], sys_member: [], sys_user_position: [{ user_id: 'u1', position: 'contributor', organization_id: null }], - sys_user_permission_set: [{ user_id: 'u1', permission_set_id: 'ps1', organization_id: null }], + sys_user_permission_set: [{ user_id: 'u1', permission_set_id: 'ps1', permission_set: 'contributor_ps', organization_id: null }], 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'] }], @@ -1115,7 +1119,7 @@ describe('resolveUserAuthzGrants — userId-driven authz for non-HTTP surfaces ( sys_user: [{ id: 'u1' }], sys_member: [], sys_user_position: [], - sys_user_permission_set: [{ user_id: 'u1', permission_set_id: 'ps1', organization_id: null }], + sys_user_permission_set: [{ user_id: 'u1', permission_set_id: 'ps1', permission_set: 'sales_ps', organization_id: null }], sys_permission_set: [{ id: 'ps1', name: 'sales_ps' }], }); const grants = await resolveUserAuthzGrants(ql, 'u1', { seedPermissions: ['api:scope'] }); @@ -1150,7 +1154,7 @@ describe('resolveUserAuthzGrants — userId-driven authz for non-HTTP surfaces ( sys_user: [{ id: 'u1' }], sys_member: [], sys_user_position: [], - sys_user_permission_set: [{ user_id: 'u1', permission_set_id: 'psA', organization_id: null, valid_until: past }], + sys_user_permission_set: [{ user_id: 'u1', permission_set_id: 'psA', permission_set: 'expired_ps', organization_id: null, valid_until: past }], sys_permission_set: [{ id: 'psA', name: 'expired_ps' }], }); const grants = await resolveUserAuthzGrants(ql, 'u1'); @@ -1456,7 +1460,7 @@ describe('[#8613] the `active` flag on the grant catalogues (ADR-0049)', () => { sys_user: [{ id: 'u1' }], sys_member: [], sys_user_position: [], - sys_user_permission_set: [{ user_id: 'u1', permission_set_id: 'ps1', organization_id: null }], + sys_user_permission_set: [{ user_id: 'u1', permission_set_id: 'ps1', permission_set: 'crm_full', organization_id: null }], sys_permission_set: [{ id: 'ps1', name: 'crm_full', @@ -1476,7 +1480,7 @@ describe('[#8613] the `active` flag on the grant catalogues (ADR-0049)', () => { sys_user: [{ id: 'u1' }], sys_member: [], sys_user_position: [], - sys_user_permission_set: [{ user_id: 'u1', permission_set_id: 'psA', organization_id: null }], + sys_user_permission_set: [{ user_id: 'u1', permission_set_id: 'psA', permission_set: 'admin_full_access', organization_id: null }], sys_permission_set: [{ id: 'psA', name: 'admin_full_access', @@ -1500,8 +1504,8 @@ describe('[#8613] the `active` flag on the grant catalogues (ADR-0049)', () => { sys_member: [], sys_user_position: [], sys_user_permission_set: [ - { user_id: 'u1', permission_set_id: 'ps1', organization_id: null }, - { user_id: 'u1', permission_set_id: 'ps2', organization_id: null }, + { user_id: 'u1', permission_set_id: 'ps1', permission_set: 'crm_full', organization_id: null }, + { user_id: 'u1', permission_set_id: 'ps2', permission_set: 'crm_read', organization_id: null }, ], sys_permission_set: [ { id: 'ps1', name: 'crm_full', active: false }, @@ -1520,7 +1524,7 @@ describe('[#8613] the `active` flag on the grant catalogues (ADR-0049)', () => { sys_user: [{ id: 'u1' }], sys_member: [], sys_user_position: [{ user_id: 'u1', position: 'contributor', organization_id: null }], - sys_user_permission_set: [{ user_id: 'u1', permission_set_id: 'ps1', 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_permission_set: [{ position_id: 'r1', permission_set_id: 'ps1' }], sys_permission_set: [{ id: 'ps1', name: 'crm_full' }], @@ -1893,9 +1897,9 @@ describe('[#20515] with no active organization, only global grants apply', () => { user_id: userId, position: 'beta_position', organization_id: BETA }, ], sys_user_permission_set: [ - { user_id: userId, permission_set_id: 'ps_global', organization_id: null }, - { user_id: userId, permission_set_id: 'ps_alpha', organization_id: ALPHA }, - { user_id: userId, permission_set_id: 'ps_beta', organization_id: BETA }, + { user_id: userId, permission_set_id: 'ps_global', permission_set: 'global_set', organization_id: null }, + { user_id: userId, permission_set_id: 'ps_alpha', permission_set: 'alpha_set', organization_id: ALPHA }, + { user_id: userId, permission_set_id: 'ps_beta', permission_set: 'beta_set', organization_id: BETA }, ], sys_permission_set: [ { id: 'ps_global', name: 'global_set', system_permissions: ['global_cap'] }, @@ -2012,10 +2016,10 @@ describe('[#20515] with no active organization, only global grants apply', () => ], sys_user_position: [], sys_user_permission_set: [ - { user_id: 'u_ex', permission_set_id: 'ps_meta', organization_id: ALPHA }, - { user_id: 'u_gone', permission_set_id: 'ps_meta', organization_id: ALPHA }, - { user_id: 'u_member', permission_set_id: 'ps_meta', organization_id: ALPHA }, - { user_id: 'u_global_ex', permission_set_id: 'ps_meta', organization_id: null }, + { user_id: 'u_ex', permission_set_id: 'ps_meta', permission_set: 'alpha_metadata_editors', organization_id: ALPHA }, + { user_id: 'u_gone', permission_set_id: 'ps_meta', permission_set: 'alpha_metadata_editors', organization_id: ALPHA }, + { user_id: 'u_member', permission_set_id: 'ps_meta', permission_set: 'alpha_metadata_editors', organization_id: ALPHA }, + { user_id: 'u_global_ex', permission_set_id: 'ps_meta', permission_set: 'alpha_metadata_editors', organization_id: null }, ], sys_permission_set: [{ id: 'ps_meta', name: 'alpha_metadata_editors', system_permissions: ['manage_metadata'] }], }); @@ -2076,7 +2080,7 @@ describe('[#20515] with no active organization, only global grants apply', () => sys_user: [{ id: 'u_admin', email: 'admin@x.com' }], sys_member: [{ user_id: 'u_admin', organization_id: ALPHA, role: 'owner' }], sys_user_position: [], - sys_user_permission_set: [{ user_id: 'u_admin', permission_set_id: 'ps_admin', organization_id: organizationId }], + sys_user_permission_set: [{ user_id: 'u_admin', permission_set_id: 'ps_admin', permission_set: 'admin_full_access', organization_id: organizationId }], sys_permission_set: [{ id: 'ps_admin', name: 'admin_full_access', system_permissions: ['manage_metadata'] }], }); diff --git a/packages/core/src/security/resolve-authz-context.ts b/packages/core/src/security/resolve-authz-context.ts index fed2632781c..561b08dadda 100644 --- a/packages/core/src/security/resolve-authz-context.ts +++ b/packages/core/src/security/resolve-authz-context.ts @@ -705,6 +705,109 @@ 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 — + * `NULL` or blank, which is a grant written before the column existed that the + * one-time backfill has not named yet, or one it could not name (an id with no + * set row, or a set row of another organization). + * + * A grant that names nothing confers nothing through this resolver. The same + * rule the `plugin-security` and `plugin-auth` grant readers apply, each + * package holding its own internal copy (`grantSetNameOf`): this package cannot + * import them, and they import this one. + */ +function grantSetNameOf(row: unknown): string | undefined { + if (!row || typeof row !== 'object') return undefined; + const value = (row as Record).permission_set; + if (typeof value !== 'string') return undefined; + const name = value.trim(); + return name === '' ? undefined : name; +} + +/** + * The organization a stored grant or catalog row belongs to, or `null` for one + * that belongs to none — a blank value is the legacy organization-less + * spelling, read as {@link grantAppliesInTenant} reads it. + */ +function grantOrganizationOf(row: unknown): string | null { + if (!row || typeof row !== 'object') return null; + const r = row as Record; + const value = r.organization_id ?? r.organizationId; + if (value === null || value === undefined || value === '') return null; + return String(value); +} + +/** + * [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. + * + * 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`. + * + * 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`). + */ +async function readGrantSetRowsByName( + 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); + } + if (allNames.size === 0) return () => undefined; + + 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); + }; +} + /** * resolveUserAuthzGrants — the userId-driven core of {@link resolveAuthzContext}. * @@ -923,22 +1026,24 @@ export async function resolveUserAuthzGrants( // with no active org, only the global rows — {@link grantAppliesInTenant}). // 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. // (read in Leg 1 above) const upsRows = upsRowsAll.filter((r) => isGrantActive(r, nowMs)); - const psIds = new Set( - upsRows - .filter((r) => grantAppliesInTenant(r.organization_id ?? r.organizationId, tenantId)) - .map((r) => r.permission_set_id ?? r.permissionSetId) - .filter(Boolean), - ); - // platform_admin (ADR-0068 D2) is DERIVED from an UNSCOPED admin_full_access - // USER grant — the single source of truth (no trusted stored boolean). - const unscopedUserPsIds = new Set( - upsRows - .filter((r) => ((r.organization_id ?? r.organizationId) ?? null) === null) - .map((r) => r.permission_set_id ?? r.permissionSetId) - .filter(Boolean), - ); + 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(); // [#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 @@ -1048,32 +1153,58 @@ 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); + }; + for (const grant of applyingGrants) { + const row = setRowOfGrant(grant); + if (!row) continue; + take(row); + // 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. + // + // [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. + if ( + grantOrganizationOf(grant) === null + && row.name === ADMIN_FULL_ACCESS + && isRowActive(row) + && !legacyGrantAnchorRetired + ) { + hasPlatformAdminGrant = true; + } + } if (psIds.size > 0) { - const psRowsAll = await tryFind(ql, 'sys_permission_set', { id: { $in: Array.from(psIds) } }, 500); + for (const row of await tryFind(ql, PERMISSION_SET_CATALOG, { id: { $in: Array.from(psIds) } }, 500)) take(row); + } + 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, so `hasPlatformAdminGrant` - // cannot be derived from a set that no longer grants either: a deactivated - // `admin_full_access` must not keep conferring PLATFORM_ADMIN. + // same discipline the validity window gets at §6. const psRows = psRowsAll.filter((r) => isRowActive(r)); 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); - // [#11663 L5] `legacyGrantAnchorRetired` gates the ANCHOR, not the grant: the - // set's own name and capabilities are pushed above 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. - if ( - ps.name === ADMIN_FULL_ACCESS - && unscopedUserPsIds.has(ps.id) - && !legacyGrantAnchorRetired - ) { - hasPlatformAdminGrant = true; - } const sysPerms = typeof ps.system_permissions === 'string' ? safeJsonParse(ps.system_permissions, []) : (ps.system_permissions ?? ps.systemPermissions); @@ -1217,8 +1348,9 @@ export async function resolveUserAuthzGrants( * Everything that governs those grants therefore applies here by construction * and cannot drift from it — the ADR-0091 validity window (§6), the ADR-0049 * `active` flag on the catalogue row (§6b), the system-identity read, and the - * resolution of `admin_full_access` BY ID rather than by scanning a page of the - * catalogue. Each of those was missing from a hand-written copy of this + * resolution of the grant's `admin_full_access` by the name it carries, on the + * organization-less catalogue row ([ADR-0131] D4), rather than by scanning a + * page of the catalogue. Each of those was missing from a hand-written copy of this * predicate; none of them can be missing from a projection. * * ⛔ Read the RUNG — never `positions.includes(BUILTIN_IDENTITY_PLATFORM_ADMIN)`. 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 7b03fe7f263..42167bc9f0c 100644 --- a/packages/core/src/security/resolve-user-grants-cache.test.ts +++ b/packages/core/src/security/resolve-user-grants-cache.test.ts @@ -73,6 +73,8 @@ function makeSeamQl(tables: Record) { Object.entries(where ?? {}).every(([k, v]) => { if (k.startsWith('$')) throw new Error(`fake driver: unsupported operator ${k}`); if (v && typeof v === 'object' && '$in' in (v as any)) return (v as any).$in.includes(row[k]); + // An absent column reads as NULL, as it does in SQL (`organization_id: null`). + if (v === null) return (row[k] ?? null) === null; return row[k] === v; }); async function writeOp(object: string, operation: 'insert' | 'update' | 'delete', executor: () => void): Promise { @@ -136,7 +138,7 @@ function adminTables(): Record { sys_member: [{ user_id: 'u1', organization_id: 'org_a', role: 'member' }], sys_user_position: [], sys_user_permission_set: [ - { user_id: 'u1', permission_set_id: 'ps_admin', organization_id: null }, + { user_id: 'u1', permission_set_id: 'ps_admin', permission_set: 'admin_full_access', organization_id: null }, ], sys_permission_set: [{ id: 'ps_admin', name: 'admin_full_access' }], sys_position: [], @@ -284,7 +286,7 @@ describe('pin 2 — read-after-write on the writing node (invalidation, not TTL) expect(before.permissions).not.toContain('admin_full_access'); await ql.insert('sys_user_permission_set', { - user_id: 'u1', permission_set_id: 'ps_admin', organization_id: null, + user_id: 'u1', permission_set_id: 'ps_admin', permission_set: 'admin_full_access', organization_id: null, }); const after = await resolveUserAuthzGrants(ql, 'u1', OPTS); @@ -554,8 +556,8 @@ describe('[#20515] with the cache on, a no-tenant resolution never serves an org sys_member: [], sys_user_position: [], sys_user_permission_set: [ - { user_id: 'u1', permission_set_id: 'ps_org', organization_id: 'org_a' }, - { user_id: 'u1', permission_set_id: 'ps_global', organization_id: null }, + { user_id: 'u1', permission_set_id: 'ps_org', permission_set: 'org_a_editors', organization_id: 'org_a' }, + { user_id: 'u1', permission_set_id: 'ps_global', permission_set: 'everyone_readers', organization_id: null }, ], sys_permission_set: [ { id: 'ps_org', name: 'org_a_editors', system_permissions: ['manage_metadata'] }, 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 b75bdbf35bf..10c21d1a5db 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 @@ -137,6 +137,8 @@ function makeAuthzQl(tables: Record>>) { Object.entries(where ?? {}).every(([k, v]) => { if (k.startsWith('$')) throw new Error(`fake driver: unsupported operator ${k}`); if (v && typeof v === 'object' && '$in' in (v as any)) return (v as any).$in.includes(row[k]); + // An absent column reads as NULL, as it does in SQL (`organization_id: null`). + if (v === null) return (row[k] ?? null) === null; return row[k] === v; }); return { @@ -170,7 +172,7 @@ function authzTables(shape: Shape) { sys_position_permission_set: [], sys_user_permission_set: shape === 'genuine' - ? [{ user_id: ATTACKER, permission_set_id: PS_ADMIN, organization_id: null }] + ? [{ user_id: ATTACKER, permission_set_id: PS_ADMIN, permission_set: ADMIN_FULL_ACCESS, organization_id: null }] : [], sys_permission_set: [{ id: PS_ADMIN, name: ADMIN_FULL_ACCESS, active: true }], }; 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 033ffce0265..ae455f41d09 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 @@ -152,6 +152,8 @@ function makeAuthzQl(tables: Record>>) { Object.entries(where ?? {}).every(([k, v]) => { if (k.startsWith('$')) throw new Error(`fake driver: unsupported operator ${k}`); if (v && typeof v === 'object' && '$in' in (v as any)) return (v as any).$in.includes(row[k]); + // An absent column reads as NULL, as it does in SQL (`organization_id: null`). + if (v === null) return (row[k] ?? null) === null; return row[k] === v; }); return { @@ -189,7 +191,7 @@ function authzTables(shape: Shape) { sys_position_permission_set: [], sys_user_permission_set: shape === 'genuine' - ? [{ user_id: ACTOR, permission_set_id: PS_ORG_ADMIN, organization_id: ORG }] + ? [{ user_id: ACTOR, permission_set_id: PS_ORG_ADMIN, permission_set: ORGANIZATION_ADMIN, organization_id: ORG }] : [], sys_permission_set: [{ id: PS_ORG_ADMIN, name: ORGANIZATION_ADMIN, active: true }], }; diff --git a/packages/plugins/plugin-auth/src/admin-has-permission-endpoint.test.ts b/packages/plugins/plugin-auth/src/admin-has-permission-endpoint.test.ts index ea2d04826a3..a6bb07affcc 100644 --- a/packages/plugins/plugin-auth/src/admin-has-permission-endpoint.test.ts +++ b/packages/plugins/plugin-auth/src/admin-has-permission-endpoint.test.ts @@ -132,6 +132,7 @@ async function stage() { await engine.insert('sys_user_permission_set', { user_id: adminId, permission_set_id: PS_ADMIN, + permission_set: 'admin_full_access', organization_id: null, }); diff --git a/packages/plugins/plugin-auth/src/admin-impersonate-endpoint.test.ts b/packages/plugins/plugin-auth/src/admin-impersonate-endpoint.test.ts index d4834512e85..767b3bae726 100644 --- a/packages/plugins/plugin-auth/src/admin-impersonate-endpoint.test.ts +++ b/packages/plugins/plugin-auth/src/admin-impersonate-endpoint.test.ts @@ -111,6 +111,7 @@ const grantPlatformAdmin = async (engine: any, userId: string) => { await engine.insert('sys_user_permission_set', { user_id: userId, permission_set_id: PS_ADMIN, + permission_set: 'admin_full_access', organization_id: null, }); }; diff --git a/packages/plugins/plugin-auth/src/auth-manager.test.ts b/packages/plugins/plugin-auth/src/auth-manager.test.ts index 345955f357f..a8e0b9e5cd2 100644 --- a/packages/plugins/plugin-auth/src/auth-manager.test.ts +++ b/packages/plugins/plugin-auth/src/auth-manager.test.ts @@ -3507,7 +3507,7 @@ describe('AuthManager', () => { find: vi.fn(async (object: string, _query?: any) => { if (object === 'sys_user_permission_set') { return opts.platformAdmin - ? [{ user_id: 'u-1', permission_set_id: 'ps-admin', organization_id: null }] + ? [{ user_id: 'u-1', permission_set_id: 'ps-admin', permission_set: 'admin_full_access', organization_id: null }] : []; } if (object === 'sys_permission_set') { @@ -4800,7 +4800,7 @@ describe('isPlatformAdminUserId – the /sso/register criterion is platform-admi if (opts.throws) throw new Error('db down'); if (object === 'sys_user_permission_set') { return opts.platformAdmin - ? [{ user_id: 'u-1', permission_set_id: 'ps-admin', organization_id: opts.orgScopedGrant ? 'org-1' : null }] + ? [{ user_id: 'u-1', permission_set_id: 'ps-admin', permission_set: 'admin_full_access', organization_id: opts.orgScopedGrant ? 'org-1' : null }] : []; } if (object === 'sys_permission_set') return [{ id: 'ps-admin', name: 'admin_full_access' }]; 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 0cd3b2f7029..970ba4a70e5 100644 --- a/packages/plugins/plugin-auth/src/impersonation-bearer-rotation.test.ts +++ b/packages/plugins/plugin-auth/src/impersonation-bearer-rotation.test.ts @@ -66,6 +66,8 @@ export const createMemoryEngine = () => { if ('$lte' in v) return actual <= v.$lte; if ('$regex' in v) return new RegExp(String(v.$regex)).test(String(actual ?? '')); } + // An absent column reads as NULL, as it does in SQL (`organization_id: null`). + if (v === null) return actual === null || actual === undefined; return eq(actual, v); }); const project = (row: any, fields?: string[]) => { diff --git a/packages/plugins/plugin-auth/src/last-admin-guard.test.ts b/packages/plugins/plugin-auth/src/last-admin-guard.test.ts index 47cb6126387..74eb6211058 100644 --- a/packages/plugins/plugin-auth/src/last-admin-guard.test.ts +++ b/packages/plugins/plugin-auth/src/last-admin-guard.test.ts @@ -2180,6 +2180,57 @@ describe('[#8613] path 4, third spelling — deactivating the admin_full_access }); }); +// --------------------------------------------------------------------------- +// [ADR-0131 D4] Write shape (4), fourth spelling: MOVING the row into an +// organization +// +// The resolver finds an unscoped grant's `admin_full_access` by name on the +// ORGANIZATION-LESS row only. Stamping that row with an organization leaves it +// named and active, and still un-makes every grant-anchored platform admin — +// by a payload that touches neither `name` nor `active`. +// --------------------------------------------------------------------------- + +describe('[ADR-0131 D4] path 4, fourth spelling — moving the admin_full_access permission set into an organization', () => { + let engine: ObjectQL; + + beforeEach(async () => { + engine = await boot(); + await seedAdminPermissionSet(engine); + }); + + const move = (id: string, organizationId: string | null) => + engine.update('sys_permission_set', { id, organization_id: organizationId }, SYSTEM); + + const organizationOf = async (id: string): Promise => + ((await engine.findOne('sys_permission_set', { where: { id } }, SYSTEM)) as any)?.organization_id ?? null; + + it('moving it is refused and the row stays organization-less', async () => { + await seedUser(engine, 'usr_platform', { platformAdmin: true }); + + await expect(move(PS_ADMIN, 'org_elsewhere')).rejects.toMatchObject({ + code: 'PERMISSION_DENIED', + status: 403, + object: 'sys_permission_set', + }); + expect(await organizationOf(PS_ADMIN)).toBeNull(); + }); + + it('an org admin elsewhere keeps the move legal — the invariant is the ENVIRONMENT\'s', async () => { + await seedUser(engine, 'usr_platform', { platformAdmin: true }); + await seedUser(engine, 'usr_owner', { role: 'owner' }); + + await expect(move(PS_ADMIN, 'org_elsewhere')).resolves.toBeTruthy(); + }); + + it('moving ANOTHER permission set, or repeating the row’s own organization-less value, is unaffected', async () => { + await seedUser(engine, 'usr_platform', { platformAdmin: true }); + + await expect(move('ps_member', 'org_elsewhere')).resolves.toBeTruthy(); + await expect(move(PS_ADMIN, null)).resolves.toBeTruthy(); + expect(await organizationOf(PS_ADMIN)).toBeNull(); + }); +}); + describe('[#8613] a DEACTIVATED break-glass set is an emptied environment, not a fresh one', () => { /** * The state the third spelling leaves behind, reachable the same way #6084's diff --git a/packages/plugins/plugin-auth/src/last-admin-guard.ts b/packages/plugins/plugin-auth/src/last-admin-guard.ts index 6121ee3ed17..9c6e2313e8a 100644 --- a/packages/plugins/plugin-auth/src/last-admin-guard.ts +++ b/packages/plugins/plugin-auth/src/last-admin-guard.ts @@ -726,6 +726,12 @@ export const GRANT_STANDING_KEYS = [ * entirely. What now pays for an enumeration is the write that actually * toggles the switch — which is the write this list exists to judge. * + * [ADR-0131 D4] `organization_id`, because the resolver finds an unscoped + * grant's `admin_full_access` on the ORGANIZATION-LESS row only: moving that + * row into an organization un-makes every grant-anchored platform admin, the + * same end state as renaming or deleting it, reached by a payload that touches + * neither `name` nor `active`. Both spellings, as the grant list carries them. + * * `id` is deliberately NOT here even though the enumeration reads it. On this * engine `data.id` on an update ADDRESSES the row (it is what * `resolveTargetIds` resolves the target from) rather than proposing a new @@ -741,7 +747,7 @@ export const GRANT_STANDING_KEYS = [ * org-administrator standing is read from `sys_member.role`. Deactivating a * position cannot empty any of them. */ -export const PERMISSION_SET_STANDING_KEYS = ['name', 'active'] as const; +export const PERMISSION_SET_STANDING_KEYS = ['name', 'active', 'organization_id', 'organizationId'] as const; /** * [#11663 L2] Same, for `sys_user` — the FIFTH write shape, and the first one @@ -986,12 +992,19 @@ export function registerLastAdminGuard( // name off — an under-count, the direction this guard may round in. A // grant that names nothing is not counted at all: it holds no set by name, // and `refuseIfEmptiedRatherThanFresh` reads it as evidence instead. + // + // [ADR-0131 D4] Only an ORGANIZATION-LESS row puts the name in effect: the + // resolver finds an unscoped grant's set on the organization-less row alone, + // never on an organization's copy, so a pending write that moves that row + // into an organization (`organization_id`) takes the standing away exactly + // as deleting it does. A copy switched off still reads as the name switched + // off — the under-count above, unchanged. const legacyGrantAnchorRetired = postureEnforcesWall(resolveTenancyPosture()); const sets = legacyGrantAnchorRetired ? [] : await scan(op, SystemObjectName.PERMISSION_SET, { where: { name: ADMIN_FULL_ACCESS }, - fields: ['id', 'name', 'active'], + fields: ['id', 'name', 'active', 'organization_id'], }); let adminSetNamed = false; let adminSetSwitchedOff = false; @@ -1005,6 +1018,8 @@ export function registerLastAdminGuard( adminSetSwitchedOff = true; continue; } + // An organization's copy — no unscoped grant resolves to it. + if (set.organization_id ?? set.organizationId) continue; adminSetNamed = true; } if (adminSetNamed && !adminSetSwitchedOff) { 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 45451418a07..35ca9fd274b 100644 --- a/packages/plugins/plugin-auth/src/organization-add-member.test.ts +++ b/packages/plugins/plugin-auth/src/organization-add-member.test.ts @@ -87,6 +87,8 @@ const createMemoryEngine = () => { if ('$ne' in v) return !eq(actual, v.$ne); if ('$in' in v) return (v.$in as any[]).some((x) => eq(actual, x)); } + // An absent column reads as NULL, as it does in SQL (`organization_id: null`). + if (v === null) return actual === null || actual === undefined; return eq(actual, v); }); const project = (row: any, fields?: string[]) => { @@ -247,6 +249,7 @@ describe('#9941 POST /organization/add-member — mounted, gated, and it clears id: 'ups_admin', user_id: admin.userId, permission_set_id: 'ps_admin', + permission_set: 'admin_full_access', organization_id: null, }); 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 17338b72864..274db7beb90 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 @@ -203,6 +203,7 @@ const seedPlatformAdmin = async (engine: any, userId: string, shape: StandingSha await engine.insert('sys_user_permission_set', { user_id: userId, permission_set_id: PS_ADMIN, + permission_set: 'admin_full_access', organization_id: shape.orgScopedGrant ? 'org_pin' : null, ...(shape.expiredGrant ? { valid_until: new Date(Date.now() - 60_000).toISOString() } : {}), }); @@ -225,6 +226,7 @@ const seedTenantAdmin = async (engine: any, userId: string) => { await engine.insert('sys_user_permission_set', { user_id: userId, permission_set_id: PS_ORG_ADMIN, + permission_set: 'organization_admin', organization_id: 'org_pin', }); }; 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 b1693d1b072..78ee644fec9 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 @@ -178,7 +178,7 @@ const arrange = async () => { // The platform operator: an UNSCOPED admin_full_access user grant (the // single-posture anchor; no tenancy posture is configured here). await engine.insert('sys_permission_set', { id: 'ps_admin', name: 'admin_full_access' }); - await engine.insert('sys_user_permission_set', { user_id: operatorId, permission_set_id: 'ps_admin', organization_id: null }); + await engine.insert('sys_user_permission_set', { user_id: operatorId, permission_set_id: 'ps_admin', permission_set: 'admin_full_access', organization_id: null }); const bearers = { owner: bearerFrom(await signIn(manager, 'owner@example.com')), diff --git a/packages/plugins/plugin-auth/src/session-platform-admin-rung-agreement.test.ts b/packages/plugins/plugin-auth/src/session-platform-admin-rung-agreement.test.ts index fcc1604f147..0ef8353ddc7 100644 --- a/packages/plugins/plugin-auth/src/session-platform-admin-rung-agreement.test.ts +++ b/packages/plugins/plugin-auth/src/session-platform-admin-rung-agreement.test.ts @@ -127,6 +127,7 @@ const arrange = async (shape: 'name-only' | 'genuine') => { await engine.insert('sys_user_permission_set', { user_id: userId, permission_set_id: PS_ADMIN, + permission_set: 'admin_full_access', organization_id: null, }); } 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 0eb1af2dcfc..91ed9c85df6 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 @@ -115,6 +115,7 @@ const sysUserPermissionSet = { id: { name: 'id', type: 'text' as const, primaryKey: true }, user_id: { name: 'user_id', type: 'text' as const }, permission_set_id: { name: 'permission_set_id', type: 'text' as const }, + permission_set: { name: 'permission_set', type: 'text' as const }, organization_id: { name: 'organization_id', type: 'text' as const }, }, }; @@ -257,6 +258,7 @@ async function grantPlatformAdmin(engine: ObjectQL, userId: string): Promise = { sys_member: [{ user_id: USER, organization_id: ACTIVE_ORG, role: 'member' }], sys_user_position: [], sys_user_permission_set: [ - { id: 'ups1', user_id: USER, permission_set_id: 'ps_ops', organization_id: ACTIVE_ORG }, + { id: 'ups1', user_id: USER, permission_set_id: 'ps_ops', permission_set: GRANTED, organization_id: ACTIVE_ORG }, ], sys_position: [], sys_position_permission_set: [], diff --git a/packages/plugins/plugin-hono-server/src/current-user-endpoints-execution-context-envelope.test.ts b/packages/plugins/plugin-hono-server/src/current-user-endpoints-execution-context-envelope.test.ts index 08595d86bb8..496e1a53339 100644 --- a/packages/plugins/plugin-hono-server/src/current-user-endpoints-execution-context-envelope.test.ts +++ b/packages/plugins/plugin-hono-server/src/current-user-endpoints-execution-context-envelope.test.ts @@ -90,7 +90,7 @@ const TABLES: Record = { sys_member: [{ user_id: USER, organization_id: ACTIVE_ORG, role: 'member' }], sys_user_position: [], sys_user_permission_set: [ - { id: 'ups1', user_id: USER, permission_set_id: 'ps_ops', organization_id: ACTIVE_ORG }, + { id: 'ups1', user_id: USER, permission_set_id: 'ps_ops', permission_set: GRANTED, organization_id: ACTIVE_ORG }, ], sys_position: [], sys_position_permission_set: [], 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 09fcf0ad621..45197491409 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 @@ -225,7 +225,7 @@ describe('position-bound permission sets reach /auth/me/permissions (#6334)', () const app = mount({ tables: baseTables({ sys_user_permission_set: [ - { id: 'ups1', user_id: USER, permission_set_id: 'ps_direct', organization_id: null }, + { id: 'ups1', user_id: USER, permission_set_id: 'ps_direct', permission_set: 'showcase_direct', organization_id: null }, ], }), }); 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 e163fa47a94..87381544503 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 @@ -27,15 +27,17 @@ const makeQl = () => ({ find: async (object: string, opts: any) => { const where = opts?.where ?? {}; if (object === 'sys_user_permission_set') { - if (where.user_id === 'admin1') return [{ permission_set_id: 'ps_admin', organization_id: null }]; - if (where.user_id === 'member1') return [{ permission_set_id: 'ps_member', organization_id: null }]; + if (where.user_id === 'admin1') return [{ permission_set_id: 'ps_admin', permission_set: 'admin_full_access', organization_id: null }]; + if (where.user_id === 'member1') return [{ permission_set_id: 'ps_member', permission_set: 'member_default', organization_id: null }]; return []; } if (object === 'sys_permission_set') { + // By id (a position binding) or by name (a user grant, ADR-0131 D4). const ids: string[] = where.id?.$in ?? []; + const names: string[] = where.name?.$in ?? []; return [ - ids.includes('ps_admin') ? { id: 'ps_admin', name: 'admin_full_access' } : null, - ids.includes('ps_member') ? { id: 'ps_member', name: 'member_default' } : null, + ids.includes('ps_admin') || names.includes('admin_full_access') ? { id: 'ps_admin', name: 'admin_full_access' } : null, + ids.includes('ps_member') || names.includes('member_default') ? { id: 'ps_member', name: 'member_default' } : null, ].filter(Boolean); } if (object === 'widget') return [{ id: '1', name: 'w' }]; 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 62568039c0d..5d3e0678d96 100644 --- a/packages/plugins/plugin-security/src/explain-enforce-parity.test.ts +++ b/packages/plugins/plugin-security/src/explain-enforce-parity.test.ts @@ -414,14 +414,15 @@ async function bootPrincipal(source: PostureSource) { await catalogue(`ps_user_admin_${n}`, 'qa_user_admin', null, {}); await catalogue(`ps_reader_${n}`, READER_SET, null, {}); await catalogue(`ps_alpha_notes_${n}`, ALPHA_ONLY_SET, ALPHA, { [NOTES]: { allowRead: true } }); - const grant = (user: string, set: string) => + // Each grant carries the set's name beside its id, as every platform writer stores it. + const grant = (user: string, set: string, name: string) => e.insert('sys_user_permission_set', { - id: `ups_${user}_${set}_${n}`, user_id: user, permission_set_id: set, organization_id: ALPHA, + id: `ups_${user}_${set}_${n}`, user_id: user, permission_set_id: set, permission_set: name, organization_id: ALPHA, }, SYS); - await grant(USER_ADMIN, `ps_user_admin_${n}`); + await grant(USER_ADMIN, `ps_user_admin_${n}`, 'qa_user_admin'); for (const u of [USER_MEMBER, USER_REMOVED]) { - await grant(u, `ps_reader_${n}`); - await grant(u, `ps_alpha_notes_${n}`); + await grant(u, `ps_reader_${n}`, READER_SET); + await grant(u, `ps_alpha_notes_${n}`, ALPHA_ONLY_SET); } await e.insert(LEDGER, { id: 'l_alpha', name: 'alpha row', organization_id: ALPHA }, SYS); await e.insert(LEDGER, { id: 'l_beta', name: 'beta row', organization_id: BETA }, SYS); diff --git a/packages/plugins/plugin-security/src/explain-engine.test.ts b/packages/plugins/plugin-security/src/explain-engine.test.ts index 96b9ca5b55f..38ad24d5002 100644 --- a/packages/plugins/plugin-security/src/explain-engine.test.ts +++ b/packages/plugins/plugin-security/src/explain-engine.test.ts @@ -893,14 +893,14 @@ const NOW = Date.parse('2026-07-10T12:00:00Z'); describe('buildContextForUser', () => { const ql = makeGrantQl({ sys_user_position: [{ user_id: 'u2', position: 'hr_specialist' }], - sys_user_permission_set: [{ user_id: 'u2', permission_set_id: 'ps1' }], + sys_user_permission_set: [{ user_id: 'u2', permission_set_id: 'ps1', permission_set: 'payroll_reader' }], sys_permission_set: [{ id: 'ps1', name: 'payroll_reader' }], }); it('derives hasPlatformAdminGrant from an UNSCOPED admin_full_access user grant (matches resolveAuthzContext)', async () => { const qlUnscoped = makeGrantQl({ // organization_id absent → unscoped - sys_user_permission_set: [{ user_id: 'u2', permission_set_id: 'psAdmin' }], + sys_user_permission_set: [{ user_id: 'u2', permission_set_id: 'psAdmin', permission_set: 'admin_full_access' }], sys_permission_set: [{ id: 'psAdmin', name: 'admin_full_access' }], }); const ctx = await buildContextForUser(qlUnscoped, 'u2'); @@ -942,7 +942,7 @@ describe('buildContextForUser', () => { it('a SCOPED (org-specific) admin_full_access user grant does NOT set hasPlatformAdminGrant', async () => { const qlScoped = makeGrantQl({ - sys_user_permission_set: [{ user_id: 'u2', permission_set_id: 'psAdmin', organization_id: 'org1' }], + sys_user_permission_set: [{ user_id: 'u2', permission_set_id: 'psAdmin', permission_set: 'admin_full_access', organization_id: 'org1' }], sys_permission_set: [{ id: 'psAdmin', name: 'admin_full_access' }], }); // [#20515] Explained IN org1: a grant scoped to an organization applies @@ -1085,7 +1085,7 @@ describe('buildContextForUser', () => { const qlAbsent = makeGrantQl({ sys_user_position: [{ user_id: 'u2', position: 'hr_specialist' }], sys_position: [{ id: 'pos_hr', name: 'hr_specialist' }], - sys_user_permission_set: [{ user_id: 'u2', permission_set_id: 'ps1' }], + sys_user_permission_set: [{ user_id: 'u2', permission_set_id: 'ps1', permission_set: 'payroll_reader' }], sys_permission_set: [{ id: 'ps1', name: 'payroll_reader' }], }); const ctx = await buildContextForUser(qlAbsent, 'u2', NOW); @@ -1140,7 +1140,7 @@ describe('buildContextForUser ↔ resolveUserAuthzGrants parity (#6352)', () => sys_user_position: [{ user_id: 'u2', position: 'hr_specialist' }], sys_position: [{ id: 'pos_hr', name: 'hr_specialist' }], sys_position_permission_set: [{ position_id: 'pos_hr', permission_set_id: 'ps_hr_tools' }], - sys_user_permission_set: [{ user_id: 'u2', permission_set_id: 'ps1' }], + sys_user_permission_set: [{ user_id: 'u2', permission_set_id: 'ps1', permission_set: 'payroll_reader' }], sys_permission_set: [ { id: 'ps1', name: 'payroll_reader' }, { id: 'ps_hr_tools', name: 'hr_tools', system_permissions: ['manage_users'] }, @@ -1179,7 +1179,7 @@ describe('buildContextForUser ↔ resolveUserAuthzGrants parity (#6352)', () => // [ADR-0068 D2] The platform_admin derivation, both polarities. name: 'unscoped admin_full_access derives platform_admin', tables: { - sys_user_permission_set: [{ user_id: 'u2', permission_set_id: 'psAdmin' }], + sys_user_permission_set: [{ user_id: 'u2', permission_set_id: 'psAdmin', permission_set: 'admin_full_access' }], sys_permission_set: [{ id: 'psAdmin', name: 'admin_full_access' }], }, expected: { @@ -1197,7 +1197,7 @@ describe('buildContextForUser ↔ resolveUserAuthzGrants parity (#6352)', () => // organization it would not resolve at all, and the case would pin nothing. tenantId: 'org1', tables: { - sys_user_permission_set: [{ user_id: 'u2', permission_set_id: 'psAdmin', organization_id: 'org1' }], + sys_user_permission_set: [{ user_id: 'u2', permission_set_id: 'psAdmin', permission_set: 'admin_full_access', organization_id: 'org1' }], sys_permission_set: [{ id: 'psAdmin', name: 'admin_full_access' }], }, expected: { @@ -1218,7 +1218,7 @@ describe('buildContextForUser ↔ resolveUserAuthzGrants parity (#6352)', () => tenantId: 'org1', tables: { sys_member: [{ user_id: 'u2', organization_id: 'org1', role: 'admin' }], - sys_user_permission_set: [{ user_id: 'u2', permission_set_id: 'psOrg', organization_id: 'org1' }], + sys_user_permission_set: [{ user_id: 'u2', permission_set_id: 'psOrg', permission_set: 'organization_admin', organization_id: 'org1' }], sys_permission_set: [{ id: 'psOrg', name: 'organization_admin' }], }, expected: { @@ -1242,8 +1242,8 @@ describe('buildContextForUser ↔ resolveUserAuthzGrants parity (#6352)', () => { user_id: 'u2', position: 'auditor', valid_from: '2026-08-01T00:00:00Z' }, ], sys_user_permission_set: [ - { user_id: 'u2', permission_set_id: 'ps1' }, - { user_id: 'u2', permission_set_id: 'psAdmin', valid_until: '2026-06-01T00:00:00Z' }, + { user_id: 'u2', permission_set_id: 'ps1', permission_set: 'payroll_reader' }, + { user_id: 'u2', permission_set_id: 'psAdmin', permission_set: 'admin_full_access', valid_until: '2026-06-01T00:00:00Z' }, ], sys_permission_set: [ { id: 'ps1', name: 'payroll_reader' }, @@ -1469,7 +1469,7 @@ describe('buildContextForUser bypasses the #11971 grants cache (ruled bypass lis process.env[TTL_ENV] = '3600000'; // one hour — nothing here may pass by expiry try { const tables: Rows = { - sys_user_permission_set: [{ user_id: 'u2', permission_set_id: 'psAdmin' }], + sys_user_permission_set: [{ user_id: 'u2', permission_set_id: 'psAdmin', permission_set: 'admin_full_access' }], sys_permission_set: [{ id: 'psAdmin', name: 'admin_full_access' }], }; const ql = makeSeamGrantQl(tables); @@ -1788,9 +1788,9 @@ describe('[#20515] buildContextForUser and resolveDelegatorContext resolve in an sys_user: [{ id: 'u_x', email: 'u_x@example.com' }], sys_member: [{ user_id: 'u_x', organization_id: BETA, role: 'member' }], sys_user_permission_set: [ - { user_id: 'u_x', permission_set_id: 'ps_alpha', organization_id: ALPHA }, - { user_id: 'u_x', permission_set_id: 'ps_beta', organization_id: BETA }, - { user_id: 'u_x', permission_set_id: 'ps_global' }, + { user_id: 'u_x', permission_set_id: 'ps_alpha', permission_set: 'alpha_metadata_editors', organization_id: ALPHA }, + { user_id: 'u_x', permission_set_id: 'ps_beta', permission_set: 'beta_readers', organization_id: BETA }, + { user_id: 'u_x', permission_set_id: 'ps_global', permission_set: 'global_readers' }, ], sys_permission_set: [ { id: 'ps_alpha', name: 'alpha_metadata_editors', system_permissions: ['manage_metadata'] }, 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 d86b686c69f..5cb58cf5a32 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 @@ -108,6 +108,8 @@ function makeAuthzQl(tables: Record>>) { Object.entries(where ?? {}).every(([k, v]) => { if (k.startsWith('$')) throw new Error(`fake driver: unsupported operator ${k}`); if (v && typeof v === 'object' && '$in' in (v as any)) return (v as any).$in.includes(row[k]); + // An absent column reads as NULL, as it does in SQL (`organization_id: null`). + if (v === null) return (row[k] ?? null) === null; return row[k] === v; }); return { @@ -134,7 +136,7 @@ function authzTables(shape: 'name-only' | 'genuine') { sys_position_permission_set: [], sys_user_permission_set: shape === 'genuine' - ? [{ user_id: USER, permission_set_id: PS_ADMIN, organization_id: null }] + ? [{ user_id: USER, permission_set_id: PS_ADMIN, permission_set: ADMIN_FULL_ACCESS, organization_id: null }] : [], sys_permission_set: [{ id: PS_ADMIN, name: ADMIN_FULL_ACCESS, active: true }], }; 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 8f7d29a023b..7f7afd28383 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 @@ -156,11 +156,12 @@ async function boot(makeDriver: () => Driver, posture: TenancyPosture) { // Grant rows, each scoped to org_alpha. Removing a member deletes their // `sys_member` row, not their grants. await e.insert('sys_user_permission_set', { - id: `ups_admin_${n}`, user_id: USER_ADMIN, permission_set_id: `ps_user_admin_${n}`, organization_id: ALPHA, + id: `ups_admin_${n}`, user_id: USER_ADMIN, permission_set_id: `ps_user_admin_${n}`, permission_set: 'qa_user_admin', + organization_id: ALPHA, }, SYS); for (const u of [USER_MEMBER, USER_REMOVED]) { await e.insert('sys_user_permission_set', { - id: `ups_${u}_${n}`, user_id: u, permission_set_id: `ps_probe_${n}`, organization_id: ALPHA, + id: `ups_${u}_${n}`, user_id: u, permission_set_id: `ps_probe_${n}`, permission_set: PROBE_SET, organization_id: ALPHA, }, SYS); } await e.insert(PROBE, { id: 'p1', name: 'probe row' }, SYS); 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 e0788757d8d..63bf8516de5 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 @@ -364,14 +364,25 @@ describe('[ADR-0131 D4] grant name backfill — every name agrees with the id an } }); -describe('[ADR-0131 D4] grant name backfill — no principal’s grants change', () => { +describe('[ADR-0131 D4] grant name backfill — every principal’s grants are back once the names are', () => { for (const posture of ['single', 'isolated'] as const) { - it(`${posture}: platform administrator, organization administrator, member and agent resolve to the golden before and after`, async () => { + it(`${posture}: an unnamed grant confers nothing through the resolver; after the backfill every principal resolves to the golden`, async () => { const world = await boot(posture); await seedPrincipals(world, posture); await clearAllNames(world.engine); - expect(await grantsByPrincipal(world.engine)).toEqual(GOLDEN[posture]); + // The resolver reads a grant's set by its name (ADR-0131 C2 stage S5a), + // so a grant the backfill has not named yet confers nothing. + const unnamed = (await grantsByPrincipal(world.engine)) as Record; + expect(unnamed.member.permissions).not.toContain('viewer_readonly'); + expect(unnamed.organizationAdmin.permissions).toEqual([]); + expect(unnamed.organizationAdmin.posture).toBe('MEMBER'); + if (posture === 'single') { + // The promoted first user's grant row is the anchor; walled, the + // declared owner is, and no grant row carries it. + expect(unnamed.platformAdmin.permissions).not.toContain('admin_full_access'); + expect(unnamed.platformAdmin.posture).not.toBe('PLATFORM_ADMIN'); + } const outcome = await runOneTimeGrantPermissionSetNameBackfill(world.engine as any, { catalog: world.catalog, logger: newLogger(), }); @@ -675,8 +686,8 @@ describe('[ADR-0131 D4] grant name backfill — boot wiring', () => { /** * The resolver's envelope per principal — recorded from the tree BEFORE the - * name column existed (the S4a grant-equivalence golden), and unchanged by - * clearing the names or by the backfill: no reader reads the name yet. + * name column existed (the S4a grant-equivalence golden). The backfill brings + * every principal back to it once the resolver reads the name (stage S5a). */ const GOLDEN: Record = { single: { diff --git a/packages/plugins/plugin-security/src/grant-permission-set-name.test.ts b/packages/plugins/plugin-security/src/grant-permission-set-name.test.ts index 9c6f0651554..4064ecb57a8 100644 --- a/packages/plugins/plugin-security/src/grant-permission-set-name.test.ts +++ b/packages/plugins/plugin-security/src/grant-permission-set-name.test.ts @@ -360,6 +360,80 @@ describe("walled posture, two organizations — the name is read from the WRITER }); }); +// --------------------------------------------------------------------------- +// A name never crosses organizations — judged at write time +// --------------------------------------------------------------------------- + +describe("a name never crosses organizations — the set row must be the grant's own organization's or organization-less", () => { + /** A tenant-less system writer: its catalog read is not walled, so it can read every organization's set. */ + const TENANTLESS = SYS; + const B_SET = { + id: 'ps_b_only', name: 'qa_b_only', label: 'B only', object_permissions: '{}', field_permissions: '{}', system_permissions: '[]', active: true, + }; + const GLOBAL_SET = { + id: 'ps_global', name: 'qa_global', label: 'Global', object_permissions: '{}', field_permissions: '{}', system_permissions: '[]', active: true, + }; + async function walledWithSets() { + const h = await boot({ walled: true }); + await h.engine.insert('sys_permission_set', B_SET, { context: { isSystem: true, tenantId: 'org_b' } } as any); + await h.engine.insert('sys_permission_set', GLOBAL_SET, { context: SYS } as any); + return h; + } + + it('an organization-less grant pointing at another organization’s set is stored UNNAMED', async () => { + const h = await walledWithSets(); + await h.engine.insert('sys_user_permission_set', { user_id: 'u_x1', permission_set_id: 'ps_b_only' }, { context: TENANTLESS } as any); + const [row] = await grantsOf(h, 'u_x1'); + expect(row).toMatchObject({ permission_set_id: 'ps_b_only', organization_id: null }); + expect(row.permission_set ?? null).toBeNull(); + }); + + it('NEGATIVE — a supplied name of another organization’s set is refused, from a system writer too; nothing is stored', async () => { + const h = await walledWithSets(); + const err = await refusalOf(() => h.engine.insert( + 'sys_user_permission_set', { user_id: 'u_x2', permission_set_id: 'ps_b_only', permission_set: 'qa_b_only' }, { context: TENANTLESS } as any, + )); + expectNameRefusal(err, 'qa_b_only'); + expect(await grantsOf(h, 'u_x2')).toHaveLength(0); + }); + + it('CONTROL — the same writer naming an organization-less set, or an organization’s grant its own set, is stamped', async () => { + const h = await walledWithSets(); + await h.engine.insert('sys_user_permission_set', { user_id: 'u_x3', permission_set_id: 'ps_global' }, { context: TENANTLESS } as any); + expect((await grantsOf(h, 'u_x3'))[0]).toMatchObject({ permission_set: 'qa_global', organization_id: null }); + await h.engine.insert( + 'sys_user_permission_set', { user_id: 'u_x3', permission_set_id: 'ps_b_only', organization_id: 'org_b' }, { context: TENANTLESS } as any, + ); + const own = (await grantsOf(h, 'u_x3')).find((g) => g.organization_id === 'org_b'); + expect(own).toMatchObject({ permission_set: 'qa_b_only' }); + }); + + it('an update re-pointing an organization-less grant at another organization’s set clears its name', async () => { + const h = await walledWithSets(); + await h.engine.insert('sys_user_permission_set', { id: 'g_x4', user_id: 'u_x4', permission_set_id: 'ps_global' }, { context: TENANTLESS } as any); + expect((await grantsOf(h, 'u_x4'))[0]).toMatchObject({ permission_set: 'qa_global' }); + await h.engine.update('sys_user_permission_set', { id: 'g_x4', permission_set_id: 'ps_b_only' }, { context: TENANTLESS } as any); + const [row] = await grantsOf(h, 'u_x4'); + expect(row.permission_set_id).toBe('ps_b_only'); + expect(row.permission_set ?? null).toBeNull(); + }); + + it('moving a grant out of the organization its set belongs to clears the name; a name on an organization-less set moves with it', async () => { + const h = await walledWithSets(); + await h.engine.insert('sys_user_permission_set', [ + { id: 'g_x5', user_id: 'u_x5', permission_set_id: 'ps_b_only', organization_id: 'org_b' }, + { id: 'g_x6', user_id: 'u_x5', permission_set_id: 'ps_global', organization_id: 'org_b' }, + ], { context: TENANTLESS } as any); + await h.engine.update('sys_user_permission_set', { id: 'g_x5', organization_id: null }, { context: TENANTLESS } as any); + await h.engine.update('sys_user_permission_set', { id: 'g_x6', organization_id: null }, { context: TENANTLESS } as any); + const rows = await grantsOf(h, 'u_x5'); + const moved = rows.find((g) => g.id === 'g_x5'); + expect(moved?.organization_id ?? null).toBeNull(); + expect(moved?.permission_set ?? null).toBeNull(); + expect(rows.find((g) => g.id === 'g_x6')).toMatchObject({ permission_set: 'qa_global' }); + }); +}); + describe('registration', () => { it('both hooks bind under one package when the plugin starts, and unbind when it is destroyed', async () => { const h = await boot(); diff --git a/packages/plugins/plugin-security/src/grant-permission-set-name.ts b/packages/plugins/plugin-security/src/grant-permission-set-name.ts index 4b5e99c22b3..b2fbdc89864 100644 --- a/packages/plugins/plugin-security/src/grant-permission-set-name.ts +++ b/packages/plugins/plugin-security/src/grant-permission-set-name.ts @@ -36,7 +36,8 @@ * value (`null`, `''`) is not a clear: the derived name is written back, so a * grant can never be left naming nothing while its id resolves. * - **UPDATE writing neither column** leaves the name alone. No backfill rides - * an unrelated edit. + * an unrelated edit — except that a move to another organization clears a + * name whose set does not belong to it (below). * * The stamp is assigned IN PLACE on the hook payload, so the engine records it * as a hook write (`hookWrittenKeys`). That is what lets it survive the @@ -55,6 +56,25 @@ * that omits the name — the data door, a seed dataset, any caller outside this * repository — gets it stamped. * + * ## A name never crosses organizations + * + * The set row the id points at must be one the grant may name: a row of the + * grant's OWN organization, or an organization-less row — the grant rule the + * authorization resolver applies to every stored grant row, asked of the set + * row, and the rule the backfill applies (so an organization-less grant names + * only an organization-less set). The grant's organization is the one it is + * stored with: on an insert, the row's own value, else the organization the + * driver stamps it with (`organizationTheInsertStores`); on an update, the + * payload's value when it carries one, else the stored row's. + * + * A row the writer can read that the grant may not name — another + * organization's set, read by a writer whose read is not walled (a tenant-less + * system writer) — is never named after: a supplied name is refused, from + * every caller, system included, and no name is stamped, so the grant is + * stored unnamed and confers nothing through a by-name reader. An update that + * re-points the grant there, or moves the grant to an organization its set + * does not belong to, clears the name it held. + * * The one place the two contexts part is an id the catalog read cannot * resolve (no row, a row outside the writer's organization, a value that is * not an id, or no `sys_permission_set` object in this composition): @@ -75,8 +95,11 @@ * `sudo()`-shaped elevation the engine's lookup probe uses — never a bare * `{ isSystem: true }`. The elevation is about visibility (RBAC, RLS and FLS * do not decide which set an id names); the writer's organization still walls - * the read, so a set in another organization resolves nothing here, and the - * read joins the writer's transaction. A read that THROWS is not "no such + * the read, so a set in another organization resolves nothing here for a + * writer that has one, and the read joins the writer's transaction. A writer + * with NO organization reads unwalled — which is why the set row's own + * organization is judged against the grant's (above), and not left to the + * wall. A read that THROWS is not "no such * set": it propagates and the write is refused, because a grant landing * without its name is the silent half of a dual write. The refusal never * echoes the derived name, so it tells a caller nothing about a set it could @@ -97,6 +120,7 @@ import type { FieldErrorCode } from '@objectstack/spec/api'; import { classifyFilterToken, isPlainRecord } from '@objectstack/spec/data'; import { validationFailure } from '@objectstack/types'; import { SysUserPermissionSet } from './objects/sys-user-permission-set.object.js'; +import { organizationTheInsertStores } from './grant-holder-membership-refusal.js'; /** The grant object this module keeps consistent. */ export const GRANT_OBJECT = 'sys_user_permission_set'; @@ -313,8 +337,14 @@ export async function readGrantSetRows( }; } +/** The set row an id points at, as far as a grant's name is concerned. */ +interface CatalogRow { + name: string; + organizationId: string | null; +} + /** Per-write memo, keyed by the dispatch scope every hook dispatch of one caller write shares. */ -const memoByWrite = new WeakMap>(); +const memoByWrite = new WeakMap>(); interface GrantNameEngine { registerHook?: ( @@ -327,11 +357,11 @@ interface GrantNameEngine { } /** - * The name of the `sys_permission_set` row `id` points at, as the WRITER's - * catalog read sees it, or `undefined` when nothing resolves. Throws when the - * read itself fails — see the module note. + * The `sys_permission_set` row `id` points at — its name and organization — as + * the WRITER's catalog read sees it, or `undefined` when nothing resolves. + * Throws when the read itself fails — see the module note. */ -async function catalogNameFor(engine: GrantNameEngine, ctx: any, id: unknown): Promise { +async function catalogRowFor(engine: GrantNameEngine, ctx: any, id: unknown): Promise { const key = idKey(id); if (key === undefined) return undefined; // A composition without the catalog object has nothing to derive from — the @@ -339,7 +369,7 @@ async function catalogNameFor(engine: GrantNameEngine, ctx: any, id: unknown): P if (typeof engine.getSchema === 'function' && !engine.getSchema(PERMISSION_SET_CATALOG_OBJECT)) return undefined; const scope = ctx?.dispatch?.scope; - let memo: Map | undefined; + let memo: Map | undefined; if (scope && typeof scope === 'object') { memo = memoByWrite.get(scope); if (!memo) { @@ -360,11 +390,29 @@ async function catalogNameFor(engine: GrantNameEngine, ctx: any, id: unknown): P const rows = await api .sudo() .object(PERMISSION_SET_CATALOG_OBJECT) - .find({ where: { id: key }, fields: ['id', 'name'], limit: 1 }); + .find({ where: { id: key }, fields: ['id', 'name', 'organization_id'], limit: 1 }); const row = Array.isArray(rows) ? rows[0] : undefined; - const name = row && typeof row.name === 'string' && row.name !== '' ? row.name : undefined; - memo?.set(key, name); - return name; + const found: CatalogRow | undefined = + row && typeof row.name === 'string' && row.name !== '' + ? { name: row.name, organizationId: grantOrganizationOf(row) } + : undefined; + memo?.set(key, found); + return found; +} + +/** + * The grant rule, asked of the set row a grant would name: an + * organization-less row applies to every grant, a row of an organization only + * to a grant of that same organization. + */ +function setRowAppliesToGrant(setOrganizationId: string | null, grantOrganizationId: string | null): boolean { + return setOrganizationId === null || setOrganizationId === grantOrganizationId; +} + +/** A stored or written organization value, normalized the way {@link grantOrganizationOf} reads it. */ +function organizationValue(value: unknown): string | null { + if (value === null || value === undefined || value === '') return null; + return String(value); } /** What the caller supplied for the name column, or `undefined` when it supplied nothing. */ @@ -373,7 +421,8 @@ type Supplied = { value: unknown } | undefined; /** * Settle `permission_set` on one payload: `decidingId` is the id the name must * agree with (the payload's own, or the matched row's stored one), `supplied` - * the caller's own value for the column. + * the caller's own value for the column, `grantOrganizationId` the + * organization the grant is stored with once the write lands. */ async function settle( engine: GrantNameEngine, @@ -381,12 +430,20 @@ async function settle( data: Record, decidingId: unknown, supplied: Supplied, + grantOrganizationId: string | null, ): Promise { const suppliedName = supplied !== undefined && carriesName(supplied.value); - const derived = await catalogNameFor(engine, ctx, decidingId); - if (derived !== undefined) { - if (suppliedName && supplied!.value !== derived) throw grantSetNameMismatchError(supplied!.value); - data[GRANT_SET_NAME_FIELD] = derived; + const row = await catalogRowFor(engine, ctx, decidingId); + if (row !== undefined && setRowAppliesToGrant(row.organizationId, grantOrganizationId)) { + if (suppliedName && supplied!.value !== row.name) throw grantSetNameMismatchError(supplied!.value); + data[GRANT_SET_NAME_FIELD] = row.name; + return; + } + if (row !== undefined) { + // Another organization's set: its name is never carried across. Refused + // from every caller when supplied; otherwise the grant is left unnamed. + if (suppliedName) throw grantSetNameMismatchError(supplied!.value); + data[GRANT_SET_NAME_FIELD] = null; return; } // Unresolvable: a non-system caller's name cannot be shown to agree. @@ -399,7 +456,11 @@ async function onInsert(engine: GrantNameEngine, ctx: any): Promise { const data = ctx?.input?.data; if (!isPlainRecord(data)) return; const supplied: Supplied = hasOwn(data, GRANT_SET_NAME_FIELD) ? { value: data[GRANT_SET_NAME_FIELD] } : undefined; - await settle(engine, ctx, data, data[GRANT_SET_ID_FIELD], supplied); + const schema = typeof engine.getSchema === 'function' ? engine.getSchema(GRANT_OBJECT) : undefined; + const organizationId = organizationValue( + organizationTheInsertStores(schema ?? SysUserPermissionSet, data, ctx?.input?.options, ctx?.session?.organizationId), + ); + await settle(engine, ctx, data, data[GRANT_SET_ID_FIELD], supplied, organizationId); } /** @@ -425,18 +486,33 @@ async function onUpdate(engine: GrantNameEngine, ctx: any): Promise { : hasOwn(data, GRANT_SET_NAME_FIELD) ? { value: data[GRANT_SET_NAME_FIELD] } : undefined; + const previous = isPlainRecord(ctx?.previous) ? ctx.previous : undefined; + const movesOrganization = hasOwn(data, 'organization_id') || hasOwn(data, 'organizationId'); + const organizationId = movesOrganization + ? organizationValue(hasOwn(data, 'organization_id') ? data.organization_id : data.organizationId) + : grantOrganizationOf(previous); if (hasOwn(data, GRANT_SET_ID_FIELD)) { - await settle(engine, ctx, data, data[GRANT_SET_ID_FIELD], supplied); + await settle(engine, ctx, data, data[GRANT_SET_ID_FIELD], supplied, organizationId); return; } - if (supplied === undefined) return; - const previous = ctx?.previous; // No stored row is the engine's to answer (not found), never a name verdict. - if (!isPlainRecord(previous)) return; - // Judged against the stored id on every matched row, an echo included: the - // stamp is then the same key on every row a predicate write matches, which - // is what the engine's per-row key-set rule asks of a batch rewrite. - await settle(engine, ctx, data, previous[GRANT_SET_ID_FIELD], supplied); + if (!previous) return; + if (supplied !== undefined) { + // Judged against the stored id on every matched row, an echo included: the + // stamp is then the same key on every row a predicate write matches, which + // is what the engine's per-row key-set rule asks of a batch rewrite. + await settle(engine, ctx, data, previous[GRANT_SET_ID_FIELD], supplied, organizationId); + return; + } + // A move to another organization keeps the name only while the set it names + // still belongs to the grant's organization, or to none. No backfill rides + // the move: an unnamed grant stays unnamed, and a name that still applies is + // left as stored. + if (!movesOrganization || grantSetNameOf(previous) === undefined) return; + const row = await catalogRowFor(engine, ctx, previous[GRANT_SET_ID_FIELD]); + if (row !== undefined && !setRowAppliesToGrant(row.organizationId, organizationId)) { + data[GRANT_SET_NAME_FIELD] = null; + } } /** 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 075cdb1e8a6..5d69f4a6814 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 @@ -157,7 +157,8 @@ async function boot(): Promise { await insertSet(e, 'ps_0_other_platform_ops', 'platform_ops', ORG_OTHER, ['probe.other_platform_ops']); await insertSet(e, 'ps_1_global_platform_ops', 'platform_ops', null, ['probe.global_platform_ops']); await e.insert('sys_user_permission_set', { - id: 'ups_global_platform_ops', user_id: USER_GLOBAL, permission_set_id: 'ps_1_global_platform_ops', organization_id: null, + 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); return engine; } 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 new file mode 100644 index 00000000000..c642a3e396b --- /dev/null +++ b/packages/plugins/plugin-security/src/resolve-authz-grant-set-by-name.golden.test.ts @@ -0,0 +1,613 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [ADR-0131 D4] Grant-equivalence goldens for the authorization resolver + * (`resolveUserAuthzGrants`, `@objectstack/core`), whose §6 reads a user + * grant's permission set BY NAME — `sys_user_permission_set.permission_set` — + * instead of through `permission_set_id`. + * + * The resolver changes its key and not its answer. This suite boots a real + * ObjectQL engine over the SQL driver with the real `SecurityPlugin`, so every + * grant is written through the engine hooks that keep the two columns in step, + * and records what the resolver answers per principal: + * + * - the whole envelope with the organization active, and with none (the global + * grants alone); + * - platform standing as `hasPlatformAdminStanding` projects it. + * + * Principals: the platform administrator (`single`: the promoted first user, a + * grant row; walled: the declared `OS_PLATFORM_OWNER_EMAIL` owner, no row), the + * organization administrator (the owner membership, reconciled), a member (a + * set granted through the data door, plus a grant of a DEACTIVATED set) and an + * agent (an organization grant of an organization-less agent set inside its + * window, plus an EXPIRED one). Three postures: `single`, `group` and + * `isolated`. + * + * The golden below was recorded on the tree BEFORE the resolver moved to the + * name (the PR record carries that run) and is unchanged after it. Pointing one + * grant's name column at a different set turns it red with the change and + * leaves it green on the tree before — the PR record carries that ablation. + */ + +import { describe, it, expect, afterEach, vi } from 'vitest'; +import { ObjectQL } from '@objectstack/objectql'; +import { SqlDriver } from '@objectstack/driver-sql'; +import { hasPlatformAdminStanding, resetPlatformAdminEmailMemo, resolveUserAuthzGrants } from '@objectstack/core'; +import type { PermissionSet } from '@objectstack/spec/security'; +import { SysUser, SysAccount, SysMember, SysOrganization } from '@objectstack/platform-objects/identity'; + +import { SecurityPlugin } from './security-plugin.js'; +import { bootstrapPlatformAdmin } from './bootstrap-platform-admin.js'; +import { reconcileOrgAdminGrant } from './auto-org-admin-grant.js'; +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'; +import { SysPositionPermissionSet } from './objects/sys-position-permission-set.object.js'; +import { SysUserPermissionSet } from './objects/sys-user-permission-set.object.js'; +import { defaultPermissionSets } from './objects/default-permission-sets.js'; + +const SYS = { isSystem: true } as const; +const POSTURE_ENV = 'OS_TENANCY_POSTURE'; +const OWNER_ENV = 'OS_PLATFORM_OWNER_EMAIL'; +const ORG = 'org_rz'; +const EXPIRED = '2020-01-01T00:00:00.000Z'; +const FUTURE = '2099-01-01T00:00:00.000Z'; + +/** The non-system administrator whose writes are the data door's (superuser wildcard). */ +const QA_ADMIN = { + name: 'qa_admin', + label: 'QA Admin', + objects: { + '*': { + allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true, + viewAllRecords: true, modifyAllRecords: true, + }, + }, +} as unknown as PermissionSet; + +const engines: ObjectQL[] = []; +afterEach(async () => { + vi.restoreAllMocks(); + delete process.env[POSTURE_ENV]; + delete process.env[OWNER_ENV]; + resetPlatformAdminEmailMemo(); + while (engines.length) { + try { await engines.pop()?.destroy(); } catch { /* noop */ } + } +}); + +type Posture = 'single' | 'group' | 'isolated'; + +const sortDeep = (value: unknown): unknown => { + if (Array.isArray(value)) return value.map(sortDeep).sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b))); + if (value && typeof value === 'object') { + return Object.fromEntries(Object.keys(value as object).sort().map((k) => [k, sortDeep((value as any)[k])])); + } + return value; +}; + +async function resolverByPrincipal(posture: Posture): Promise> { + const walled = posture !== 'single'; + if (walled) { + process.env[POSTURE_ENV] = posture; + process.env[OWNER_ENV] = 'admin@rz.example'; + } + resetPlatformAdminEmailMemo(); + + const engine = new ObjectQL(); + engine.registerDriver( + new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }), + true, + ); + await engine.init(); + engine.registerApp({ + id: 'com.objectstack.qa.resolve-authz-grant-set-by-name', + name: 'Resolver grant set by name', + version: '1.0.0', + type: 'plugin', + scope: 'system', + objects: [ + SysUser, SysAccount, SysMember, SysOrganization, + SysPosition, SysUserPosition, SysPermissionSet, SysPositionPermissionSet, SysUserPermissionSet, + ], + } as any); + await engine.syncSchemas(); + engines.push(engine); + + const services: Record = { + manifest: { register: vi.fn() }, + objectql: engine, + metadata: { + get: async (_type: string, name: string) => engine.getSchema(name) ?? null, + list: async () => [...defaultPermissionSets, QA_ADMIN], + }, + ...(walled + ? { 'org-scoping': { name: 'com.objectstack.org-scoping' }, tenancy: { posture } } + : {}), + }; + const ctx: any = { + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, + hook: vi.fn(), + registerService: vi.fn(), + getService: (name: string) => { + if (!(name in services)) throw new Error(`service not registered: ${name}`); + return services[name]; + }, + }; + const plugin = new SecurityPlugin({ fallbackPermissionSet: 'member_default' }); + await plugin.init(ctx); + await plugin.start(ctx); + vi.spyOn((engine as any).logger, 'warn').mockImplementation(() => undefined); + + const orgCtx = { isSystem: true, tenantId: ORG }; + await engine.insert('sys_organization', { id: ORG, name: 'RZ Org', slug: 'rz' }, { context: SYS } as any); + const user = async (id: string, email: string, createdAt: string) => { + await engine.insert('sys_user', { id, email, name: id, created_at: createdAt, email_verified: true }, { context: SYS } as any); + await engine.insert( + 'sys_account', { id: `acc_${id}`, user_id: id, account_id: email, provider_id: 'credential' }, { context: SYS } as any, + ); + }; + await user('usr_admin', 'admin@rz.example', '2025-01-01T00:00:00.000Z'); + await user('usr_orgadmin', 'orgadmin@rz.example', '2025-02-01T00:00:00.000Z'); + await user('usr_member', 'member@rz.example', '2025-03-01T00:00:00.000Z'); + await user('usr_agent', 'agent@rz.example', '2025-04-01T00:00:00.000Z'); + + // The catalog and (single) the platform-admin promotion. + const boot = await bootstrapPlatformAdmin(engine, defaultPermissionSets); + expect(boot.adminPromoted).toBe(posture === 'single'); + + // Walled: the organization's own copies of the sets its grants point at. + if (walled) { + for (const name of ['organization_admin', 'organization_admin_no_bypass', 'viewer_readonly']) { + const [bucket] = await engine.find('sys_permission_set', { where: { name }, context: SYS }); + const { id: _id, created_at: _c, updated_at: _u, ...rest } = bucket as any; + await engine.insert('sys_permission_set', { ...rest, id: `ps_${name}_${ORG}`, organization_id: ORG }, { context: orgCtx } as any); + } + } + // A deactivated set the member still holds a grant of (ADR-0049). + await engine.insert( + 'sys_permission_set', + { + id: 'ps_legacy_reports', + name: 'legacy_reports', + label: 'Legacy reports', + active: false, + ...(walled ? { organization_id: ORG } : {}), + }, + { context: walled ? orgCtx : SYS } as any, + ); + + await engine.insert('sys_member', [ + { id: 'm_owner', user_id: 'usr_orgadmin', organization_id: ORG, role: 'owner', created_at: '2025-02-01T00:00:00.000Z' }, + { id: 'm_member', user_id: 'usr_member', organization_id: ORG, role: 'member', created_at: '2025-03-01T00:00:00.000Z' }, + { id: 'm_agent', user_id: 'usr_agent', organization_id: ORG, role: 'member', created_at: '2025-04-01T00:00:00.000Z' }, + ], { context: orgCtx } as any); + const reconciled = await reconcileOrgAdminGrant(engine, 'usr_orgadmin', ORG, { posture }); + expect(reconciled.action).toBe('granted'); + + // The member's set, granted through the data door by an administrator. + const [viewer] = await engine.find('sys_permission_set', { + where: { name: 'viewer_readonly', ...(walled ? { organization_id: ORG } : {}) }, + context: SYS, + }); + await engine.insert( + 'sys_user_permission_set', + { user_id: 'usr_member', permission_set_id: (viewer as any).id }, + { + context: { + userId: 'usr_orgadmin', positions: [], permissions: ['qa_admin'], tenantId: ORG, accessible_org_ids: [ORG], + }, + } as any, + ); + // System-written grants: the member's deactivated set, the agent's windowed + // and expired agent grants (the platform bucket's organization-less rows). + const setId = async (name: string) => + String(((await engine.find('sys_permission_set', { where: { name, organization_id: null }, context: SYS })) as any[])[0]?.id); + await engine.insert('sys_user_permission_set', [ + { id: 'ups_member_legacy', user_id: 'usr_member', permission_set_id: 'ps_legacy_reports', organization_id: ORG }, + { + id: 'ups_agent_read', user_id: 'usr_agent', permission_set_id: await setId('mcp_agent_data_read'), + organization_id: ORG, valid_until: FUTURE, + }, + { + id: 'ups_agent_write', user_id: 'usr_agent', permission_set_id: await setId('mcp_agent_data_write'), + organization_id: ORG, valid_until: EXPIRED, + }, + ], { context: orgCtx } as any); + + const principals = { + platformAdmin: 'usr_admin', + organizationAdmin: 'usr_orgadmin', + member: 'usr_member', + agent: 'usr_agent', + } as const; + const byPrincipal: Record = {}; + for (const [kind, userId] of Object.entries(principals)) { + byPrincipal[kind] = { + inOrganization: await resolveUserAuthzGrants(engine, userId, { tenantId: ORG }), + noOrganization: await resolveUserAuthzGrants(engine, userId, {}), + platformStanding: await hasPlatformAdminStanding(engine, userId), + }; + } + return sortDeep(byPrincipal) as Record; +} + +describe('[ADR-0131 D4] the resolver reads a grant’s set by name — every principal answers the recorded golden', () => { + for (const posture of ['single', 'group', 'isolated'] as const) { + it(`${posture}: the envelope in the organization and outside it, and platform standing, per principal`, async () => { + const actual = await resolverByPrincipal(posture); + expect(actual).toEqual(GOLDEN[posture]); + }); + } +}); + +/** + * Recorded on the tree before the resolver moved to the name; `group` and + * `isolated` were recorded identical, so they share one literal. `single` and + * the walled postures differ in the organization administrator’s set alone + * (the walled `organization_admin` against the wall-less + * `organization_admin_no_bypass`, ADR-0105 D4); platform standing is a grant + * row under `single` and the declared owner under a wall, and both answer the + * same. + */ +const SINGLE: unknown = { + agent: { + inOrganization: { + accessible_org_ids: [ + 'org_rz' + ], + email: 'agent@rz.example', + org_user_ids: [ + 'usr_agent', + 'usr_member', + 'usr_orgadmin' + ], + permissions: [ + 'mcp_agent_data_read' + ], + positions: [ + 'everyone', + 'org_member' + ], + posture: 'MEMBER', + systemPermissions: [] + }, + noOrganization: { + accessible_org_ids: [ + 'org_rz' + ], + email: 'agent@rz.example', + org_user_ids: [ + 'usr_agent' + ], + permissions: [], + positions: [ + 'everyone', + 'org_member' + ], + posture: 'MEMBER', + systemPermissions: [] + }, + platformStanding: false + }, + member: { + inOrganization: { + accessible_org_ids: [ + 'org_rz' + ], + email: 'member@rz.example', + org_user_ids: [ + 'usr_agent', + 'usr_member', + 'usr_orgadmin' + ], + permissions: [ + 'viewer_readonly' + ], + positions: [ + 'everyone', + 'org_member' + ], + posture: 'MEMBER', + systemPermissions: [] + }, + noOrganization: { + accessible_org_ids: [ + 'org_rz' + ], + email: 'member@rz.example', + org_user_ids: [ + 'usr_member' + ], + permissions: [], + positions: [ + 'everyone', + 'org_member' + ], + posture: 'MEMBER', + systemPermissions: [] + }, + platformStanding: false + }, + organizationAdmin: { + inOrganization: { + accessible_org_ids: [ + 'org_rz' + ], + email: 'orgadmin@rz.example', + org_user_ids: [ + 'usr_agent', + 'usr_member', + 'usr_orgadmin' + ], + permissions: [ + 'organization_admin_no_bypass' + ], + positions: [ + 'everyone', + 'org_owner' + ], + posture: 'TENANT_ADMIN', + systemPermissions: [ + 'manage_org_users', + 'setup.access', + 'setup.write' + ] + }, + noOrganization: { + accessible_org_ids: [ + 'org_rz' + ], + email: 'orgadmin@rz.example', + org_user_ids: [ + 'usr_orgadmin' + ], + permissions: [], + positions: [ + 'everyone', + 'org_owner' + ], + posture: 'MEMBER', + systemPermissions: [] + }, + platformStanding: false + }, + platformAdmin: { + inOrganization: { + accessible_org_ids: [], + email: 'admin@rz.example', + org_user_ids: [ + 'usr_admin', + 'usr_agent', + 'usr_member', + 'usr_orgadmin' + ], + permissions: [ + 'admin_full_access' + ], + positions: [ + 'everyone', + 'platform_admin' + ], + posture: 'PLATFORM_ADMIN', + systemPermissions: [ + 'manage_metadata', + 'manage_platform_settings', + 'manage_sharing', + 'manage_users', + 'setup.access', + 'setup.write', + 'studio.access', + 'view_all_audit_log' + ] + }, + noOrganization: { + accessible_org_ids: [], + email: 'admin@rz.example', + org_user_ids: [ + 'usr_admin' + ], + permissions: [ + 'admin_full_access' + ], + positions: [ + 'everyone', + 'platform_admin' + ], + posture: 'PLATFORM_ADMIN', + systemPermissions: [ + 'manage_metadata', + 'manage_platform_settings', + 'manage_sharing', + 'manage_users', + 'setup.access', + 'setup.write', + 'studio.access', + 'view_all_audit_log' + ] + }, + platformStanding: true + } +}; + +const WALLED: unknown = { + agent: { + inOrganization: { + accessible_org_ids: [ + 'org_rz' + ], + email: 'agent@rz.example', + org_user_ids: [ + 'usr_agent', + 'usr_member', + 'usr_orgadmin' + ], + permissions: [ + 'mcp_agent_data_read' + ], + positions: [ + 'everyone', + 'org_member' + ], + posture: 'MEMBER', + systemPermissions: [] + }, + noOrganization: { + accessible_org_ids: [ + 'org_rz' + ], + email: 'agent@rz.example', + org_user_ids: [ + 'usr_agent' + ], + permissions: [], + positions: [ + 'everyone', + 'org_member' + ], + posture: 'MEMBER', + systemPermissions: [] + }, + platformStanding: false + }, + member: { + inOrganization: { + accessible_org_ids: [ + 'org_rz' + ], + email: 'member@rz.example', + org_user_ids: [ + 'usr_agent', + 'usr_member', + 'usr_orgadmin' + ], + permissions: [ + 'viewer_readonly' + ], + positions: [ + 'everyone', + 'org_member' + ], + posture: 'MEMBER', + systemPermissions: [] + }, + noOrganization: { + accessible_org_ids: [ + 'org_rz' + ], + email: 'member@rz.example', + org_user_ids: [ + 'usr_member' + ], + permissions: [], + positions: [ + 'everyone', + 'org_member' + ], + posture: 'MEMBER', + systemPermissions: [] + }, + platformStanding: false + }, + organizationAdmin: { + inOrganization: { + accessible_org_ids: [ + 'org_rz' + ], + email: 'orgadmin@rz.example', + org_user_ids: [ + 'usr_agent', + 'usr_member', + 'usr_orgadmin' + ], + permissions: [ + 'organization_admin' + ], + positions: [ + 'everyone', + 'org_owner' + ], + posture: 'TENANT_ADMIN', + systemPermissions: [ + 'manage_org_users', + 'setup.access', + 'setup.write' + ] + }, + noOrganization: { + accessible_org_ids: [ + 'org_rz' + ], + email: 'orgadmin@rz.example', + org_user_ids: [ + 'usr_orgadmin' + ], + permissions: [], + positions: [ + 'everyone', + 'org_owner' + ], + posture: 'MEMBER', + systemPermissions: [] + }, + platformStanding: false + }, + platformAdmin: { + inOrganization: { + accessible_org_ids: [], + email: 'admin@rz.example', + org_user_ids: [ + 'usr_admin', + 'usr_agent', + 'usr_member', + 'usr_orgadmin' + ], + permissions: [ + 'admin_full_access' + ], + positions: [ + 'everyone', + 'platform_admin' + ], + posture: 'PLATFORM_ADMIN', + systemPermissions: [ + 'manage_metadata', + 'manage_platform_settings', + 'manage_sharing', + 'manage_users', + 'setup.access', + 'setup.write', + 'studio.access', + 'view_all_audit_log' + ] + }, + noOrganization: { + accessible_org_ids: [], + email: 'admin@rz.example', + org_user_ids: [ + 'usr_admin' + ], + permissions: [ + 'admin_full_access' + ], + positions: [ + 'everyone', + 'platform_admin' + ], + posture: 'PLATFORM_ADMIN', + systemPermissions: [ + 'manage_metadata', + 'manage_platform_settings', + 'manage_sharing', + 'manage_users', + 'setup.access', + 'setup.write', + 'studio.access', + 'view_all_audit_log' + ] + }, + platformStanding: true + } +}; + +const GOLDEN: Record = { single: SINGLE, group: WALLED, isolated: 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 new file mode 100644 index 00000000000..3b051593cfd --- /dev/null +++ b/packages/plugins/plugin-security/src/resolve-authz-grant-set-by-name.test.ts @@ -0,0 +1,274 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [ADR-0131 D4] The authorization resolver (`resolveUserAuthzGrants`, + * `@objectstack/core`) finds a user grant's permission set BY NAME, on a real + * ObjectQL engine over the SQL driver with the real `SecurityPlugin` — the + * driver's tenant scope and the write hooks included, which the core suite's + * recording double answers for neither of. + * + * - **Another organization's set.** A grant whose id names another + * organization's set row confers nothing, whether it is left unnamed (what + * the write hook and the backfill leave it) or carries that set's name (what + * the hook could write before it judged the set row's organization). + * - **An unnamed grant** confers nothing through the resolver, and its id + * still restricts where it restricted: the organization-administrator + * reconcile still revokes it through its id. + * - **The upgrade boot.** Grants stored before the name column existed are + * unnamed until the one-time backfill at `kernel:bootstrapped` names them. + * No request reaches the resolver before then: the HTTP socket opens at + * `kernel:listening`, strictly after every `kernel:bootstrapped` handler, + * and by then the grant confers again. A `kernel:ready` handler — boot code, + * never a request — still sees it unnamed. + */ + +import { describe, it, expect, afterEach, vi } from 'vitest'; +import { ObjectQL } from '@objectstack/objectql'; +import { SqlDriver } from '@objectstack/driver-sql'; +import { LiteKernel, resetPlatformAdminEmailMemo, resolveUserAuthzGrants } from '@objectstack/core'; +import { SysUser, SysAccount, SysMember, SysOrganization } from '@objectstack/platform-objects/identity'; +import { SysMigration } from '@objectstack/platform-objects/system'; + +import { SecurityPlugin } from './security-plugin.js'; +import { bootstrapPlatformAdmin } from './bootstrap-platform-admin.js'; +import { reconcileOrgAdminGrant } from './auto-org-admin-grant.js'; +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'; +import { SysPositionPermissionSet } from './objects/sys-position-permission-set.object.js'; +import { SysUserPermissionSet } from './objects/sys-user-permission-set.object.js'; +import { defaultPermissionSets } from './objects/default-permission-sets.js'; +import { + registerGrantPermissionSetNameHooks, + unregisterGrantPermissionSetNameHooks, +} from './grant-permission-set-name.js'; + +const SYS = { isSystem: true } as const; +const POSTURE_ENV = 'OS_TENANCY_POSTURE'; +const OWNER_ENV = 'OS_PLATFORM_OWNER_EMAIL'; +const ORG_A = 'org_a'; +const ORG_B = 'org_b'; +const CATALOG_PACKAGE = 'com.objectstack.qa.resolve-authz-grant-set-by-name'; + +const engines: ObjectQL[] = []; +afterEach(async () => { + vi.restoreAllMocks(); + delete process.env[POSTURE_ENV]; + delete process.env[OWNER_ENV]; + resetPlatformAdminEmailMemo(); + while (engines.length) { + try { await engines.pop()?.destroy(); } catch { /* noop */ } + } +}); + +type Engine = ObjectQL; + +async function newEngine(): Promise { + const engine = new ObjectQL(); + engine.registerDriver( + new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }), + true, + ); + await engine.init(); + engine.registerApp({ + id: CATALOG_PACKAGE, + name: 'Resolver grant set by name', + version: '1.0.0', + type: 'plugin', + scope: 'system', + objects: [ + SysUser, SysAccount, SysMember, SysOrganization, SysMigration, + SysPosition, SysUserPosition, SysPermissionSet, SysPositionPermissionSet, SysUserPermissionSet, + ], + } as any); + await engine.syncSchemas(); + engines.push(engine); + vi.spyOn((engine as any).logger, 'warn').mockImplementation(() => undefined); + return engine; +} + +/** An engine with the real `SecurityPlugin` started on it the way a composition does. */ +async function boot(opts: { walled?: boolean } = {}): Promise { + if (opts.walled) process.env[POSTURE_ENV] = 'isolated'; + resetPlatformAdminEmailMemo(); + const engine = await newEngine(); + const services: Record = { + manifest: { register: vi.fn() }, + objectql: engine, + metadata: { + get: async (_type: string, name: string) => engine.getSchema(name) ?? null, + list: async () => [...defaultPermissionSets], + }, + ...(opts.walled + ? { 'org-scoping': { name: 'com.objectstack.org-scoping' }, tenancy: { posture: 'isolated' } } + : {}), + }; + const ctx: any = { + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, + hook: vi.fn(), + registerService: vi.fn(), + getService: (name: string) => { + if (!(name in services)) throw new Error(`service not registered: ${name}`); + return services[name]; + }, + }; + const plugin = new SecurityPlugin({ fallbackPermissionSet: 'member_default' }); + await plugin.init(ctx); + await plugin.start(ctx); + return engine; +} + +async function insertUser(engine: Engine, id: string): Promise { + await engine.insert('sys_user', { id, email: `${id}@rz.example`, name: id, email_verified: true }, { context: SYS } as any); +} + +/** + * A grant written with the name hooks unbound: with no name, a grant stored + * before the column existed; with one, a name its set row does not justify — + * what a write could store before the hook judged organizations. + */ +async function insertGrantUnhooked(engine: Engine, row: Record): Promise { + unregisterGrantPermissionSetNameHooks(engine); + try { + await engine.insert('sys_user_permission_set', row, { context: SYS } as any); + } finally { + registerGrantPermissionSetNameHooks(engine); + } +} + +async function grant(engine: Engine, id: string): Promise | null> { + return (await engine.findOne('sys_user_permission_set', { where: { id }, context: SYS })) as any; +} + +describe('[ADR-0131 D4] a grant whose id names another organization’s set confers nothing through the resolver', () => { + async function world(): Promise { + const engine = await boot({ walled: true }); + await insertUser(engine, 'usr_x'); + await engine.insert('sys_permission_set', + { id: 'ps_b_tools', name: 'b_tools', label: 'B tools', system_permissions: '["cap_b"]', active: true }, + { context: { isSystem: true, tenantId: ORG_B } } as any); + return engine; + } + + it('left unnamed (what the write hook and the backfill leave it): nothing, in either organization or none', async () => { + const engine = await world(); + await insertGrantUnhooked(engine, { id: 'g_x', user_id: 'usr_x', permission_set_id: 'ps_b_tools', organization_id: null }); + for (const tenantId of [undefined, ORG_A, ORG_B]) { + const out = await resolveUserAuthzGrants(engine, 'usr_x', tenantId ? { tenantId } : {}); + expect(out.permissions, String(tenantId)).not.toContain('b_tools'); + expect(out.systemPermissions, String(tenantId)).not.toContain('cap_b'); + } + }); + + it('carrying that set’s name: nothing either — no row the grant may name bears it', async () => { + const engine = await world(); + await insertGrantUnhooked(engine, { + id: 'g_y', user_id: 'usr_x', permission_set_id: 'ps_b_tools', permission_set: 'b_tools', organization_id: null, + }); + await insertGrantUnhooked(engine, { + id: 'g_z', user_id: 'usr_x', permission_set_id: 'ps_b_tools', permission_set: 'b_tools', organization_id: ORG_A, + }); + for (const tenantId of [undefined, ORG_A, ORG_B]) { + const out = await resolveUserAuthzGrants(engine, 'usr_x', tenantId ? { tenantId } : {}); + expect(out.permissions, String(tenantId)).not.toContain('b_tools'); + expect(out.systemPermissions, String(tenantId)).not.toContain('cap_b'); + } + }); + + it('CONTROL — an organization’s own grant of that set confers it, in that organization only', 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).toContain('cap_b'); + expect((await resolveUserAuthzGrants(engine, 'usr_x', { tenantId: ORG_A })).systemPermissions).not.toContain('cap_b'); + }); +}); + +describe('[ADR-0131 D4] an unnamed grant confers nothing through the resolver; its id still restricts', () => { + it('an unnamed organization-administrator grant confers nothing, and the reconcile still revokes it through its id', async () => { + const engine = await boot(); + await insertUser(engine, 'usr_admin'); + await insertUser(engine, 'usr_owner'); + await bootstrapPlatformAdmin(engine, defaultPermissionSets); + await engine.insert('sys_organization', { id: ORG_A, name: 'A', slug: 'a' }, { context: SYS } as any); + // A plain member holding an organization-administrator grant the backfill + // has not named — what a demoted owner's left-behind grant looks like. + await engine.insert('sys_member', + { id: 'm_owner', user_id: 'usr_owner', organization_id: ORG_A, role: 'member' }, + { context: { isSystem: true, tenantId: ORG_A } } as any); + const [set] = (await engine.find('sys_permission_set', { + where: { name: 'organization_admin_no_bypass', organization_id: null }, context: SYS, + })) as any[]; + await insertGrantUnhooked(engine, { id: 'g_oa', user_id: 'usr_owner', permission_set_id: set.id, organization_id: ORG_A }); + + const out = await resolveUserAuthzGrants(engine, 'usr_owner', { tenantId: ORG_A }); + expect(out.permissions).not.toContain('organization_admin_no_bypass'); + expect(out.posture).toBe('MEMBER'); + + const demotion = await reconcileOrgAdminGrant(engine, 'usr_owner', ORG_A, { posture: 'single' }); + expect(demotion.action).toBe('revoked'); + expect(await grant(engine, 'g_oa')).toBeNull(); + }); +}); + +describe('[ADR-0131 D4] the upgrade boot — no request reaches the resolver before the backfill names the grants', () => { + it('a grant stored before the name column: unnamed at kernel:ready, named and conferring at kernel:listening', async () => { + const engine = await newEngine(); + for (const ps of defaultPermissionSets) { + 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, + ); + // Stored before this boot: the set row, and a grant with no name. + await engine.insert('sys_permission_set', + { id: 'ps_up', name: 'upgrade_reviewer', label: 'Reviewer', system_permissions: '["cap_review"]', active: true }, + { context: SYS } as any); + await insertUser(engine, 'usr_member'); + await engine.insert('sys_user_permission_set', + { id: 'g_up', user_id: 'usr_member', permission_set_id: 'ps_up' }, + { context: SYS } as any); + expect((await grant(engine, 'g_up'))?.permission_set).toBeNull(); + + const seen: Record = {}; + const metadata = { + get: async (_type: string, name: string) => engine.getSchema(name) ?? null, + list: async () => [...defaultPermissionSets], + }; + const kernel = new LiteKernel({ logger: { level: 'silent' } }); + kernel.use({ + name: 'com.objectstack.engine.objectql', + init: async (ctx: any) => { + ctx.registerService('objectql', engine); + ctx.registerService('metadata', metadata); + ctx.registerService('manifest', { register: () => undefined }); + }, + start: async () => undefined, + } as any); + kernel.use(new SecurityPlugin({ fallbackPermissionSet: 'member_default' })); + // Registered after SecurityPlugin, so each of its handlers runs after + // SecurityPlugin's own handler for the same hook. + kernel.use({ + name: 'com.objectstack.qa.resolver-probe', + dependencies: ['com.objectstack.security'], + init: async () => undefined, + start: async (ctx: any) => { + const probe = (phase: string) => async () => { + seen[phase] = (await resolveUserAuthzGrants(engine, 'usr_member', { bypassGrantsCache: true })).systemPermissions; + }; + ctx.hook('kernel:ready', probe('ready')); + ctx.hook('kernel:listening', probe('listening')); + }, + } as any); + try { + await kernel.bootstrap(); + expect(seen.ready).not.toContain('cap_review'); + expect(seen.listening).toContain('cap_review'); + expect((await grant(engine, 'g_up'))?.permission_set).toBe('upgrade_reviewer'); + } finally { + await kernel.shutdown(); + } + }); +}); diff --git a/packages/plugins/plugin-security/src/security-plugin.test.ts b/packages/plugins/plugin-security/src/security-plugin.test.ts index e7b10b841a3..dd150c33196 100644 --- a/packages/plugins/plugin-security/src/security-plugin.test.ts +++ b/packages/plugins/plugin-security/src/security-plugin.test.ts @@ -3782,6 +3782,8 @@ describe('explainAccessForCaller (ADR-0090 D6/D12)', () => { Object.entries(where ?? {}).every(([k, v]) => { if (k.startsWith('$')) throw new Error(`fake driver: unsupported operator ${k}`); if (v && typeof v === 'object' && Array.isArray((v as any).$in)) return (v as any).$in.includes(row[k]); + // An absent column reads as NULL, as it does in SQL (`organization_id: null`). + if (v === null) return (row[k] ?? null) === null; return row[k] === v; }); const baseSchema: any = { name: 'task', fields: { id: { name: 'id' }, owner_id: { name: 'owner_id' }, name: { name: 'name' } } }; @@ -3864,7 +3866,7 @@ describe('explainAccessForCaller (ADR-0090 D6/D12)', () => { const scopedTarget = async () => { const b = await boot(); b.h.tables.sys_user_permission_set.push( - { user_id: 'u_west_1', permission_set_id: 'ps_sub', organization_id: 'org_alpha' }, + { user_id: 'u_west_1', permission_set_id: 'ps_sub', permission_set: 'sub_admin', organization_id: 'org_alpha' }, ); b.h.tables.sys_permission_set.push({ id: 'ps_sub', name: 'sub_admin' }); return b; 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 1a98d83380f..53dcdfa41bd 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 @@ -75,6 +75,8 @@ function makeAuthzQl(tables: Record>>) { Object.entries(where ?? {}).every(([k, v]) => { if (k.startsWith('$')) throw new Error(`fake driver: unsupported operator ${k}`); if (v && typeof v === 'object' && '$in' in (v as any)) return (v as any).$in.includes(row[k]); + // An absent column reads as NULL, as it does in SQL (`organization_id: null`). + if (v === null) return (row[k] ?? null) === null; return row[k] === v; }); return { @@ -110,10 +112,10 @@ function authzTables(shape: 'name-only' | 'genuine') { // every arm below would be vacuous. It is still `sharing_admin`, never // `admin_full_access`, so it cannot be mistaken for the grant that confers // standing. - { user_id: USER, permission_set_id: PS_SHARING, organization_id: null }, + { user_id: USER, permission_set_id: PS_SHARING, permission_set: 'sharing_admin', organization_id: null }, ]; if (shape === 'genuine') { - userSets.push({ user_id: USER, permission_set_id: PS_ADMIN, organization_id: null }); + userSets.push({ user_id: USER, permission_set_id: PS_ADMIN, permission_set: ADMIN_FULL_ACCESS, organization_id: null }); } return { sys_user: [{ id: USER, email: 'subject@example.com', email_verified: true }], @@ -174,6 +176,8 @@ function makeSharingEngine() { const matches = (row: Record, where: any): boolean => Object.entries(where ?? {}).every(([k, v]) => { if (k.startsWith('$')) throw new Error(`fake engine: unsupported operator ${k}`); + // An absent column reads as NULL, as it does in SQL (`organization_id: null`). + if (v === null) return (row[k] ?? null) === null; return row[k] === v; }); return { 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 d3514601e8a..e14d7009334 100644 --- a/packages/rest/src/execctx-authz-input-seam-reachability.test.ts +++ b/packages/rest/src/execctx-authz-input-seam-reachability.test.ts @@ -394,6 +394,11 @@ function matchesWhere(row: any, where: any): boolean { if (!(cond as any).$in.includes(row[field])) return false; continue; } + // An absent column reads as NULL, as it does in SQL (`organization_id: null`). + if (cond === null) { + if ((row[field] ?? null) !== null) return false; + continue; + } if (row[field] !== cond) return false; } return true; @@ -421,9 +426,9 @@ function qlWith(opts: { memberships: Array<{ user_id: string; organization_id: s { id: 'u_gated', email: 'u_gated@example.com' }, ], sys_user_permission_set: [ - { user_id: 'u_member', permission_set_id: 'ps_pkg' }, - { user_id: 'u_exmember', permission_set_id: 'ps_pkg' }, - { user_id: 'u_orgless', permission_set_id: 'ps_pkg' }, + { user_id: 'u_member', permission_set_id: 'ps_pkg', permission_set: 'pkg_admin' }, + { user_id: 'u_exmember', permission_set_id: 'ps_pkg', permission_set: 'pkg_admin' }, + { user_id: 'u_orgless', permission_set_id: 'ps_pkg', permission_set: 'pkg_admin' }, ], sys_permission_set: [ { id: 'ps_pkg', name: 'pkg_admin', system_permissions: ['manage_metadata', 'studio.access'] }, 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 34a13ad5e3a..591def599a2 100644 --- a/packages/rest/src/external-datasource-routes-auth-guard.test.ts +++ b/packages/rest/src/external-datasource-routes-auth-guard.test.ts @@ -240,12 +240,14 @@ async function bootFederation( } if (object === 'sys_user_permission_set') { return query?.where?.user_id === 'u_federation' && grants.length > 0 - ? [{ id: 'ups_1', user_id: 'u_federation', permission_set_id: GRANT_SET_ID, organization_id: null }] + ? [{ id: 'ups_1', user_id: 'u_federation', permission_set_id: GRANT_SET_ID, permission_set: 'federation_caller', organization_id: null }] : []; } if (object === 'sys_permission_set') { + // By id (a position binding) or by name (a user grant, ADR-0131 D4). const ids: string[] = query?.where?.id?.$in ?? []; - return ids.includes(GRANT_SET_ID) + const names: string[] = query?.where?.name?.$in ?? []; + return ids.includes(GRANT_SET_ID) || names.includes('federation_caller') ? [{ id: GRANT_SET_ID, name: 'federation_caller', diff --git a/packages/rest/src/meta-type-read-capability.test.ts b/packages/rest/src/meta-type-read-capability.test.ts index 8541f3e2f11..b806d3c188f 100644 --- a/packages/rest/src/meta-type-read-capability.test.ts +++ b/packages/rest/src/meta-type-read-capability.test.ts @@ -346,7 +346,7 @@ const SETS: Record = { */ const GRANT_TABLES: Record = { sys_user_permission_set: Object.values(PRINCIPALS).flatMap((p) => p.sets.map((setId) => ({ - id: `ups_${p.userId}_${setId}`, user_id: p.userId, permission_set_id: setId, organization_id: null, + id: `ups_${p.userId}_${setId}`, user_id: p.userId, permission_set_id: setId, permission_set: setId, organization_id: null, }))), sys_permission_set: Object.entries(SETS).map(([id, caps]) => ({ id, name: id, system_permissions: JSON.stringify(caps), object_permissions: '{}', @@ -366,6 +366,8 @@ const grantsEngine = { Object.entries(where).every(([k, v]) => { if (k.startsWith('$')) throw new Error(`grants double: unsupported operator ${k}`); if (v && typeof v === 'object' && '$in' in (v as any)) return (v as any).$in.includes(r[k]); + // An absent column reads as NULL, as it does in SQL (`organization_id: null`). + if (v === null) return (r[k] ?? null) === null; return r[k] === v; }), ), diff --git a/packages/rest/src/meta-type-write-capability.test.ts b/packages/rest/src/meta-type-write-capability.test.ts index e964dcc9663..8af0220ae69 100644 --- a/packages/rest/src/meta-type-write-capability.test.ts +++ b/packages/rest/src/meta-type-write-capability.test.ts @@ -307,7 +307,7 @@ const SETS: Record = { const GRANT_TABLES: Record = { sys_user_permission_set: Object.values(PRINCIPALS).flatMap((p) => p.sets.map((setId) => ({ - id: `ups_${p.userId}_${setId}`, user_id: p.userId, permission_set_id: setId, organization_id: null, + id: `ups_${p.userId}_${setId}`, user_id: p.userId, permission_set_id: setId, permission_set: setId, organization_id: null, }))), sys_permission_set: Object.entries(SETS).map(([id, caps]) => ({ id, name: id, system_permissions: JSON.stringify(caps), object_permissions: '{}', @@ -322,6 +322,8 @@ const grantsEngine = { Object.entries(where).every(([k, v]) => { if (k.startsWith('$')) throw new Error(`grants double: unsupported operator ${k}`); if (v && typeof v === 'object' && '$in' in (v as any)) return (v as any).$in.includes(r[k]); + // An absent column reads as NULL, as it does in SQL (`organization_id: null`). + if (v === null) return (r[k] ?? null) === null; return r[k] === v; }), ); 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 021c1fc7b45..456e87a5a4a 100644 --- a/packages/rest/src/package-door-execctx-fault-reachability.test.ts +++ b/packages/rest/src/package-door-execctx-fault-reachability.test.ts @@ -259,7 +259,7 @@ const AUTH_OK = async () => ({ api: { getSession: async () => ({ user: { id: 'u_ */ const qlHealthy = () => ({ find: async (object: string) => { - if (object === 'sys_user_permission_set') return [{ permission_set_id: 'ps_pkg' }]; + 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'] }]; } diff --git a/packages/rest/src/remote-tables-twin.equivalence.test.ts b/packages/rest/src/remote-tables-twin.equivalence.test.ts index 37483261a70..54d5e4e02d1 100644 --- a/packages/rest/src/remote-tables-twin.equivalence.test.ts +++ b/packages/rest/src/remote-tables-twin.equivalence.test.ts @@ -192,11 +192,16 @@ const makeQl = () => ({ if (object === 'sys_user_permission_set') { const setId = HOLDS[where.user_id as string]; return setId - ? [{ id: `ups_${where.user_id}`, user_id: where.user_id, permission_set_id: setId, organization_id: null }] + ? [{ + id: `ups_${where.user_id}`, user_id: where.user_id, permission_set_id: setId, + permission_set: SETS[setId].name, organization_id: null, + }] : []; } if (object === 'sys_permission_set') { - const ids: string[] = where.id?.$in ?? []; + // By id (a position binding) or by name (a user grant, ADR-0131 D4). + const names: string[] = where.name?.$in ?? []; + const ids: string[] = where.id?.$in ?? Object.keys(SETS).filter((id) => names.includes(SETS[id].name)); return ids .filter((id) => id in SETS) .map((id) => ({ 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 578ca5f8723..0b39a44876e 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 @@ -259,7 +259,7 @@ const AUTH_OK = async () => ({ api: { getSession: async () => ({ user: { id: 'u_ */ const engineGranting = () => ({ find: async (object: string) => { - if (object === 'sys_user_permission_set') return [{ permission_set_id: 'ps_pkg' }]; + 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'] }]; } 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 cf3d0988d1a..6a1bcfec68c 100644 --- a/packages/rest/src/rest-exec-ctx-principal-kind.test.ts +++ b/packages/rest/src/rest-exec-ctx-principal-kind.test.ts @@ -55,15 +55,17 @@ const makeQl = () => ({ } if (object === 'sys_user') return [{ id: where.id, email: `${where.id}@example.com` }]; if (object === 'sys_user_permission_set') { - if (where.user_id === 'admin1') return [{ permission_set_id: 'ps_admin', organization_id: null }]; - if (where.user_id === 'member1') return [{ permission_set_id: 'ps_member', organization_id: null }]; + if (where.user_id === 'admin1') return [{ permission_set_id: 'ps_admin', permission_set: 'admin_full_access', organization_id: null }]; + if (where.user_id === 'member1') return [{ permission_set_id: 'ps_member', permission_set: 'member_default', organization_id: null }]; return []; } if (object === 'sys_permission_set') { + // By id (a position binding) or by name (a user grant, ADR-0131 D4). const ids: string[] = where.id?.$in ?? []; + const names: string[] = where.name?.$in ?? []; return [ - ids.includes('ps_admin') ? { id: 'ps_admin', name: 'admin_full_access' } : null, - ids.includes('ps_member') ? { id: 'ps_member', name: 'member_default' } : null, + ids.includes('ps_admin') || names.includes('admin_full_access') ? { id: 'ps_admin', name: 'admin_full_access' } : null, + ids.includes('ps_member') || names.includes('member_default') ? { id: 'ps_member', name: 'member_default' } : null, ].filter(Boolean); } return []; diff --git a/packages/rest/src/rest-server-timing.test.ts b/packages/rest/src/rest-server-timing.test.ts index bc09feb751e..f06390d79fb 100644 --- a/packages/rest/src/rest-server-timing.test.ts +++ b/packages/rest/src/rest-server-timing.test.ts @@ -32,15 +32,17 @@ const makeQl = () => ({ const where = opts?.where ?? {}; if (object === 'sys_user') return [{ id: where.id, email: `${where.id}@example.com` }]; if (object === 'sys_user_permission_set') { - if (where.user_id === 'admin1') return [{ permission_set_id: 'ps_admin', organization_id: null }]; - if (where.user_id === 'member1') return [{ permission_set_id: 'ps_member', organization_id: null }]; + if (where.user_id === 'admin1') return [{ permission_set_id: 'ps_admin', permission_set: 'admin_full_access', organization_id: null }]; + if (where.user_id === 'member1') return [{ permission_set_id: 'ps_member', permission_set: 'member_default', organization_id: null }]; return []; } if (object === 'sys_permission_set') { + // By id (a position binding) or by name (a user grant, ADR-0131 D4). const ids: string[] = where.id?.$in ?? []; + const names: string[] = where.name?.$in ?? []; return [ - ids.includes('ps_admin') ? { id: 'ps_admin', name: 'admin_full_access' } : null, - ids.includes('ps_member') ? { id: 'ps_member', name: 'member_default' } : null, + ids.includes('ps_admin') || names.includes('admin_full_access') ? { id: 'ps_admin', name: 'admin_full_access' } : null, + ids.includes('ps_member') || names.includes('member_default') ? { id: 'ps_member', name: 'member_default' } : null, ].filter(Boolean); } // sys_member, sys_user_position, sys_position, sys_position_permission_set, diff --git a/packages/rest/src/single-kernel-isolated-api-key-matrix.test.ts b/packages/rest/src/single-kernel-isolated-api-key-matrix.test.ts index 7455da3372f..1da7d9a57dc 100644 --- a/packages/rest/src/single-kernel-isolated-api-key-matrix.test.ts +++ b/packages/rest/src/single-kernel-isolated-api-key-matrix.test.ts @@ -133,9 +133,9 @@ function makeQl() { // principals held different capabilities, RBAC could be what separates // the arms; with one shared grant, only the organization wall can be. sys_user_permission_set: [ - { user_id: 'u_member', permission_set_id: 'ps_shared' }, - { user_id: 'u_exmember', permission_set_id: 'ps_shared' }, - { user_id: 'u_orgless', permission_set_id: 'ps_shared' }, + { user_id: 'u_member', permission_set_id: 'ps_shared', permission_set: 'shared_access' }, + { user_id: 'u_exmember', permission_set_id: 'ps_shared', permission_set: 'shared_access' }, + { user_id: 'u_orgless', permission_set_id: 'ps_shared', permission_set: 'shared_access' }, ], sys_permission_set: [ { id: 'ps_shared', name: 'shared_access', system_permissions: ['manage_metadata', 'studio.access'] }, diff --git a/packages/rest/src/single-kernel-isolated-session-org-claim-matrix.test.ts b/packages/rest/src/single-kernel-isolated-session-org-claim-matrix.test.ts index 143f444d05b..a193067d921 100644 --- a/packages/rest/src/single-kernel-isolated-session-org-claim-matrix.test.ts +++ b/packages/rest/src/single-kernel-isolated-session-org-claim-matrix.test.ts @@ -183,8 +183,8 @@ function makeQl() { // reading's discipline. With one shared grant, only the organization // claim can separate the arms. sys_user_permission_set: [ - { user_id: 'u_member', permission_set_id: 'ps_shared' }, - { user_id: 'u_exmember', permission_set_id: 'ps_shared' }, + { user_id: 'u_member', permission_set_id: 'ps_shared', permission_set: 'shared_access' }, + { user_id: 'u_exmember', permission_set_id: 'ps_shared', permission_set: 'shared_access' }, ], sys_permission_set: [ { id: 'ps_shared', name: 'shared_access', system_permissions: ['manage_metadata', 'studio.access'] }, diff --git a/packages/runtime/src/dispatcher-plugin.endpoint-fallback.integration.test.ts b/packages/runtime/src/dispatcher-plugin.endpoint-fallback.integration.test.ts index 4fc0f52a8da..4a0588bba3a 100644 --- a/packages/runtime/src/dispatcher-plugin.endpoint-fallback.integration.test.ts +++ b/packages/runtime/src/dispatcher-plugin.endpoint-fallback.integration.test.ts @@ -407,7 +407,7 @@ function executionServicesPlugin(): Plugin { engineFinds.push({ object, options }); if (object === 'sys_user_permission_set') { return options?.where?.user_id === 'admin1' - ? [{ user_id: 'admin1', permission_set_id: 'ps-admin', organization_id: null }] + ? [{ user_id: 'admin1', permission_set_id: 'ps-admin', permission_set: 'admin_full_access', organization_id: null }] : []; } if (object === 'sys_permission_set') { 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 04cffaa954e..8ef1f3b9d35 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 @@ -93,6 +93,8 @@ function makeAuthzQl(tables: Record>>) { Object.entries(where ?? {}).every(([k, v]) => { if (k.startsWith('$')) throw new Error(`fake driver: unsupported operator ${k}`); if (v && typeof v === 'object' && '$in' in (v as any)) return (v as any).$in.includes(row[k]); + // An absent column reads as NULL, as it does in SQL (`organization_id: null`). + if (v === null) return (row[k] ?? null) === null; return row[k] === v; }); return { @@ -110,10 +112,10 @@ function authzTables(shape: Shape) { // The ORG-scoped authoring capability every shape holds, so the // `manage_metadata` tier above always passes and this gate is the only // thing under test. Scoped to the org, never unscoped. - { user_id: ACTOR, permission_set_id: PS_METADATA, organization_id: TENANT_ORG }, + { user_id: ACTOR, permission_set_id: PS_METADATA, permission_set: 'metadata_author', organization_id: TENANT_ORG }, ]; if (shape === 'genuine') { - userSets.push({ user_id: ACTOR, permission_set_id: PS_ADMIN, organization_id: null }); + userSets.push({ user_id: ACTOR, permission_set_id: PS_ADMIN, permission_set: ADMIN_FULL_ACCESS, organization_id: null }); } return { sys_user: [{ id: ACTOR, email: 'tenant-admin@example.com', email_verified: true }], 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 ef1df77989f..d10115e3ce3 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 @@ -73,6 +73,8 @@ function makeQl(tables: Tables) { Object.entries(where ?? {}).every(([k, v]) => { if (k.startsWith('$')) throw new Error(`fake driver: unsupported operator ${k}`); if (v && typeof v === 'object' && '$in' in (v as any)) return (v as any).$in.includes(row[k]); + // An absent column reads as NULL, as it does in SQL (`organization_id: null`). + if (v === null) return (row[k] ?? null) === null; return row[k] === v; }); return { @@ -130,7 +132,7 @@ const REMEDIES: readonly Remedy[] = [ arrange: () => { const tables = userOnly('usr_granted', 'legacy@corp.example'); tables.sys_user_permission_set = [ - { id: 'ups_1', user_id: 'usr_granted', permission_set_id: 'pst_1', organization_id: null }, + { id: 'ups_1', user_id: 'usr_granted', permission_set_id: 'pst_1', permission_set: ADMIN_FULL_ACCESS, organization_id: null }, ]; tables.sys_permission_set = [{ id: 'pst_1', name: ADMIN_FULL_ACCESS, active: true }]; return { userId: 'usr_granted', email: 'legacy@corp.example', tables, declared: undefined }; 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 151869d7aab..34468d618b9 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 @@ -141,8 +141,8 @@ function makeQl() { { id: 'u_exmember', email: 'u_exmember@example.com' }, ], sys_user_permission_set: [ - { user_id: 'u_member', permission_set_id: 'ps_shared' }, - { user_id: 'u_exmember', permission_set_id: 'ps_shared' }, + { user_id: 'u_member', permission_set_id: 'ps_shared', permission_set: 'shared_access' }, + { user_id: 'u_exmember', permission_set_id: 'ps_shared', permission_set: 'shared_access' }, ], sys_permission_set: [ { id: 'ps_shared', name: 'shared_access', system_permissions: ['manage_metadata', 'studio.access'] }, @@ -162,6 +162,8 @@ function makeQl() { } return (cond as any).$in.includes(row[field]); } + // An absent column reads as NULL, as it does in SQL (`organization_id: null`). + if (cond === null) return (row[field] ?? null) === null; return row[field] === cond; }); return { 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 7465d3ffb5c..f152f160112 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 @@ -61,6 +61,8 @@ function matchesWhere(row: any, where: any): boolean { } return (cond as any).$in.includes(row[field]); } + // An absent column reads as NULL, as it does in SQL (`organization_id: null`). + if (cond === null) return (row[field] ?? null) === null; return row[field] === cond; }); } @@ -87,11 +89,11 @@ const TABLES: Record = { sys_user: ['u_member', 'u_exmember', 'u_gone', 'u_global', 'u_admin', 'u_posex', 'u_posmember', 'u_orgless'] .map((id) => ({ id, email: `${id}@example.com` })), sys_user_permission_set: [ - { user_id: 'u_member', permission_set_id: 'ps_meta', organization_id: ALPHA }, - { user_id: 'u_exmember', permission_set_id: 'ps_meta', organization_id: ALPHA }, - { user_id: 'u_gone', permission_set_id: 'ps_meta', organization_id: ALPHA }, - { user_id: 'u_global', permission_set_id: 'ps_meta', organization_id: null }, - { user_id: 'u_admin', permission_set_id: 'ps_admin', organization_id: null }, + { user_id: 'u_member', permission_set_id: 'ps_meta', permission_set: 'alpha_metadata_editors', organization_id: ALPHA }, + { user_id: 'u_exmember', permission_set_id: 'ps_meta', permission_set: 'alpha_metadata_editors', organization_id: ALPHA }, + { user_id: 'u_gone', permission_set_id: 'ps_meta', permission_set: 'alpha_metadata_editors', organization_id: ALPHA }, + { user_id: 'u_global', permission_set_id: 'ps_meta', permission_set: 'alpha_metadata_editors', organization_id: null }, + { user_id: 'u_admin', permission_set_id: 'ps_admin', permission_set: 'admin_full_access', organization_id: null }, ], sys_permission_set: [ { id: 'ps_meta', name: 'alpha_metadata_editors', system_permissions: ['manage_metadata', 'studio.access'] }, 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 745e33686d6..7976e40b5ee 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 @@ -122,7 +122,7 @@ const ORG = 'org_acme'; const TABLES: Record = { sys_member: [{ user_id: 'u_admin', organization_id: ORG, role: 'member' }], sys_user: [{ id: 'u_admin', email: 'u_admin@example.com' }], - sys_user_permission_set: [{ user_id: 'u_admin', permission_set_id: 'ps_pkg' }], + sys_user_permission_set: [{ user_id: 'u_admin', permission_set_id: 'ps_pkg', permission_set: 'pkg_admin' }], sys_permission_set: [ { id: 'ps_pkg', name: 'pkg_admin', system_permissions: ['manage_metadata', 'studio.access'] }, ], @@ -147,6 +147,11 @@ function matchesWhere(row: any, where: any): boolean { if (!(cond as any).$in.includes(row[field])) return false; continue; } + // An absent column reads as NULL, as it does in SQL (`organization_id: null`). + if (cond === null) { + if ((row[field] ?? null) !== null) return false; + continue; + } if (row[field] !== cond) return false; } return true; diff --git a/packages/runtime/src/domains/packages-single-door.test.ts b/packages/runtime/src/domains/packages-single-door.test.ts index c1df0603a93..7f78c710856 100644 --- a/packages/runtime/src/domains/packages-single-door.test.ts +++ b/packages/runtime/src/domains/packages-single-door.test.ts @@ -95,6 +95,11 @@ function matchesWhere(row: any, where: any): boolean { if (!(cond as any).$in.includes(row[field])) return false; continue; } + // An absent column reads as NULL, as it does in SQL (`organization_id: null`). + if (cond === null) { + if ((row[field] ?? null) !== null) return false; + continue; + } if (row[field] !== cond) return false; } return true; @@ -103,7 +108,7 @@ function matchesWhere(row: any, where: any): boolean { /** The permission store the shared authz resolver reads, in its shipped shapes. */ const TABLES: Record = { sys_user: [{ id: 'u_admin', email: 'u_admin@example.com' }], - sys_user_permission_set: [{ user_id: 'u_admin', permission_set_id: 'ps_pkg' }], + sys_user_permission_set: [{ user_id: 'u_admin', permission_set_id: 'ps_pkg', permission_set: 'pkg_admin' }], sys_permission_set: [ { id: 'ps_pkg', name: 'pkg_admin', system_permissions: ['manage_metadata', 'studio.access'] }, ], 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 89108401b48..0ad2f03076a 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 @@ -60,6 +60,8 @@ function matchesWhere(row: any, where: any): boolean { } return (cond as any).$in.includes(row[field]); } + // An absent column reads as NULL, as it does in SQL (`organization_id: null`). + if (cond === null) return (row[field] ?? null) === null; return row[field] === cond; }); } @@ -81,9 +83,9 @@ const TABLES: Record = { { id: 'u_orgless', email: 'u_orgless@example.com' }, ], sys_user_permission_set: [ - { user_id: 'u_member', permission_set_id: 'ps_shared' }, - { user_id: 'u_exmember', permission_set_id: 'ps_shared' }, - { user_id: 'u_orgless', permission_set_id: 'ps_shared' }, + { user_id: 'u_member', permission_set_id: 'ps_shared', permission_set: 'shared_access' }, + { user_id: 'u_exmember', permission_set_id: 'ps_shared', permission_set: 'shared_access' }, + { user_id: 'u_orgless', permission_set_id: 'ps_shared', permission_set: 'shared_access' }, ], sys_permission_set: [ { id: 'ps_shared', name: 'shared_access', system_permissions: ['manage_metadata', 'studio.access'] }, 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 58e3d9594f7..1ad4a1cb144 100644 --- a/packages/runtime/src/domains/packages-vetted-org-source.test.ts +++ b/packages/runtime/src/domains/packages-vetted-org-source.test.ts @@ -181,8 +181,8 @@ function makeQl() { { id: 'u_exmember', email: 'u_exmember@example.com' }, ], sys_user_permission_set: [ - { user_id: 'u_member', permission_set_id: 'ps_shared' }, - { user_id: 'u_exmember', permission_set_id: 'ps_shared' }, + { user_id: 'u_member', permission_set_id: 'ps_shared', permission_set: 'shared_access' }, + { user_id: 'u_exmember', permission_set_id: 'ps_shared', permission_set: 'shared_access' }, ], sys_permission_set: [ { id: 'ps_shared', name: 'shared_access', system_permissions: ['manage_metadata', 'studio.access'] }, @@ -198,6 +198,8 @@ function makeQl() { } return (cond as any).$in.includes(row[field]); } + // An absent column reads as NULL, as it does in SQL (`organization_id: null`). + if (cond === null) return (row[field] ?? null) === null; return row[field] === cond; }); const pkg = { manifest: { id: PKG, name: 'CRM', version: '1.0.0', scope: 'project' } }; diff --git a/packages/runtime/src/route-parity.integration.test.ts b/packages/runtime/src/route-parity.integration.test.ts index 6d1d63d376e..46aaca5d37a 100644 --- a/packages/runtime/src/route-parity.integration.test.ts +++ b/packages/runtime/src/route-parity.integration.test.ts @@ -66,7 +66,7 @@ function fakeObjectQL() { if (object === 'sys_user_permission_set') { const uid = where.user_id; return uid === 'admin1' - ? [{ user_id: uid, permission_set_id: 'ps-admin', organization_id: null }] + ? [{ user_id: uid, permission_set_id: 'ps-admin', permission_set: 'admin_full_access', organization_id: null }] : []; } if (object === 'sys_permission_set') { diff --git a/packages/runtime/src/security/resolve-execution-context.test.ts b/packages/runtime/src/security/resolve-execution-context.test.ts index c029f7f524f..83d506e84df 100644 --- a/packages/runtime/src/security/resolve-execution-context.test.ts +++ b/packages/runtime/src/security/resolve-execution-context.test.ts @@ -318,7 +318,7 @@ describe('resolveExecutionContext — platform-scoped (null-org) grants (ADR-006 sys_api_key: [{ id: 'k1', key: hashApiKey(RAW), revoked: false, user_id: 'u1', active_organization_id: 'orgA', expires_at: FUTURE }], sys_member: [{ user_id: 'u1', organization_id: 'orgA', role: 'owner' }], sys_user_permission_set: [ - { id: 'ups_global', user_id: 'u1', permission_set_id: 'ps_admin', organization_id: null }, + { id: 'ups_global', user_id: 'u1', permission_set_id: 'ps_admin', permission_set: 'admin_full_access', organization_id: null }, ...extraGrants, ], sys_permission_set: [ @@ -359,7 +359,7 @@ describe('resolveExecutionContext — platform-scoped (null-org) grants (ADR-006 }); it('still drops a grant scoped to a DIFFERENT org', async () => { - const ql = makeAuthQl([{ id: 'ups_otherorg', user_id: 'u1', permission_set_id: 'ps_other', organization_id: 'orgB' }]); + const ql = makeAuthQl([{ id: 'ups_otherorg', user_id: 'u1', permission_set_id: 'ps_other', permission_set: 'other_org_set', organization_id: 'orgB' }]); const ctx = await resolveExecutionContext(opts(ql)); expect(ctx.permissions).toContain('admin_full_access'); // global grant kept expect(ctx.permissions).not.toContain('other_org_set'); // foreign-org grant dropped @@ -411,7 +411,7 @@ describe('resolveExecutionContext — posture plumbing (#2947)', () => { it('carries MEMBER for an ordinary principal (no admin / org-admin capability)', async () => { const ql = makeQlFor( [{ id: 'ps_basic', name: 'sales_rep', system_permissions: '[]', object_permissions: '{}' }], - [{ id: 'ups1', user_id: 'u1', permission_set_id: 'ps_basic', organization_id: null }], + [{ id: 'ups1', user_id: 'u1', permission_set_id: 'ps_basic', permission_set: 'sales_rep', organization_id: null }], ); const ctx = await resolveExecutionContext(opts(ql)); expect(ctx.userId).toBe('u1'); @@ -501,7 +501,7 @@ describe('resolveExecutionContext — ADR-0090 D10 agent principal (OAuth on /mc const capQl = () => { const tables: Record = { sys_member: [{ user_id: 'u1', organization_id: 'orgA', role: 'owner' }], - sys_user_permission_set: [{ id: 'ups1', user_id: 'u1', permission_set_id: 'ps_admin', organization_id: null }], + sys_user_permission_set: [{ id: 'ups1', user_id: 'u1', permission_set_id: 'ps_admin', permission_set: 'admin_full_access', organization_id: null }], 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: [], }; 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 c0b2c886744..ac4865baea0 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 @@ -31,7 +31,7 @@ function fakeObjectQl(tables: Record) { const match = (object: string, where: any): any[] => (tables[object] ?? []).filter((r) => Object.entries(where ?? {}).every(([k, v]) => - { if (k.startsWith('$')) throw new Error(`fake driver: unsupported operator ${k}`); return v && typeof v === 'object' && '$in' in (v as any) ? (v as any).$in.includes(r[k]) : r[k] === v; }, + { if (k.startsWith('$')) throw new Error(`fake driver: unsupported operator ${k}`); if (v === null) return (r[k] ?? null) === null; /* an absent column reads as NULL, as in SQL */ return v && typeof v === 'object' && '$in' in (v as any) ? (v as any).$in.includes(r[k]) : r[k] === v; }, ), ); const engine: any = { @@ -64,7 +64,7 @@ const AUTHZ_TABLES: Record = { sys_user: [{ id: 'u1', email: 'u1@x.com' }], sys_member: [], sys_user_position: [{ user_id: 'u1', position: 'approver', organization_id: null }], - sys_user_permission_set: [{ user_id: 'u1', permission_set_id: 'psE', organization_id: null }], + sys_user_permission_set: [{ user_id: 'u1', permission_set_id: 'psE', permission_set: 'ehr_all', organization_id: null }], sys_permission_set: [{ id: 'psE', name: 'ehr_all', system_permissions: ['cap_ehr'] }], sys_position: [], sys_position_permission_set: [], 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 d031de3a5bf..b628991ecf6 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 @@ -89,6 +89,11 @@ function matchesWhere(row: any, where: any): boolean { if (!(cond as any).$in.includes(row[field])) return false; continue; } + // An absent column reads as NULL, as it does in SQL (`organization_id: null`). + if (cond === null) { + if ((row[field] ?? null) !== null) return false; + continue; + } if (row[field] !== cond) return false; } return true; @@ -122,9 +127,9 @@ function makeQl() { // `u_plain` is deliberately absent: it is the capability control, the one // caller separated by RBAC rather than by posture. sys_user_permission_set: [ - { user_id: 'u_member', permission_set_id: SHARED_SET, organization_id: null }, - { user_id: 'u_exmember', permission_set_id: SHARED_SET, organization_id: null }, - { user_id: 'u_orgless', permission_set_id: SHARED_SET, organization_id: null }, + { user_id: 'u_member', permission_set_id: SHARED_SET, permission_set: 'datasource_operator', organization_id: null }, + { user_id: 'u_exmember', permission_set_id: SHARED_SET, permission_set: 'datasource_operator', organization_id: null }, + { user_id: 'u_orgless', permission_set_id: SHARED_SET, permission_set: 'datasource_operator', organization_id: null }, ], sys_permission_set: [ { 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 8afbee6d31b..c6196924636 100644 --- a/packages/services/service-datasource/src/__tests__/entitled-caller.fixture.ts +++ b/packages/services/service-datasource/src/__tests__/entitled-caller.fixture.ts @@ -99,7 +99,7 @@ export function createGrantsEngine() { ? [{ id: 'ups_datasource_operator', user_id: ENTITLED_USER, - permission_set_id: GRANT_SET_ID, + permission_set_id: GRANT_SET_ID, permission_set: GRANT_SET_NAME, // Unscoped (null org) — the grant is platform-scoped, matching // `manage_platform_settings`'s own `scope: 'platform'`. organization_id: null, @@ -107,8 +107,10 @@ export function createGrantsEngine() { : []; } if (object === 'sys_permission_set') { + // By id (a position binding) or by name (a user grant, ADR-0131 D4). const ids: unknown = opts?.where?.id?.$in; - return Array.isArray(ids) && ids.includes(GRANT_SET_ID) + const names: unknown = opts?.where?.name?.$in; + return (Array.isArray(ids) && ids.includes(GRANT_SET_ID)) || (Array.isArray(names) && names.includes(GRANT_SET_NAME)) ? [{ id: GRANT_SET_ID, name: GRANT_SET_NAME, 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 a5e96ec45bb..a505a0918c5 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 @@ -144,6 +144,11 @@ function matchesWhere(row: any, where: any): boolean { if (!(cond as any).$in.includes(row[field])) return false; continue; } + // An absent column reads as NULL, as it does in SQL (`organization_id: null`). + if (cond === null) { + if ((row[field] ?? null) !== null) return false; + continue; + } if (row[field] !== cond) return false; } return true; @@ -177,10 +182,10 @@ const PERMISSION_TABLES: Record = { sys_position: [], sys_position_permission_set: [], sys_user_permission_set: [ - { id: 'ups_member', user_id: 'u_member', permission_set_id: 'ps_settings', organization_id: null }, - { id: 'ups_exmember', user_id: 'u_exmember', permission_set_id: 'ps_settings', organization_id: null }, - { id: 'ups_orgless', user_id: 'u_orgless', permission_set_id: 'ps_settings', organization_id: null }, - { id: 'ups_session', user_id: 'u_session_exmember', permission_set_id: 'ps_settings', organization_id: null }, + { id: 'ups_member', user_id: 'u_member', permission_set_id: 'ps_settings', permission_set: 'settings_admin', organization_id: null }, + { id: 'ups_exmember', user_id: 'u_exmember', permission_set_id: 'ps_settings', permission_set: 'settings_admin', organization_id: null }, + { id: 'ups_orgless', user_id: 'u_orgless', permission_set_id: 'ps_settings', permission_set: 'settings_admin', organization_id: null }, + { id: 'ups_session', user_id: 'u_session_exmember', permission_set_id: 'ps_settings', permission_set: 'settings_admin', organization_id: null }, ], sys_permission_set: [ { id: 'ps_settings', name: 'settings_admin', system_permissions: ['setup.access', 'setup.write'] },