diff --git a/.changeset/24583-7a2-organization-less-grant-readers-retired.md b/.changeset/24583-7a2-organization-less-grant-readers-retired.md new file mode 100644 index 00000000000..cd50487b8df --- /dev/null +++ b/.changeset/24583-7a2-organization-less-grant-readers-retired.md @@ -0,0 +1,22 @@ +--- +'@objectstack/core': minor +'@objectstack/plugin-security': minor +'@objectstack/runtime': patch +--- + +feat(core,plugin-security)!: a grant applies only in its own organization — an organization-less grant row confers nothing, and the Default Organization's `admin_full_access` grant is the only platform-admin anchor + +Clause-②: yes (narrowing) + + + +**BREAKING** for a stored grant row that carries no `organization_id` (`NULL`, absent or blank), shipped as `minor` under the repo's launch-window convention for breaking changes (ADR-0131 D10). After the ownership ceremony no such row exists: it attributes every one to an organization and makes the column NOT NULL, a server refuses a database it did not create or certify, and a fresh install that registers the organization object never writes one. So on a certified or fresh database nothing resolves differently. + +- **`resolveUserAuthzGrants` / `resolveAuthzContext` (`@objectstack/core`).** A `sys_user_position` or `sys_user_permission_set` row applies only while its organization is the active one. FROM: a row with no organization was "global" and applied in every resolution, including one with no active organization. TO: it applies nowhere, and a resolution with no active organization gets no stored grant at all. +- **Platform-admin standing.** FROM: under `single`, an organization-less `admin_full_access` grant anchored `platform_admin`, beside the Default Organization's. TO: only the Default Organization's grant anchors it (still in every organization, and with none active). Walled postures are unchanged: standing there comes from `OS_PLATFORM_OWNER_EMAIL` alone. +- **`bootstrapPlatformAdmin` (`@objectstack/plugin-security`).** The existing-admin check reads only the Default Organization's grant. When no Default Organization can be named (a walled posture, or none to name), it reads no grant at all. The walled "no owner email declared" error drops its sentence about a legacy unscoped grant row. +- **The delegated-administration gate.** A held position counts toward self-delegation only when its row belongs to the caller's active organization. An organization-less holding, or a caller with no active organization, holds nothing there, which is what the resolver grants. An organization administrator's removal of an organization-less row is refused as "not one of the organization you administer". +- **The run-lifecycle refusal (`@objectstack/runtime`).** Under `single` it now names the Default Organization's `admin_full_access` grant as the remedy, where it named an unscoped one. +- **What a lean composition sees.** A composition that registers no organization object (`sys_organization`, which plugin-auth ships) still stores grants without an organization, and those grants now confer nothing. No shipped composition does this: `os serve` registers the security plugin only together with plugin-auth. + +**What an operator does.** Nothing beyond the upgrade itself: run `os migrate organization-ownership` before serving a database that 17.x or an 18.0 pre-release created. The server refuses such a database until you do. If an embedding composes `@objectstack/plugin-security` without plugin-auth, register the organization object so that grants carry an organization. diff --git a/packages/cloud-connection/src/cloud-connection-auth-gate.pin.test.ts b/packages/cloud-connection/src/cloud-connection-auth-gate.pin.test.ts index 2f824bc650a..a823e29401c 100644 --- a/packages/cloud-connection/src/cloud-connection-auth-gate.pin.test.ts +++ b/packages/cloud-connection/src/cloud-connection-auth-gate.pin.test.ts @@ -267,9 +267,14 @@ const APP = { }; const LEDGER_FILE = 'com.acme.gated.json'; -/** Every test user holds the authoring capability, so a refusal below is the gate's alone. */ +/** + * Every test user holds the authoring capability, so a refusal below is the gate's alone: + * the `admin_full_access` grant owned by the Default Organization, which applies with no + * active organization. + */ function grantRows(): Record { return { + sys_organization: [{ id: 'org_default', slug: 'default' }], sys_user: Object.keys(POSTURES).map((id) => ({ id, email: `${id}@acme.test` })), sys_member: [], sys_user_position: [], @@ -277,7 +282,7 @@ function grantRows(): Record { sys_metadata_activation: [], sys_position_permission_set: [], sys_user_permission_set: Object.keys(POSTURES).map((id, i) => ({ - id: `ups${i}`, user_id: id, permission_set_id: 'ps1', permission_set: 'admin_full_access', organization_id: null, + id: `ups${i}`, user_id: id, permission_set_id: 'ps1', permission_set: 'admin_full_access', organization_id: 'org_default', })), sys_permission_set: [{ id: 'ps1', name: 'admin_full_access', system_permissions: ['manage_metadata', 'studio.access', 'setup.access'] }], }; diff --git a/packages/cloud-connection/src/cloud-connection-plugin.test.ts b/packages/cloud-connection/src/cloud-connection-plugin.test.ts index 28a58b77e7e..12745233deb 100644 --- a/packages/cloud-connection/src/cloud-connection-plugin.test.ts +++ b/packages/cloud-connection/src/cloud-connection-plugin.test.ts @@ -56,19 +56,24 @@ function makeC(url: string, body?: any) { }; } +/** The organization the sessions act in, and the one {@link setupAccessEngine}'s grant is owned by. */ +const ORG = 'o1'; + const sessionAuth = (userId?: string) => ({ - api: { getSession: async () => (userId ? { user: { id: userId } } : null) }, + api: { getSession: async () => (userId ? { user: { id: userId }, session: { activeOrganizationId: ORG } } : null) }, }); /** * [#24148] A data engine under which `userId` holds `setup.access` — what * `/status` serves its full answer behind. Real permission-set rows, read by - * the shared resolver through `find`. + * the shared resolver through `find`. The grant is owned by {@link ORG}, the + * organization the session acts in: a grant applies only in its own + * organization. */ const setupAccessEngine = (userId: string) => { const rows: Record = { sys_user: [{ id: userId, email: `${userId}@example.test` }], - sys_user_permission_set: [{ id: 'ups_1', user_id: userId, permission_set_id: 'ps_1', permission_set: 'setup_admin', organization_id: null }], + sys_user_permission_set: [{ id: 'ups_1', user_id: userId, permission_set_id: 'ps_1', permission_set: 'setup_admin', organization_id: ORG }], sys_permission_set: [{ id: 'ps_1', name: 'setup_admin', system_permissions: ['setup.access'] }], }; return bindCatalogFromTables({ find: async (object: string) => rows[object] ?? [] }, rows); diff --git a/packages/cloud-connection/src/cloud-connection-runtime-version.pin.test.ts b/packages/cloud-connection/src/cloud-connection-runtime-version.pin.test.ts index 4a18234c408..86f0ff8617a 100644 --- a/packages/cloud-connection/src/cloud-connection-runtime-version.pin.test.ts +++ b/packages/cloud-connection/src/cloud-connection-runtime-version.pin.test.ts @@ -35,10 +35,11 @@ const CC = '/api/v1/cloud-connection'; const ORIGIN = 'https://env.example.test'; const CLOUD = 'http://cloud.test'; -/** Holds `setup.access`, which the two bind doors demand. */ +/** Holds `setup.access`, which the two bind doors demand — in {@link ORG}, the organization the session acts in. */ const ADMIN = 'u_admin'; +const ORG = 'o1'; const GRANTS: Record = { - sys_user_permission_set: [{ id: 'ups_1', user_id: ADMIN, permission_set_id: 'ps_1', permission_set: 'setup_admin', organization_id: null }], + sys_user_permission_set: [{ id: 'ups_1', user_id: ADMIN, permission_set_id: 'ps_1', permission_set: 'setup_admin', organization_id: ORG }], sys_permission_set: [{ id: 'ps_1', name: 'setup_admin', system_permissions: ['setup.access'] }], }; @@ -95,7 +96,7 @@ async function mount(mode: Mode, discovery: Discovery) { post: (p: string, h: Handler) => routes.set(`POST ${p}`, h), }; const hooks = new Map(); - const auth = { api: { getSession: async () => ({ user: { id: ADMIN }, session: { id: 'sess_admin' } }) } }; + const auth = { api: { getSession: async () => ({ user: { id: ADMIN }, session: { id: 'sess_admin', activeOrganizationId: ORG } }) } }; const objectql = bindCatalogFromTables({ find: async (object: string) => GRANTS[object] ?? [] }, GRANTS); const protocol = discovery === 'real' ? discoveryProducer() diff --git a/packages/cloud-connection/src/cloud-connection-setup-access.pin.test.ts b/packages/cloud-connection/src/cloud-connection-setup-access.pin.test.ts index f4f7a015b17..4095b296df8 100644 --- a/packages/cloud-connection/src/cloud-connection-setup-access.pin.test.ts +++ b/packages/cloud-connection/src/cloud-connection-setup-access.pin.test.ts @@ -38,20 +38,22 @@ import { bindCatalogFromTables } from './__tests__/security-catalog.testkit.js'; const CC = '/api/v1/cloud-connection'; const ORIGIN = 'https://env.example.test'; -/** Holds `setup.access` through an unscoped grant (the platform-admin shape). */ +/** Holds `setup.access` through the Default Organization's `admin_full_access` grant (the platform-admin shape: it applies with no active organization). */ const ADMIN = 'u_setup_admin'; /** Holds `setup.access` through a grant scoped to the organization its session is active in (the organization-admin shape). */ const ORG_ADMIN = 'u_org_admin'; -/** Signed in, holds a permission set, and the set carries no `setup.access`. */ +/** Signed in, holds a permission set in the organization its session is active in, and the set carries no `setup.access`. */ const MEMBER = 'u_member'; -/** Holds `setup.access`, but the ADR-0069 policy holds the session. */ +/** Holds `setup.access` (the platform-admin shape, as {@link ADMIN}), but the ADR-0069 policy holds the session. */ const HELD_ADMIN = 'u_held_admin'; const ORG = 'org_a'; +/** The Default Organization — the owner of the one `admin_full_access` grant that anchors platform standing. */ +const DEFAULT_ORG = 'org_default'; const SESSIONS: Record = { [ADMIN]: {}, [ORG_ADMIN]: { activeOrganizationId: ORG }, - [MEMBER]: {}, + [MEMBER]: { activeOrganizationId: ORG }, [HELD_ADMIN]: { authGate: { code: 'PASSWORD_EXPIRED', message: 'You must change your password before continuing.' } }, }; @@ -61,18 +63,20 @@ const ADMIN_KEY = 'osk_setup_admin_key_24148'; function grantRows(): Record { return { sys_user: Object.keys(SESSIONS).map((id) => ({ id, email: `${id}@example.test` })), + sys_organization: [{ id: DEFAULT_ORG, slug: 'default' }, { id: ORG, slug: 'org-a' }], sys_member: [{ id: 'mem_1', user_id: ORG_ADMIN, organization_id: ORG, role: 'admin' }], sys_user_position: [], sys_position: [], sys_metadata_activation: [], sys_position_permission_set: [], sys_user_permission_set: [ - { id: 'ups_admin', user_id: ADMIN, permission_set_id: 'ps_setup', permission_set: 'setup_admin', organization_id: null }, + { id: 'ups_admin', user_id: ADMIN, permission_set_id: 'ps_full', permission_set: 'admin_full_access', organization_id: DEFAULT_ORG }, { id: 'ups_org_admin', user_id: ORG_ADMIN, permission_set_id: 'ps_setup', permission_set: 'setup_admin', organization_id: ORG }, - { id: 'ups_held', user_id: HELD_ADMIN, permission_set_id: 'ps_setup', permission_set: 'setup_admin', organization_id: null }, - { id: 'ups_member', user_id: MEMBER, permission_set_id: 'ps_member', permission_set: 'member_basic', organization_id: null }, + { id: 'ups_held', user_id: HELD_ADMIN, permission_set_id: 'ps_full', permission_set: 'admin_full_access', organization_id: DEFAULT_ORG }, + { id: 'ups_member', user_id: MEMBER, permission_set_id: 'ps_member', permission_set: 'member_basic', organization_id: ORG }, ], sys_permission_set: [ + { id: 'ps_full', name: 'admin_full_access', system_permissions: ['manage_metadata', 'studio.access', 'setup.access'] }, { id: 'ps_setup', name: 'setup_admin', system_permissions: ['setup.access'] }, { id: 'ps_member', name: 'member_basic', system_permissions: ['export_data'] }, ], diff --git a/packages/cloud-connection/src/connection-credential-store.test.ts b/packages/cloud-connection/src/connection-credential-store.test.ts index a0630254f87..f1222f99ba3 100644 --- a/packages/cloud-connection/src/connection-credential-store.test.ts +++ b/packages/cloud-connection/src/connection-credential-store.test.ts @@ -55,15 +55,18 @@ function makeRawApp() { post: (p: string, h: Handler) => routes.set(`POST ${p}`, h), }; } -const sessionAuth = (userId?: string) => ({ api: { getSession: async () => (userId ? { user: { id: userId } } : null) } }); +/** The organization the sessions act in, and the one {@link ADMIN_GRANTS}' grant is owned by. */ +const ORG = 'o1'; +const sessionAuth = (userId?: string) => ({ api: { getSession: async () => (userId ? { user: { id: userId }, session: { activeOrganizationId: ORG } } : null) } }); /** * [#24148] The suites below sign in as `admin`, who must hold `setup.access` to * reach the connection-changing doors (their subject is the credential, not the * permission — that is `cloud-connection-setup-access.pin.test.ts`). Real - * permission-set rows, read by the shared resolver through `find`. + * permission-set rows, read by the shared resolver through `find`; the grant is + * owned by {@link ORG}, the organization the session acts in. */ const ADMIN_GRANTS: Record = { - sys_user_permission_set: [{ id: 'ups_1', user_id: 'admin', permission_set_id: 'ps_1', permission_set: 'setup_admin', organization_id: null }], + sys_user_permission_set: [{ id: 'ups_1', user_id: 'admin', permission_set_id: 'ps_1', permission_set: 'setup_admin', organization_id: ORG }], sys_permission_set: [{ id: 'ps_1', name: 'setup_admin', system_permissions: ['setup.access'] }], }; /** diff --git a/packages/cloud-connection/src/error-envelope.conformance.test.ts b/packages/cloud-connection/src/error-envelope.conformance.test.ts index 598f58c02ec..b3f1d610ece 100644 --- a/packages/cloud-connection/src/error-envelope.conformance.test.ts +++ b/packages/cloud-connection/src/error-envelope.conformance.test.ts @@ -189,12 +189,13 @@ async function mountCloudConnection(opts: { const hooks = new Map(); const auth = { api: { - getSession: async () => (opts.userId ? { user: { id: opts.userId }, session: {} } : null), + // The session acts in `o1`, the organization the grant below is owned by. + getSession: async () => (opts.userId ? { user: { id: opts.userId }, session: { activeOrganizationId: 'o1' } } : null), }, }; const grantRows: Record = opts.userId && opts.setupAccess !== false ? { - sys_user_permission_set: [{ id: 'ups_1', user_id: opts.userId, permission_set_id: 'ps_1', permission_set: 'setup_admin', organization_id: null }], + sys_user_permission_set: [{ id: 'ups_1', user_id: opts.userId, permission_set_id: 'ps_1', permission_set: 'setup_admin', organization_id: 'o1' }], sys_permission_set: [{ id: 'ps_1', name: 'setup_admin', system_permissions: ['setup.access'] }], } : {}; diff --git a/packages/cloud-connection/src/install-local-principal.fixtures.ts b/packages/cloud-connection/src/install-local-principal.fixtures.ts index f182e893ee8..e3a51a4c5d8 100644 --- a/packages/cloud-connection/src/install-local-principal.fixtures.ts +++ b/packages/cloud-connection/src/install-local-principal.fixtures.ts @@ -47,22 +47,39 @@ export const INSTALLER_USER_ID = 'admin'; */ export const INSTALLER_SET = { name: 'admin_full_access', systemPermissions: ['manage_metadata', 'studio.access', 'setup.access'] }; +/** + * [ADR-0131 D5] The Default Organization the installer's grant is owned by — + * the organization `resolveAuthzContext` names by `slug = 'default'`. Only the + * `admin_full_access` grant that organization owns anchors platform standing, + * and an anchoring grant applies whatever organization the request acts in + * (none included), so these fixtures stay independent of the active tenant. + */ +export const INSTALLER_ORGANIZATION = { id: 'org_default', slug: 'default' }; + /** * The `sys_*` rows that make `userId` a holder of `manage_metadata`, shaped the - * way `resolveAuthzContext` reads them (an UNSCOPED `sys_user_permission_set` - * grant pointing at a `sys_permission_set` whose `system_permissions` carry the - * capability — the shipped `admin_full_access` shape). + * way `resolveAuthzContext` reads them (a `sys_user_permission_set` grant owned + * by {@link INSTALLER_ORGANIZATION}, pointing at a `sys_permission_set` whose + * `system_permissions` carry the capability — the shipped `admin_full_access` + * shape the first-user promotion writes). An organization-less grant would + * apply nowhere and anchor nothing. + * + * `sys_organization` is deliberately NOT among them: suites wire their own + * organizations. {@link answerDefaultOrganizationRead} supplies the Default + * Organization only where the suite's engine names none. */ export function installerGrantRows(userId: string = INSTALLER_USER_ID): Record { return { - sys_user: [{ id: userId, email: `${userId}@objectstack.test` }], + // Verified, so a suite that declares the installer the platform owner + // ({@link declareInstallerPlatformOwner}) is read as one. + sys_user: [{ id: userId, email: installerEmail(userId), email_verified: true }], sys_member: [], sys_user_position: [], sys_position: [], sys_metadata_activation: [], sys_position_permission_set: [], sys_user_permission_set: [ - { id: 'ups_installer', user_id: userId, permission_set_id: 'ps_installer', permission_set: 'admin_full_access', organization_id: null }, + { id: 'ups_installer', user_id: userId, permission_set_id: 'ps_installer', permission_set: 'admin_full_access', organization_id: INSTALLER_ORGANIZATION.id }, ], sys_permission_set: [ { @@ -74,6 +91,65 @@ export function installerGrantRows(userId: string = INSTALLER_USER_ID): Record } | undefined)?.where; + if (!where || Object.keys(where).length !== 1 || where.slug !== 'default') return undefined; + const rows = innerRows && (innerRows as { value?: unknown }).value !== undefined ? (innerRows as { value: unknown }).value : innerRows; + if (Array.isArray(rows) && rows.some((row) => (row as { slug?: unknown } | null)?.slug === 'default')) return undefined; + return [{ ...INSTALLER_ORGANIZATION }]; +} + +/** + * A suite's real registry, except that it holds `sys_organization`: the + * resolver reads no Default Organization out of a composition whose registry + * lacks the object, and every deployment the installer exists in registers it. + * Every other member answers from the suite's registry, bound to it. + */ +function registryHoldingOrganizations(registry: any): any { + if (!registry || typeof registry.getObject !== 'function') return registry; + return new Proxy(registry, { + get(target, prop) { + if (prop === 'getObject') { + return (name: string, ...rest: unknown[]) => + target.getObject(name, ...rest) ?? (name === 'sys_organization' ? { name: 'sys_organization', fields: {} } : undefined); + } + const value = Reflect.get(target, prop, target); + return typeof value === 'function' ? value.bind(target) : value; + }, + }); +} + +/** The installer's stored address. */ +export function installerEmail(userId: string = INSTALLER_USER_ID): string { + return `${userId}@objectstack.test`; +} + +/** + * [#11663 L5] Declare the installer the deployment's platform owner + * (`OS_PLATFORM_OWNER_EMAIL`) — the one platform administrator a WALLED posture + * (`group` / `isolated`) has: there no stored grant anchors standing, and with + * no active organization no grant applies, so a walled suite whose request + * carries no organization installs as the declared owner, exactly as a walled + * deployment's operator does. Returns the restore; call it in `afterEach`. + */ +export function declareInstallerPlatformOwner(userId: string = INSTALLER_USER_ID): () => void { + const previous = process.env.OS_PLATFORM_OWNER_EMAIL; + process.env.OS_PLATFORM_OWNER_EMAIL = installerEmail(userId); + return () => { + if (previous === undefined) delete process.env.OS_PLATFORM_OWNER_EMAIL; + else process.env.OS_PLATFORM_OWNER_EMAIL = previous; + }; +} + /** The `auth` service shape the plugin resolves a session through. */ export function installerAuthService(userId: string = INSTALLER_USER_ID) { return { api: { getSession: async () => ({ user: { id: userId }, session: {} }) } }; @@ -81,8 +157,11 @@ export function installerAuthService(userId: string = INSTALLER_USER_ID) { /** * Wrap an existing `objectql` fake so the authorization tables answer from - * {@link installerGrantRows} and EVERY other object falls through to whatever - * the suite already wired. + * {@link installerGrantRows}, the resolver's Default Organization read answers + * {@link INSTALLER_ORGANIZATION} when the suite names none + * ({@link answerDefaultOrganizationRead}; a suite registry is made to hold + * `sys_organization` so the read is issued), and EVERY other object falls + * through to whatever the suite already wired. * * Wrapping rather than replacing is the point: these suites' engines carry * behaviour their own assertions depend on (seed lookups, ledger probes, @@ -98,9 +177,20 @@ export function withInstallerGrants>( const inner = typeof engine?.find === 'function' ? engine.find.bind(engine) : undefined; const wrapped = { ...engine, + ...(engine?.registry ? { registry: registryHoldingOrganizations(engine.registry) } : {}), find: async (object: string, options?: unknown) => { if (Object.prototype.hasOwnProperty.call(rows, object)) return rows[object]; - return inner ? inner(object, options) : []; + let answered: unknown; + try { + answered = inner ? await inner(object, options) : []; + } catch (err) { + // An engine that registers no organization object refuses the read; + // the installer's Default Organization is still the one it names. + const supplied = answerDefaultOrganizationRead(object, options, []); + if (supplied) return supplied; + throw err; + } + return answerDefaultOrganizationRead(object, options, answered) ?? answered; }, } as T; // What the security plugin binds to a real engine: the catalog the diff --git a/packages/cloud-connection/src/marketplace-install-local-api-key-account-posture.pin.test.ts b/packages/cloud-connection/src/marketplace-install-local-api-key-account-posture.pin.test.ts index b9cf74e512a..72dd977ab75 100644 --- a/packages/cloud-connection/src/marketplace-install-local-api-key-account-posture.pin.test.ts +++ b/packages/cloud-connection/src/marketplace-install-local-api-key-account-posture.pin.test.ts @@ -94,9 +94,14 @@ function matchesWhere(row: Record, where: unknown): boolean { return true; } -/** The key's owner holds the authoring capability, so a refusal below is the gate's alone. */ +/** + * The key's owner holds the authoring capability, so a refusal below is the gate's alone: + * the `admin_full_access` grant owned by the Default Organization, which applies with no + * active organization (a key carries none here). + */ function grantRows(): Record>> { return { + sys_organization: [{ id: 'org_default', slug: 'default' }], sys_api_key: [{ id: 'key_1', key: hashApiKey(KEY), user_id: OWNER, revoked: false, scopes: '[]' }], sys_user: [{ id: OWNER, email: 'owner@acme.test' }], sys_member: [], @@ -105,7 +110,7 @@ function grantRows(): Record>> { sys_metadata_activation: [], sys_position_permission_set: [], sys_user_permission_set: [ - { id: 'ups1', user_id: OWNER, permission_set_id: 'ps1', permission_set: 'admin_full_access', organization_id: null }, + { id: 'ups1', user_id: OWNER, permission_set_id: 'ps1', permission_set: 'admin_full_access', organization_id: 'org_default' }, ], sys_permission_set: [{ id: 'ps1', name: 'admin_full_access', system_permissions: ['manage_metadata', 'studio.access', 'setup.access'] }], }; diff --git a/packages/cloud-connection/src/marketplace-install-local-capability-enumeration.test.ts b/packages/cloud-connection/src/marketplace-install-local-capability-enumeration.test.ts index 3b5acae1079..27e82e2da0b 100644 --- a/packages/cloud-connection/src/marketplace-install-local-capability-enumeration.test.ts +++ b/packages/cloud-connection/src/marketplace-install-local-capability-enumeration.test.ts @@ -108,6 +108,11 @@ function makeRawApp() { }; } +/** The Default Organization — the owner of the one `admin_full_access` grant that anchors platform standing. */ +const DEFAULT_ORG = 'org_default'; +/** The tenant organization the org-admin shape administers, and its session is active in. */ +const TENANT_ORG = 'org_acme'; + /** The three principal shapes the ruling names, as the resolver sees them. */ type Shape = 'header-only' | 'member' | 'org-admin' | 'capable'; @@ -117,6 +122,12 @@ type Shape = 'header-only' | 'member' | 'org-admin' | 'capable'; * + `sys_permission_set` rows rather than handing the plugin a pre-baked * capability list is what keeps this test honest about the resolution path: a * gate wired to a different aggregate would not see these. + * + * Every grant is owned by an organization: the capable shape's by the Default + * Organization (the platform-admin shape, which applies with no active + * organization), the org-admin shape's by {@link TENANT_ORG}, the organization + * its session is active in — so its capabilities are really held, and its + * refusal is the missing `manage_metadata`, not a grant that applies nowhere. */ function grantRows(shape: Shape): Record { const sets: Record = { @@ -129,6 +140,7 @@ function grantRows(shape: Shape): Record { }; const held = sets[shape]; return { + sys_organization: [{ id: DEFAULT_ORG, slug: 'default' }, { id: TENANT_ORG, slug: 'acme' }], sys_user: [{ id: `usr_${shape}`, email: `${shape}@acme.test` }], sys_member: [], sys_user_position: [], @@ -138,7 +150,8 @@ function grantRows(shape: Shape): Record { sys_user_permission_set: held.length ? [{ id: 'ups1', user_id: `usr_${shape}`, permission_set_id: 'ps1', - permission_set: shape === 'capable' ? 'admin_full_access' : 'organization_admin', organization_id: null, + permission_set: shape === 'capable' ? 'admin_full_access' : 'organization_admin', + organization_id: shape === 'capable' ? DEFAULT_ORG : TENANT_ORG, }] : [], sys_permission_set: held.length @@ -192,7 +205,13 @@ async function mount(shape: Shape, storageDir: string) { const services: Record = { manifest: { register }, - auth: { api: { getSession: async () => (sessionUser ? { user: sessionUser, session: {} } : null) } }, + auth: { + api: { + getSession: async () => (sessionUser + ? { user: sessionUser, session: shape === 'org-admin' ? { activeOrganizationId: TENANT_ORG } : {} } + : null), + }, + }, objectql: bindCatalogFromTables({ syncSchemas, find: async (object: string) => (object === 'widget' ? widgets.map((w) => ({ ...w })) : rows[object] ?? []), diff --git a/packages/cloud-connection/src/marketplace-install-local-install-modes.test.ts b/packages/cloud-connection/src/marketplace-install-local-install-modes.test.ts index 5b1c617a079..4ff094baf78 100644 --- a/packages/cloud-connection/src/marketplace-install-local-install-modes.test.ts +++ b/packages/cloud-connection/src/marketplace-install-local-install-modes.test.ts @@ -34,7 +34,7 @@ import { tmpdir } from 'node:os'; // top, never inside a clocked `it` (`scripts/check-test-source-alias.mjs`). import '@objectstack/runtime'; import { MarketplaceInstallLocalPlugin } from './marketplace-install-local-plugin.js'; -import { installerAuthService, withInstallerGrants } from './install-local-principal.fixtures.js'; +import { declareInstallerPlatformOwner, installerAuthService, withInstallerGrants } from './install-local-principal.fixtures.js'; import { LocalManifestSource } from './local-manifest-source.js'; const ROUTE = '/api/v1/marketplace/install-local'; @@ -251,6 +251,11 @@ describe('install-local — the package must permit the mode (`installModes`)', }); describe('install-local — `unmanaged` under a walled REQUESTED posture', () => { + // The request carries no organization, so under a wall the installer is + // the deployment's declared platform owner. + let restoreOwner: () => void; + beforeEach(() => { restoreOwner = declareInstallerPlatformOwner(INSTALLER); }); + afterEach(() => restoreOwner()); // An UNRECOGNISED posture value is read as walled by the same function // (pinned in `package-unmanaged-install.test.ts`); at this door the // install-capability admission refuses such a boot first, so it is not a 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 346bbb9fb50..7c2eecf47ba 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 @@ -85,6 +85,11 @@ function makeRawApp() { }; } +/** The Default Organization — the owner of the one `admin_full_access` grant that anchors platform standing. */ +const DEFAULT_ORG = 'org_default'; +/** The tenant organization the member shape administers, and its session is active in. */ +const TENANT_ORG = 'org_acme'; + /** The three caller shapes the ruling names, as the shared resolver sees them. */ type Shape = 'anonymous' | 'member' | 'operator'; @@ -96,6 +101,11 @@ type Shape = 'anonymous' | 'member' | 'operator'; * through the same `find` production calls is what keeps this file honest about * the resolution path: a gate rewired to some other aggregate would not see * these and would fail here rather than silently keep passing. + * + * Every grant is owned by an organization: the operator's by the Default + * Organization (the platform-admin shape, which applies with no active + * organization), the member's by {@link TENANT_ORG}, the organization its + * session is active in — so the narrowed row really holds its capabilities. */ function grantRows(shape: Shape): Record { // `organization_admin` deliberately withholds `manage_metadata` — the @@ -106,6 +116,7 @@ function grantRows(shape: Shape): Record { ? ['setup.access', 'manage_org_users'] : []; return { + sys_organization: [{ id: DEFAULT_ORG, slug: 'default' }, { id: TENANT_ORG, slug: 'acme' }], sys_user: [{ id: `usr_${shape}`, email: `${shape}@acme.test` }], sys_member: [], sys_user_position: [], @@ -115,7 +126,8 @@ function grantRows(shape: Shape): Record { sys_user_permission_set: held.length ? [{ id: 'ups1', user_id: `usr_${shape}`, permission_set_id: 'ps1', - permission_set: shape === 'operator' ? 'admin_full_access' : 'organization_admin', organization_id: null, + permission_set: shape === 'operator' ? 'admin_full_access' : 'organization_admin', + organization_id: shape === 'operator' ? DEFAULT_ORG : TENANT_ORG, }] : [], sys_permission_set: held.length @@ -164,7 +176,13 @@ async function mount(shape: Shape, storageDir: string) { const services: Record = { manifest: { register: vi.fn() }, - auth: { api: { getSession: async () => (sessionUser ? { user: sessionUser, session: {} } : null) } }, + auth: { + api: { + getSession: async () => (sessionUser + ? { user: sessionUser, session: shape === 'member' ? { activeOrganizationId: TENANT_ORG } : {} } + : null), + }, + }, objectql: bindCatalogFromTables({ syncSchemas: vi.fn(async () => undefined), find: async (object: string) => rows[object] ?? [] }, rows), metadata: {}, }; 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 69aef94f2c5..3172e2efe87 100644 --- a/packages/cloud-connection/src/marketplace-install-local-listing-not-loaded.test.ts +++ b/packages/cloud-connection/src/marketplace-install-local-listing-not-loaded.test.ts @@ -43,7 +43,7 @@ import { PROTOCOL_MAJOR } from '@objectstack/spec/kernel'; import '@objectstack/runtime'; import { MarketplaceInstallLocalPlugin } from './marketplace-install-local-plugin.js'; import { LocalManifestSource, type InstalledManifestEntry } from './local-manifest-source.js'; -import { installerGrantRows, INSTALLER_USER_ID, INSTALLER_SET } from './install-local-principal.fixtures.js'; +import { answerDefaultOrganizationRead, installerGrantRows, INSTALLER_USER_ID, INSTALLER_SET } from './install-local-principal.fixtures.js'; import { bindTestSecurityCatalog } from './__tests__/security-catalog.testkit.js'; type Handler = (c: any) => Promise; @@ -82,8 +82,12 @@ function ledgerEntry(manifest: { id: string; version: string }): InstalledManife }; } -/** A tenant administrator that does NOT hold `manage_metadata`: the narrowed caller. */ +/** + * A tenant administrator that does NOT hold `manage_metadata`: the narrowed caller. Its + * grant is owned by {@link MEMBER_ORG}, the organization its session is active in. + */ const MEMBER_ID = 'usr_member'; +const MEMBER_ORG = 'org_acme'; const MEMBER_GRANTS: Record = { sys_user: [{ id: MEMBER_ID, email: 'member@objectstack.test' }], sys_member: [], @@ -91,7 +95,7 @@ const MEMBER_GRANTS: Record = { sys_position: [], sys_metadata_activation: [], sys_position_permission_set: [], - sys_user_permission_set: [{ id: 'ups_member', user_id: MEMBER_ID, permission_set_id: 'ps_member', permission_set: 'organization_admin', organization_id: null }], + sys_user_permission_set: [{ id: 'ups_member', user_id: MEMBER_ID, permission_set_id: 'ps_member', permission_set: 'organization_admin', organization_id: MEMBER_ORG }], sys_permission_set: [{ id: 'ps_member', name: 'organization_admin', system_permissions: ['setup.access', 'manage_org_users'] }], }; @@ -119,6 +123,9 @@ async function restartWith(manifests: Array<{ id: string; version: string }>, di async find(object: string, query?: any): Promise { const granted = grants(); if (Object.prototype.hasOwnProperty.call(granted, object)) return granted[object]!; + // The resolver's Default Organization read: the operator's grant is owned by it. + const defaultOrganization = answerDefaultOrganizationRead(object, query, []); + if (defaultOrganization) return defaultOrganization; reads.push(object); if (!objects.has(object)) throw new Error(`Object '${object}' not found`); const where: Record = query?.where ?? {}; @@ -137,7 +144,7 @@ async function restartWith(manifests: Array<{ id: string; version: string }>, di const register = vi.fn((m: any) => { for (const o of m?.objects ?? []) objects.set(o.name, o); }); const services: Record = { manifest: { register }, - auth: { api: { getSession: async () => ({ user: { id: caller.as === 'operator' ? INSTALLER_USER_ID : MEMBER_ID }, session: {} }) } }, + auth: { api: { getSession: async () => ({ user: { id: caller.as === 'operator' ? INSTALLER_USER_ID : MEMBER_ID }, session: caller.as === 'operator' ? {} : { activeOrganizationId: MEMBER_ORG } }) } }, objectql: engine, metadata: { getObject: async (name: string) => objects.get(name) }, }; diff --git a/packages/cloud-connection/src/marketplace-install-local-posture-gate.test.ts b/packages/cloud-connection/src/marketplace-install-local-posture-gate.test.ts index e04011f204c..53bbb1d8214 100644 --- a/packages/cloud-connection/src/marketplace-install-local-posture-gate.test.ts +++ b/packages/cloud-connection/src/marketplace-install-local-posture-gate.test.ts @@ -53,7 +53,7 @@ import { tmpdir } from 'node:os'; // (the clocked-window rule, `scripts/check-test-source-alias.mjs`). import '@objectstack/runtime'; import { MarketplaceInstallLocalPlugin } from './marketplace-install-local-plugin.js'; -import { installerAuthService, withInstallerGrants } from './install-local-principal.fixtures.js'; +import { declareInstallerPlatformOwner, installerAuthService, withInstallerGrants } from './install-local-principal.fixtures.js'; import { LocalManifestSource } from './local-manifest-source.js'; type Handler = (c: any) => Promise; @@ -135,12 +135,17 @@ const EXPECTED_IDS = ['material:field:code', 'material:index:external_id']; let dir: string; let posture: string | undefined; +let restoreOwner: () => void; beforeEach(() => { dir = mkdtempSync(join(tmpdir(), 'mil-d5e-')); posture = process.env.OS_TENANCY_POSTURE; + // The request carries no organization, and most of these run walled: the + // installer is the deployment's declared platform owner. + restoreOwner = declareInstallerPlatformOwner('usr_installer'); }); afterEach(() => { + restoreOwner(); rmSync(dir, { recursive: true, force: true }); if (posture === undefined) delete process.env.OS_TENANCY_POSTURE; else process.env.OS_TENANCY_POSTURE = posture; diff --git a/packages/cloud-connection/src/marketplace-install-local-purge.test.ts b/packages/cloud-connection/src/marketplace-install-local-purge.test.ts index 9b377eba365..5eb6d420357 100644 --- a/packages/cloud-connection/src/marketplace-install-local-purge.test.ts +++ b/packages/cloud-connection/src/marketplace-install-local-purge.test.ts @@ -543,9 +543,12 @@ describe('GET … listing — `withSampleData` is the caller\'s own scope, read reads.length = 0; opts.activeOrg = 'org_b'; expect(await listedSampleData(list)).toEqual({ status: 200, withSampleData: true, items: 1 }); - // B's answer came from B's rows, and from no other organization's. - expect(reads.length).toBeGreaterThan(0); - for (const r of reads) expect(r.query.where).toEqual({ organization_id: 'org_b' }); + // B's answer came from B's rows, and from no other organization's. (The + // resolver's Default Organization read — the installer's grant is owned + // by it — is an authorization read, not a row of the listing's.) + const rowReads = reads.filter((r) => r.object !== 'sys_organization'); + expect(rowReads.length).toBeGreaterThan(0); + for (const r of rowReads) expect(r.query.where).toEqual({ organization_id: 'org_b' }); // A keeps a user-authored row in a seeded object; it is not a seed row. opts.activeOrg = 'org_a'; diff --git a/packages/cloud-connection/src/marketplace-install-local-sample-data-not-loaded.test.ts b/packages/cloud-connection/src/marketplace-install-local-sample-data-not-loaded.test.ts index 8d88fcffcc8..ee2173713a0 100644 --- a/packages/cloud-connection/src/marketplace-install-local-sample-data-not-loaded.test.ts +++ b/packages/cloud-connection/src/marketplace-install-local-sample-data-not-loaded.test.ts @@ -49,7 +49,7 @@ import { PROTOCOL_MAJOR } from '@objectstack/spec/kernel'; import '@objectstack/runtime'; import { MarketplaceInstallLocalPlugin } from './marketplace-install-local-plugin.js'; import { LocalManifestSource, type InstalledManifestEntry } from './local-manifest-source.js'; -import { installerGrantRows, INSTALLER_USER_ID, INSTALLER_SET } from './install-local-principal.fixtures.js'; +import { answerDefaultOrganizationRead, installerGrantRows, INSTALLER_USER_ID, INSTALLER_SET } from './install-local-principal.fixtures.js'; import { bindTestSecurityCatalog } from './__tests__/security-catalog.testkit.js'; type Handler = (c: any) => Promise; @@ -138,7 +138,7 @@ async function restartWith( async find(object: string, query?: any): Promise { const granted = installerGrantRows(); if (Object.prototype.hasOwnProperty.call(granted, object)) return granted[object]!; - if (object === 'sys_organization') return []; + if (object === 'sys_organization') return answerDefaultOrganizationRead(object, query, []) ?? []; const where: Record = query?.where ?? {}; const rows = known(object).filter((row) => Object.entries(where).every(([k, v]) => { // Scalar equality only; anything else is refused, never guessed. diff --git a/packages/cloud-connection/src/marketplace-install-local-seed-replayer.test.ts b/packages/cloud-connection/src/marketplace-install-local-seed-replayer.test.ts index d96b5140e90..791e9bffc01 100644 --- a/packages/cloud-connection/src/marketplace-install-local-seed-replayer.test.ts +++ b/packages/cloud-connection/src/marketplace-install-local-seed-replayer.test.ts @@ -97,7 +97,7 @@ vi.mock('@objectstack/spec/data', async (importOriginal) => ({ })); import { MarketplaceInstallLocalPlugin } from './marketplace-install-local-plugin.js'; -import { installerAuthService, withInstallerGrants } from './install-local-principal.fixtures.js'; +import { declareInstallerPlatformOwner, installerAuthService, withInstallerGrants } from './install-local-principal.fixtures.js'; import { LocalManifestSource } from './local-manifest-source.js'; type Handler = (c: any) => Promise; @@ -201,6 +201,7 @@ const OLD_POSTURE = process.env.OS_TENANCY_POSTURE; const OLD_LEGACY = process.env.OS_MULTI_ORG_ENABLED; let dir: string; +let restoreOwner: () => void; beforeEach(() => { dir = mkdtempSync(join(tmpdir(), 'mil-replayer-')); seedResult = { summary: { totalInserted: 2, totalUpdated: 0, totalSkipped: 0 }, errors: [] }; @@ -208,11 +209,14 @@ beforeEach(() => { helpersPresent = true; helperRegisterOnce.mockClear(); helperMerge.mockClear(); - // The walled deployment the defect was measured on. + // The walled deployment the defect was measured on, and its installer: with + // no organization on the request, the deployment's declared platform owner. process.env.OS_TENANCY_POSTURE = 'isolated'; delete process.env.OS_MULTI_ORG_ENABLED; + restoreOwner = declareInstallerPlatformOwner(); }); afterEach(() => { + restoreOwner(); rmSync(dir, { recursive: true, force: true }); if (OLD_POSTURE === undefined) delete process.env.OS_TENANCY_POSTURE; else process.env.OS_TENANCY_POSTURE = OLD_POSTURE; 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 81b091239dc..07ae1e000c3 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 @@ -122,8 +122,14 @@ function matchesWhere(row: any, where: any): boolean { /** * A permission store in the SHIPPED aggregation shapes: keys in `sys_api_key`, * memberships in `sys_member`, capabilities through `sys_user_permission_set` - * → `sys_permission_set`. All three principals hold `manage_metadata`, so a - * refusal below can only be the tenancy wall — never a missing capability. + * → `sys_permission_set`. All three principals hold `manage_metadata` twice + * over: in `org_A`, the organization every key is stamped with and the session + * is active in (a grant applies only in its own organization), and through the + * Default Organization's `admin_full_access` grant — the platform-admin shape, + * which applies whichever organization the caller acts in and carries the + * platform-operator rung a walled install demands wherever the REQUESTED + * posture is not walled. So a refusal below can only be the tenancy wall — + * never a missing capability. * * `u_exmember`'s key still carries `active_organization_id: 'org_A'`; the row * that made that stamp true is simply gone from `sys_member`. That is the whole @@ -139,6 +145,7 @@ function permissionStore(opts: { syncSchemas: () => Promise }) { // `u_exmember` is deliberately ABSENT: the membership that backed the // stamp ended. `u_orgless` never had one. sys_member: [{ id: 'm1', user_id: 'u_member', organization_id: 'org_A' }], + sys_organization: [{ id: 'org_default', slug: 'default' }, { id: 'org_A', slug: 'org-a' }], sys_user: [ { id: 'u_member', email: 'u_member@acme.test' }, { id: 'u_exmember', email: 'u_exmember@acme.test' }, @@ -150,10 +157,14 @@ function permissionStore(opts: { syncSchemas: () => Promise }) { sys_metadata_activation: [], sys_position_permission_set: [], sys_user_permission_set: [ - { id: 'ups_member', user_id: 'u_member', permission_set_id: 'ps_install', permission_set: 'admin_full_access', organization_id: null }, - { 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 }, + { id: 'ups_member', user_id: 'u_member', permission_set_id: 'ps_install', permission_set: 'admin_full_access', organization_id: 'org_default' }, + { id: 'ups_member_org_a', user_id: 'u_member', permission_set_id: 'ps_install', permission_set: 'admin_full_access', organization_id: 'org_A' }, + { id: 'ups_exmember', user_id: 'u_exmember', permission_set_id: 'ps_install', permission_set: 'admin_full_access', organization_id: 'org_default' }, + { id: 'ups_exmember_org_a', user_id: 'u_exmember', permission_set_id: 'ps_install', permission_set: 'admin_full_access', organization_id: 'org_A' }, + { id: 'ups_orgless', user_id: 'u_orgless', permission_set_id: 'ps_install', permission_set: 'admin_full_access', organization_id: 'org_default' }, + { id: 'ups_orgless_org_a', user_id: 'u_orgless', permission_set_id: 'ps_install', permission_set: 'admin_full_access', organization_id: 'org_A' }, + { id: 'ups_session', user_id: 'u_session', permission_set_id: 'ps_install', permission_set: 'admin_full_access', organization_id: 'org_default' }, + { id: 'ups_session_org_a', user_id: 'u_session', permission_set_id: 'ps_install', permission_set: 'admin_full_access', organization_id: 'org_A' }, ], sys_permission_set: [ { id: 'ps_install', name: 'admin_full_access', system_permissions: ['manage_metadata', 'studio.access'] }, diff --git a/packages/cloud-connection/src/marketplace-install-local-tenancy-posture.test.ts b/packages/cloud-connection/src/marketplace-install-local-tenancy-posture.test.ts index 862acc8095f..b38fd7e8df3 100644 --- a/packages/cloud-connection/src/marketplace-install-local-tenancy-posture.test.ts +++ b/packages/cloud-connection/src/marketplace-install-local-tenancy-posture.test.ts @@ -66,7 +66,7 @@ vi.mock('@objectstack/spec/data', async (importOriginal) => ({ })); import { MarketplaceInstallLocalPlugin } from './marketplace-install-local-plugin.js'; -import { installerAuthService, withInstallerGrants } from './install-local-principal.fixtures.js'; +import { declareInstallerPlatformOwner, installerAuthService, withInstallerGrants } from './install-local-principal.fixtures.js'; import { LocalManifestSource } from './local-manifest-source.js'; type Handler = (c: any) => Promise; @@ -145,14 +145,19 @@ const OLD_POSTURE = process.env.OS_TENANCY_POSTURE; const OLD_LEGACY = process.env.OS_MULTI_ORG_ENABLED; let dir: string; +let restoreOwner: () => void; beforeEach(() => { dir = mkdtempSync(join(tmpdir(), 'mil-posture-')); seedResult = { summary: { totalInserted: 2, totalUpdated: 0, totalSkipped: 0 }, errors: [] }; loadCalls = []; delete process.env.OS_TENANCY_POSTURE; delete process.env.OS_MULTI_ORG_ENABLED; + // No organization on the request: under the walled postures below the + // installer is the deployment's declared platform owner. + restoreOwner = declareInstallerPlatformOwner(); }); afterEach(() => { + restoreOwner(); rmSync(dir, { recursive: true, force: true }); if (OLD_POSTURE === undefined) delete process.env.OS_TENANCY_POSTURE; else process.env.OS_TENANCY_POSTURE = OLD_POSTURE; 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 1c9ad3c081e..d9aa1d30833 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 @@ -215,9 +215,10 @@ export const FIXTURES: Fixture[] = [ }, }, { - // Position-derived grants: a global row, an org-scoped row for ANOTHER org - // (must not resolve), a lapsed row, a not-yet-valid row, plus the ADR-0049 - // deactivated position whose NAME must leave `positions` too. + // Position-derived grants: rows owned by the active org, an org-scoped row + // for ANOTHER org (must not resolve), an organization-less row (applies + // nowhere, so must not resolve either), a lapsed row, a not-yet-valid row, + // plus the ADR-0049 deactivated position whose NAME must leave `positions` too. name: 'position-derived-grants', userId: 'u_pos', opts: { tenantId: 'org_a', seedEmail: 'pos@x.com', nowMs: T0 }, @@ -225,12 +226,13 @@ export const FIXTURES: Fixture[] = [ sys_user: [{ id: 'u_pos' }], sys_member: [{ user_id: 'u_pos', organization_id: 'org_a', role: 'member' }], sys_user_position: [ - { user_id: 'u_pos', position: 'contributor', organization_id: null }, + { user_id: 'u_pos', position: 'contributor', organization_id: 'org_a' }, { user_id: 'u_pos', position: 'auditor', organization_id: 'org_a' }, { user_id: 'u_pos', position: 'foreigner', organization_id: 'org_z' }, - { user_id: 'u_pos', position: 'expired_role', organization_id: null, valid_until: past }, - { user_id: 'u_pos', position: 'future_role', organization_id: null, valid_from: future }, - { user_id: 'u_pos', position: 'retired', organization_id: null }, + { user_id: 'u_pos', position: 'orgless_role', organization_id: null }, + { user_id: 'u_pos', position: 'expired_role', organization_id: 'org_a', valid_until: past }, + { user_id: 'u_pos', position: 'future_role', organization_id: 'org_a', valid_from: future }, + { user_id: 'u_pos', position: 'retired', organization_id: 'org_a' }, ], sys_position: [ { id: 'p_contrib', name: 'contributor' }, @@ -254,27 +256,32 @@ export const FIXTURES: Fixture[] = [ }, }, { - // Permission-set-derived grants: the UNSCOPED `admin_full_access` user grant - // is the ONLY thing that derives platform_admin, an org-scoped copy is not, - // a lapsed one is not, and a DEACTIVATED set grants nothing at all. + // Permission-set-derived grants: the Default Organization's `admin_full_access` + // user grant is the ONLY thing that derives platform_admin (the `single` + // posture's anchor, applying in whichever organization is active), another + // org's copy is not, an organization-less row confers nothing at all, a + // lapsed one is not, and a DEACTIVATED set grants nothing at all. name: 'permission-set-derived-grants', userId: 'u_ps', opts: { tenantId: 'org_a', seedEmail: 'ps@x.com', nowMs: T0 }, tables: { sys_user: [{ id: 'u_ps' }], + sys_organization: [{ id: 'org_default', slug: 'default' }, { id: 'org_a', slug: 'org-a' }], 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', permission_set: 'admin_full_access', organization_id: null }, + { user_id: 'u_ps', permission_set_id: 'ps_admin', permission_set: 'admin_full_access', organization_id: 'org_default' }, { 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 }, + { user_id: 'u_ps', permission_set_id: 'ps_orgless', permission_set: 'orgless_set', organization_id: null }, + { user_id: 'u_ps', permission_set_id: 'ps_lapsed', permission_set: 'lapsed_set', organization_id: 'org_a', valid_until: past }, + { user_id: 'u_ps', permission_set_id: 'ps_dead', permission_set: 'dead_set', organization_id: 'org_a' }, ], sys_permission_set: [ { id: 'ps_admin', name: 'admin_full_access', system_permissions: ['manage_users'] }, { id: 'ps_org', name: 'org_tools', tab_permissions: { crm: 'hidden' } }, { id: 'ps_other', name: 'other_org_tools' }, + { id: 'ps_orgless', name: 'orgless_set', system_permissions: ['orgless_cap'] }, { id: 'ps_lapsed', name: 'lapsed_set' }, { id: 'ps_dead', name: 'dead_set', active: false }, ], @@ -291,7 +298,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', permission_set: 'low', organization_id: null }], + sys_user_permission_set: [{ user_id: 'u_ta', permission_set_id: 'ps_low', permission_set: 'low', organization_id: 'org_a' }], 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: [ @@ -342,7 +349,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', permission_set: 'extra', organization_id: null }], + sys_user_permission_set: [{ user_id: 'u_seed', permission_set_id: 'ps_x', permission_set: 'extra', organization_id: 'org_a' }], 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 8472ffd8a57..20f76e75706 100644 --- a/packages/core/src/security/admin-standing-surface.test.ts +++ b/packages/core/src/security/admin-standing-surface.test.ts @@ -151,16 +151,19 @@ const VARIANTS: Record< platformAdminEmails?: string; } > = { - // Snake_case rows, unscoped in-window grant, active set: the happy platform-admin path. + // Snake_case rows, the Default Organization's in-window grant, active set: the + // happy platform-admin path, resolved in another organization (`org_1`), which + // owns the position grant. 'snake-case rows, standing intact': { org: 'org_1', tables: { sys_user: [{ id: 'usr_1', email: 'ada@example.com', ai_access: 1 }], + sys_organization: [{ id: 'org_default', slug: 'default' }, { id: 'org_1', slug: 'one' }], sys_member: [ { id: 'mem_1', user_id: 'usr_1', organization_id: 'org_1', role: 'owner', valid_from: null, valid_until: null }, ], sys_user_position: [ - { id: 'upo_1', user_id: 'usr_1', position: 'contributor', organization_id: null, valid_from: null, valid_until: null }, + { id: 'upo_1', user_id: 'usr_1', position: 'contributor', organization_id: 'org_1', valid_from: null, valid_until: null }, ], sys_position: [{ id: 'pos_1', name: 'contributor', active: true }], sys_position_permission_set: [{ position_id: 'pos_1', permission_set_id: 'pst_2' }], @@ -169,7 +172,7 @@ const VARIANTS: Record< id: 'ups_1', user_id: 'usr_1', permission_set_id: 'pst_1', permission_set: 'admin_full_access', - organization_id: null, + organization_id: 'org_default', valid_from: null, valid_until: null, }, @@ -194,10 +197,10 @@ const VARIANTS: Record< tables: { sys_user: [{ id: 'usr_1', email: 'ada@example.com', ai_access: true }], sys_member: [{ id: 'mem_1', userId: 'usr_1', organizationId: 'org_1', role: 'admin' }], - sys_user_position: [{ id: 'upo_1', userId: 'usr_1', position: 'contributor' }], + sys_user_position: [{ id: 'upo_1', userId: 'usr_1', position: 'contributor', organizationId: 'org_1' }], sys_position: [{ id: 'pos_1', name: 'contributor', active: true }], sys_position_permission_set: [{ positionId: 'pos_1', permissionSetId: 'pst_2' }], - sys_user_permission_set: [{ id: 'ups_1', userId: 'usr_1', permissionSetId: 'pst_1' }], + sys_user_permission_set: [{ id: 'ups_1', userId: 'usr_1', permissionSetId: 'pst_1', organizationId: 'org_1' }], sys_permission_set: [ { id: 'pst_1', @@ -269,10 +272,11 @@ const VARIANTS: Record< }, // [ADR-0131 D5] The `single` promotion's grant, owned by the Default - // Organization: the only variant in which that organization's row is read, - // so the union needs it — without it `sys_organization.id` would be absent - // from the declaration and the guard would stop judging the delete that - // moves the anchor off this grant. + // Organization and resolved IN it, with no other grant: the isolated reading + // of that organization's row (the happy path above reads it too, beside + // everything else), so the union needs it — without it `sys_organization.id` + // would be absent from the declaration and the guard would stop judging the + // delete that moves the anchor off this grant. 'default-organization-owned grant': { org: 'org_1', tables: { diff --git a/packages/core/src/security/admin-standing-surface.ts b/packages/core/src/security/admin-standing-surface.ts index 5e6f4f4d78a..f656fa4e952 100644 --- a/packages/core/src/security/admin-standing-surface.ts +++ b/packages/core/src/security/admin-standing-surface.ts @@ -99,8 +99,8 @@ export interface AdminStandingTable { * authenticates a principal and seeds `permissions` with the key's scopes, and * confers no administrator standing of its own — `hasPlatformAdminGrant` is set * from the catalog's `admin_full_access` set reached through the anchoring - * `sys_user_permission_set` grant (§6b — the Default Organization's or an - * organization-less one, on a NON-WALLED posture only since #11663 L5) or from the deployment config matched against the caller's own + * `sys_user_permission_set` grant (§6b — the Default Organization's, on a + * NON-WALLED posture only since #11663 L5) or from the deployment config matched against the caller's own * STORED `sys_user` row (§6b-config, on every posture), never from a scope * string and never from the caller-seedable `grants.email`. */ @@ -128,7 +128,7 @@ export const ADMIN_STANDING_SURFACE: Readonly reason: 'The grant that makes a user a platform admin UNDER A NON-WALLED POSTURE: an in-window ' + '(ADR-0091) grant of `admin_full_access` owned by the Default Organization (ADR-0131 D5, the ' - + 'row the first-user promotion writes) or by no organization (the row it wrote before) — §6, ' + + 'row the first-user promotion writes) — §6, ' + '`grantAnchorsPlatformStanding`. Re-pointing it, scoping it to another organization or moving ' + 'it out of its window revokes the standing while leaving the row in place. ⚠️ Under a ' + 'WALLED posture (`group`/`isolated`) it confers NOTHING: the walled half ' @@ -214,7 +214,7 @@ export const ADMIN_STANDING_SURFACE: Readonly reason: 'ADR-0057 D4 platform-RBAC position assignments (§4). A position can carry permission sets ' + '(its catalog definition\'s `permissionSets`, ADR-0131 D3/D4) but never platform-admin ' - + 'standing — §6b requires the set to be reached through an unscoped USER grant, so a ' + + 'standing — §6b requires the set to be reached through the Default Organization\'s USER grant, so a ' + 'position-bound `admin_full_access` resolves the set name into `permissions` and leaves ' + '`hasPlatformAdminGrant` false.', }, @@ -266,7 +266,7 @@ export const ADMIN_STANDING_NON_TABLE_INPUTS: readonly AdminStandingNonTableInpu kind: 'security-catalog', name: 'admin_full_access', reason: - 'The permission-set definition an unscoped `admin_full_access` user grant names, read from ' + 'The permission-set definition the anchoring `admin_full_access` user grant (the Default Organization\'s) names, read from ' + 'the security catalog by name (ADR-0131 D3/D4) — the platform\'s own declaration, shipped ' + 'on the security plugin\'s manifest. A name the catalog does not hold confers nothing, so ' + 'every grant-derived platform administrator rests on it. It is code-declared and refused ' diff --git a/packages/core/src/security/authz-store-unavailable.test.ts b/packages/core/src/security/authz-store-unavailable.test.ts index 817064db4b4..ab52901b942 100644 --- a/packages/core/src/security/authz-store-unavailable.test.ts +++ b/packages/core/src/security/authz-store-unavailable.test.ts @@ -36,6 +36,9 @@ import { bindStaticSecurityCatalog } from './__tests__/security-catalog.testkit. const USER = 'u_admin'; const SESSION = { getSession: async () => ({ user: { id: USER } }) }; +/** The organization {@link qlHealthy}'s grant belongs to, and the session acting in it. */ +const ORG = 'org_a'; +const SESSION_IN_ORG = { getSession: async () => ({ user: { id: USER }, session: { activeOrganizationId: ORG } }) }; /** The permission store, unreachable — every read throws, as a driver outage does. */ const qlDown = () => ({ find: async () => { throw new Error('permission store unreachable'); } }); @@ -44,11 +47,12 @@ const qlEmpty = () => ({ find: async () => [] }); /** * A store that actually grants something, so "resolves" is read against a real * grant: the grant row, and the set it names in the security catalog bound to - * the engine (ADR-0131 D3/D4). + * the engine (ADR-0131 D3/D4). The grant belongs to {@link ORG} and applies only + * there, so it is resolved with {@link SESSION_IN_ORG}. */ const qlHealthy = () => bindStaticSecurityCatalog({ find: async (object: string) => { - if (object === 'sys_user_permission_set') return [{ permission_set_id: 'ps', permission_set: 'pkg_admin' }]; + if (object === 'sys_user_permission_set') return [{ permission_set_id: 'ps', permission_set: 'pkg_admin', organization_id: ORG }]; if (object === 'sys_permission_set') return [{ id: 'ps', name: 'pkg_admin' }]; return []; }, @@ -83,7 +87,7 @@ describe('[#13279] resolveAuthzContext — an outage is loud, an empty store is }); it('⭐ CONTROL: a healthy store still resolves the capability it grants', async () => { - const ctx = await resolveAuthzContext({ userAuthGates: undefined, apiKeyLifecycle: undefined, ql: qlHealthy(), headers: {}, ...SESSION }); + const ctx = await resolveAuthzContext({ userAuthGates: undefined, apiKeyLifecycle: undefined, ql: qlHealthy(), headers: {}, ...SESSION_IN_ORG }); expect(ctx.userId).toBe(USER); expect(ctx.systemPermissions).toContain('studio.access'); }); @@ -472,9 +476,13 @@ describe('[#13279 option A] ⭐ THE UNPROVISIONED DIRECTION — a never-provisio }); const empty = await resolveAuthzContext({ userAuthGates: undefined, apiKeyLifecycle: undefined, ql: qlEmpty(), headers: {}, ...SESSION }); expect(JSON.stringify(unprovisioned)).toBe(JSON.stringify(empty)); - // CONTROL against a vacuous comparison: a HEALTHY store differs from both. - const healthy = await resolveAuthzContext({ userAuthGates: undefined, apiKeyLifecycle: undefined, ql: qlHealthy(), headers: {}, ...SESSION }); - expect(JSON.stringify(healthy)).not.toBe(JSON.stringify(empty)); + // CONTROL against a vacuous comparison: a HEALTHY store differs from an + // empty one — compared in the organization its grant belongs to, so the + // difference is the grant and not the session. + const healthy = await resolveAuthzContext({ userAuthGates: undefined, apiKeyLifecycle: undefined, ql: qlHealthy(), headers: {}, ...SESSION_IN_ORG }); + const emptyInOrg = await resolveAuthzContext({ userAuthGates: undefined, apiKeyLifecycle: undefined, ql: qlEmpty(), headers: {}, ...SESSION_IN_ORG }); + expect(JSON.stringify(healthy)).not.toBe(JSON.stringify(emptyInOrg)); + expect(healthy.systemPermissions).toContain('studio.access'); }); it('⭐ `resolveUserAuthzGrants` inherits the quiet direction too', async () => { 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 0b1b68539f3..58ea87c34e7 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 @@ -385,6 +385,14 @@ "limit": 100, "isSystem": true }, + { + "object": "sys_organization", + "where": { + "slug": "default" + }, + "limit": 1, + "isSystem": true + }, { "object": "sys_metadata_activation", "where": { 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 ad03dc227ac..e00e84e994a 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 @@ -69,6 +69,24 @@ * convert to (`convertDeactivatedCatalogRows`, the upgrade ceremony's), so the * deactivated `retired` position and `dead_set` set still grant nothing. Every * fixture now resolves in two legs. + * + * ⚠️ A fourth recorded move (the organization-less grant readers retire): a + * stored grant row applies only in its own organization, and an + * organization-less row applies NOWHERE — the ADR-0131 D10 ownership ceremony + * attributes every such row, so it is invalid state rather than a "global" + * grant. The fixtures' organization-less grant rows that exercised something + * else are re-stamped with the active organization (`org_a`), and the + * platform-admin grant with the Default Organization (`org_default`, the + * `single` posture's anchor, ADR-0131 D5); every `grants` envelope is + * byte-identical to the capture. `position-derived-grants` and + * `permission-set-derived-grants` each gain one organization-less row that must + * resolve to nothing, which the unchanged envelope pins. ONE recorded read + * moved: `permission-set-derived-grants` now issues the Default Organization + * read (`sys_organization {slug: 'default'}`, limit 1) that an + * organization-owned `admin_full_access` grant has always cost under `single`; + * it joins the activation-ledger read's wave, so the fixture still resolves in + * two legs. That read is in the golden because the code genuinely issues it — + * the organization-less row this fixture used to hold asked none. */ import { readFileSync } from 'node:fs'; @@ -258,7 +276,9 @@ describe('[#10825] batched resolveUserAuthzGrants — equivalence with the seque expect(wave1).toEqual( asMultiset(GOLDEN[name].queries) .map((s) => JSON.parse(s) as RecordedCall) - .filter((c) => c.object !== 'sys_metadata_activation') + // Wave 2: the activation ledger and the Default Organization read, + // which are issued together after wave 1 (#10757 leg 4). + .filter((c) => c.object !== 'sys_metadata_activation' && c.object !== 'sys_organization') .map((c) => c.object) .sort(), ); diff --git a/packages/core/src/security/resolve-authz-context.default-organization-memo.test.ts b/packages/core/src/security/resolve-authz-context.default-organization-memo.test.ts index ada9f45c769..c86cce7483a 100644 --- a/packages/core/src/security/resolve-authz-context.default-organization-memo.test.ts +++ b/packages/core/src/security/resolve-authz-context.default-organization-memo.test.ts @@ -152,10 +152,14 @@ describe('[#24523] the Default Organization answer is read once per engine', () expect(await e.resolve()).toEqual({ reads: [BY_SLUG], standing: true }); }); - it('CONTROL — an organization-less grant never reads it, memo or not', async () => { + it('CONTROL — an organization-less grant never reads it, memo or not, and anchors nothing', async () => { const e = makeEngineQl(tablesOf('', [{ id: 'org_a', slug: 'default' }])); - // The grant's organization is blank: the legacy organization-less spelling. - expect(await e.resolve()).toEqual({ reads: [], standing: true }); + // The grant's organization is blank: the legacy organization-less spelling, + // which applies nowhere and anchors nothing — the D10 ownership ceremony + // attributes such a row to the Default Organization, so one still carrying + // none is invalid state. The arms above, on the same organization, are the + // positive half: the grant owned by `org_a` anchors. + expect(await e.resolve()).toEqual({ reads: [], standing: false }); expect(e.middlewares).toHaveLength(0); }); }); diff --git a/packages/core/src/security/resolve-authz-context.grant-set-by-name.test.ts b/packages/core/src/security/resolve-authz-context.grant-set-by-name.test.ts index c9ffc353324..519129ed1f4 100644 --- a/packages/core/src/security/resolve-authz-context.grant-set-by-name.test.ts +++ b/packages/core/src/security/resolve-authz-context.grant-set-by-name.test.ts @@ -15,7 +15,8 @@ * - the body is the catalog definition, whichever organization's row carries * the name — and a name only a row carries (no definition) confers nothing; * - a grant that names nothing confers nothing, platform standing included; - * - platform standing stands on an organization-less grant only; + * - platform standing stands on the Default Organization's grant only, and an + * organization-less grant confers nothing at all; * - deactivation: a ledger row switches a name off in every organization, and * a catalog row's `active` column is not read; * - position-bound sets are the ones the position's definition names, and the @@ -31,6 +32,9 @@ import { bindStaticSecurityCatalog } from './__tests__/security-catalog.testkit. const ORG_A = 'org_a'; const ORG_B = 'org_b'; +const DEFAULT_ORG = 'org_default'; +/** The organizations a platform-standing fixture names: the Default Organization and ORG_B. */ +const ORGANIZATIONS = { sys_organization: [{ id: DEFAULT_ORG, slug: 'default' }, { id: ORG_B, slug: 'b' }] }; const OWNER_ENV = 'OS_PLATFORM_OWNER_EMAIL'; const POSTURE_ENV = 'OS_TENANCY_POSTURE'; @@ -109,22 +113,32 @@ async function resolve( describe('[ADR-0131 D3/D4] a grant names its set; the catalog says what the set is', () => { it('the body is the definition, whichever organization\'s row carries the name', async () => { - for (const [grantOrg, tenantId] of [[ORG_A, ORG_A], [null, ORG_A], [ORG_B, ORG_B], [null, undefined]] as const) { + for (const [grantOrg, tenantId] of [[ORG_A, ORG_A], [ORG_B, ORG_B]] as const) { const { out } = await resolve([{ permission_set_id: 'ps_tools_a', permission_set: 'tools', organization_id: grantOrg }], tenantId); expect(out.permissions, `${grantOrg} in ${tenantId}`).toEqual(['tools']); expect(out.systemPermissions, `${grantOrg} in ${tenantId}`).toEqual(['cap_tools']); } }); + it('⛔ an ORGANIZATION-LESS grant of the same name confers nothing — in an organization or with none', async () => { + // The CONTROL is the arm above: the same name, owned by the active + // organization, resolves its body on the same catalog. + for (const tenantId of [ORG_A, ORG_B, undefined]) { + const { out } = await resolve([{ permission_set_id: 'ps_tools', permission_set: 'tools', organization_id: null }], tenantId); + expect(out.permissions, String(tenantId)).toEqual([]); + expect(out.systemPermissions, String(tenantId)).toEqual([]); + } + }); + it('a name only a ROW carries — no definition — confers nothing, in its organization or out of it', async () => { - for (const [grantOrg, tenantId] of [[ORG_B, ORG_B], [null, ORG_B], [null, undefined]] as const) { + for (const [grantOrg, tenantId] of [[ORG_B, ORG_B], [ORG_A, ORG_A]] as const) { const { out } = await resolve([{ permission_set_id: 'ps_row_only', permission_set: 'row_only', organization_id: grantOrg }], tenantId); expect(out.permissions, `${grantOrg} in ${tenantId}`).toEqual([]); } }); it('CONTROL — a definition with no row at all confers its body: no row carries no flag to read', async () => { - const { out } = await resolve([{ permission_set: 'no_row', organization_id: null }], ORG_A); + const { out } = await resolve([{ permission_set: 'no_row', organization_id: ORG_A }], ORG_A); expect(out.permissions).toEqual(['no_row']); expect(out.systemPermissions).toEqual(['cap_no_row']); }); @@ -144,24 +158,33 @@ describe('[ADR-0131 D3/D4] a grant names its set; the catalog says what the set }); }); -describe('[ADR-0068 D2] platform standing stands on an organization-less grant only', () => { - it('an organization-less grant named admin_full_access holds platform standing', async () => { - const { out } = await resolve([{ permission_set_id: 'ps_admin_b', permission_set: ADMIN_FULL_ACCESS, organization_id: null }], ORG_B); +describe('[ADR-0068 D2] platform standing stands on the Default Organization\'s grant only', () => { + it('the Default Organization\'s grant named admin_full_access holds platform standing, in another organization too', async () => { + const { out } = await resolve([{ permission_set_id: 'ps_admin_b', permission_set: ADMIN_FULL_ACCESS, organization_id: DEFAULT_ORG }], ORG_B, ORGANIZATIONS); expect(out.posture).toBe('PLATFORM_ADMIN'); expect(out.positions[0]).toBe('platform_admin'); }); + it('⛔ an ORGANIZATION-LESS grant named admin_full_access holds no platform standing, and not the set either', async () => { + for (const tenantId of [ORG_B, DEFAULT_ORG, undefined]) { + const { out } = await resolve([{ permission_set_id: 'ps_admin', permission_set: ADMIN_FULL_ACCESS, organization_id: null }], tenantId, ORGANIZATIONS); + expect(out.posture, String(tenantId)).not.toBe('PLATFORM_ADMIN'); + expect(out.positions, String(tenantId)).not.toContain('platform_admin'); + expect(out.permissions, String(tenantId)).toEqual([]); + } + }); + it('an ORGANIZATION’s admin_full_access grant carries the set, never platform standing', async () => { - const { out } = await resolve([{ permission_set_id: 'ps_admin_b', permission_set: ADMIN_FULL_ACCESS, organization_id: ORG_B }], ORG_B); + const { out } = await resolve([{ permission_set_id: 'ps_admin_b', permission_set: ADMIN_FULL_ACCESS, organization_id: ORG_B }], ORG_B, ORGANIZATIONS); expect(out.permissions).toContain(ADMIN_FULL_ACCESS); expect(out.posture).not.toBe('PLATFORM_ADMIN'); }); it('with no admin_full_access definition in the catalog, the grant confers nothing at all', async () => { const { out } = await resolve( - [{ permission_set_id: 'ps_admin', permission_set: ADMIN_FULL_ACCESS, organization_id: null }], - undefined, + [{ permission_set_id: 'ps_admin', permission_set: ADMIN_FULL_ACCESS, organization_id: DEFAULT_ORG }], undefined, + ORGANIZATIONS, { ...definitions, permissions: definitions.permissions.filter((d) => d.name !== ADMIN_FULL_ACCESS) }, ); expect(out.posture).not.toBe('PLATFORM_ADMIN'); @@ -170,9 +193,9 @@ describe('[ADR-0068 D2] platform standing stands on an organization-less grant o it('admin_full_access switched off in the activation ledger takes the standing away', async () => { const { out } = await resolve( - [{ permission_set_id: 'ps_admin', permission_set: ADMIN_FULL_ACCESS, organization_id: null }], + [{ permission_set_id: 'ps_admin', permission_set: ADMIN_FULL_ACCESS, organization_id: DEFAULT_ORG }], undefined, - { sys_metadata_activation: [off('permission', ADMIN_FULL_ACCESS)] }, + { ...ORGANIZATIONS, sys_metadata_activation: [off('permission', ADMIN_FULL_ACCESS)] }, ); expect(out.posture).not.toBe('PLATFORM_ADMIN'); expect(out.permissions).toEqual([]); @@ -181,7 +204,7 @@ describe('[ADR-0068 D2] platform standing stands on an organization-less grant o describe('[ADR-0049, ADR-0131 D3] the activation ledger switches a set off', () => { it('a ledger row switches the name off in every organization: no set, no capability', async () => { - for (const [grantOrg, tenantId] of [[ORG_A, ORG_A], [null, ORG_A], [null, undefined]] as const) { + for (const [grantOrg, tenantId] of [[ORG_A, ORG_A], [ORG_B, ORG_B]] as const) { const { out } = await resolve( [{ permission_set_id: 'ps_off_a', permission_set: 'switched', organization_id: grantOrg }], tenantId, @@ -218,8 +241,8 @@ describe('[ADR-0131 D4] a grant that names nothing confers nothing', () => { } }); - 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); + it('an unnamed grant owned by the Default Organization, on the platform admin_full_access row: no platform standing', async () => { + const { out } = await resolve([{ permission_set_id: 'ps_admin', organization_id: DEFAULT_ORG }], undefined, ORGANIZATIONS); expect(out.posture).not.toBe('PLATFORM_ADMIN'); expect(out.positions).not.toContain('platform_admin'); expect(out.permissions).not.toContain(ADMIN_FULL_ACCESS); 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 4f3403f19be..0cf13e63ff7 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 @@ -14,11 +14,14 @@ * * ⚠️ [#11663 L5] That last clause is L2's, and its WALLED half has since EXPIRED. * L2/L4 opened a time-boxed migration window; L5 closed it. Under a walled - * posture the legacy unscoped `admin_full_access` row is no longer an anchor and - * there is no deprecation pointer left to log — both symbols that carried it are - * gone from `@objectstack/core`. Under `single` the row still confers, exactly as - * it always did (Choice 4A, the zero-config path); its disposition is #11979's. - * The suites at the end of this file are the measurement of that boundary. + * posture the legacy grant-row anchor — the `admin_full_access` row first-user + * promotion mints — is no longer an anchor and there is no deprecation pointer + * left to log — both symbols that carried it are gone from `@objectstack/core`. + * Under `single` the row still confers (Choice 4A, the zero-config path). Since + * ADR-0131 D5 that row is OWNED BY THE DEFAULT ORGANIZATION; the + * organization-less row an earlier promotion wrote anchors nothing any more (the + * D10 ownership ceremony attributes it to the Default Organization). The suites + * at the end of this file are the measurement of that boundary. * * ⭐ The single most important test in this file is * "a session payload carrying a configured address over a sys_user row that @@ -336,27 +339,32 @@ describe('⭐ [#11663 L2 pin P1] the derivation reads the STORED row, never the }); }); +/** The Default Organization every grant-row fixture below owns its anchor by. */ +const DEFAULT_ORGANIZATION = 'org_default'; + /** - * A principal whose PLATFORM_ADMIN rests on the LEGACY unscoped - * `admin_full_access` grant row and nothing else — the shape - * `bootstrapPlatformAdmin` mints when it promotes the first human user. + * A principal whose PLATFORM_ADMIN rests on the LEGACY grant-row route (L5's + * name for it, as opposed to the config anchor) and nothing else: the + * `admin_full_access` row `bootstrapPlatformAdmin` mints when it promotes the + * first human user, owned by the Default Organization (ADR-0131 D5). * Hoisted out of the `#11663 L2 / P5` suite so the `#13667` posture suite below * drives the identical fixture rather than a second copy of it. */ const legacyTables = () => ({ sys_user: [{ id: 'usr_1', email: 'legacy@corp.example', email_verified: true }], + sys_organization: [{ id: DEFAULT_ORGANIZATION, slug: 'default' }], sys_member: [], sys_user_position: [], sys_position: [], sys_position_permission_set: [], sys_user_permission_set: [ - { id: 'ups_1', user_id: 'usr_1', permission_set_id: 'pst_1', permission_set: ADMIN_FULL_ACCESS, organization_id: null }, + { id: 'ups_1', user_id: 'usr_1', permission_set_id: 'pst_1', permission_set: ADMIN_FULL_ACCESS, organization_id: DEFAULT_ORGANIZATION }, ], sys_permission_set: [{ id: 'pst_1', name: ADMIN_FULL_ACCESS, active: true }], }); describe('[#11663 L5] the legacy grant row is an anchor under `single`, and NOWHERE else', () => { - it('an unscoped admin_full_access grant still confers PLATFORM_ADMIN with no config at all', async () => { + it('the Default Organization\'s admin_full_access grant still confers PLATFORM_ADMIN with no config at all', async () => { // ⭐ The zero-config path, and the reason this card is walled-only. The // harness clears both tenancy inputs, so this rig resolves `single` — what a // deployment that configured no tenancy at all looks like. Choice 4A rules @@ -417,9 +425,12 @@ describe('[#11663 L5] the legacy grant row is an anchor under `single`, and NOWH // ROW, which is ADR-0131 C3's on the v18 line and #11979's under `single` — // ⛔ not this card's. If a later leg widens to the row, this pin is the one // that should be re-authored deliberately rather than deleted in passing. + // Resolved IN the organization that owns the row: an organization-owned + // grant applies in its own organization, and with no anchor that is the + // only place it applies. declare(undefined); requestPosture('isolated'); - const grants = await resolveUserAuthzGrants(makeQl(legacyTables()), 'usr_1', { nowMs: NOW }); + const grants = await resolveUserAuthzGrants(makeQl(legacyTables()), 'usr_1', { nowMs: NOW, tenantId: DEFAULT_ORGANIZATION }); expect(grants.permissions).toContain(ADMIN_FULL_ACCESS); expect(grants.posture).toBe('MEMBER'); expect(grants.positions).not.toContain('platform_admin'); @@ -453,11 +464,11 @@ describe('[#11663 L2] the sys_user read stays CONDITIONAL on config', () => { // ─────────────────────────────────────────────────────────────────────────── /** - * [#11663 L5] The legacy unscoped anchor is POSTURE-KEYED — the request side + * [#11663 L5] The legacy grant-row anchor is POSTURE-KEYED — the request side * matching the boot side, on the standing itself rather than on a log line. * * `bootstrapPlatformAdmin` has always been posture-keyed: under `single` a - * pre-existing unscoped `admin_full_access` holder is `already_have_admin` and + * pre-existing Default Organization `admin_full_access` holder is `already_have_admin` and * the boot exits silently, because under Choice 4A that row IS that rig's * anchor — first-user promotion mints it and is ruled correct and unchanged. * Only under a walled posture is the same row the LEGACY anchor, and L4 stopped @@ -537,22 +548,31 @@ describe('[#11663 L5] the legacy anchor confers only on the rigs Choice 4A keeps } }); - it('adds NO read: the recorded query multiset is identical under both answers of the gate', async () => { + it('adds NO read of its own: the gate\'s two answers differ by the Default Organization read alone', async () => { // The in-place claim at the call site — the posture gate "asks the - // ENVIRONMENT, so it issues no query and the pinned batch-equivalence query - // multiset cannot move whichever way it answers" — re-MEASURED rather than - // quoted. #13667 owed this measurement for a gate on a LOG line; L5 owes it - // again because the gate moved onto the derivation itself, where a stray - // engine read would be a per-request cost on every walled rig. - const reads: Record = {}; + // ENVIRONMENT, so it issues no query" — re-MEASURED rather than quoted. + // #13667 owed this measurement for a gate on a LOG line; L5 owes it again + // because the gate moved onto the derivation itself, where a stray engine + // read would be a per-request cost on every walled rig. The one read the + // two answers do not share is the anchor's own question — which + // organization is the Default Organization — and `single` asks it because + // its anchor is that organization's grant, while a walled rig has no row + // anchor to ask it for (the D5 suite below pins the walled half). Resolved + // IN the organization that owns the grant, so the grant applies under both + // answers and every other read — the activation ledger's question included + // — is asked about the same names. + const reads: Record> = {}; for (const p of ['single', 'isolated'] as const) { declare(undefined); requestPosture(p); const ql = makeQl(legacyTables()); - await resolveUserAuthzGrants(ql, 'usr_1', { nowMs: NOW }); + await resolveUserAuthzGrants(ql, 'usr_1', { nowMs: NOW, tenantId: DEFAULT_ORGANIZATION }); reads[p] = ql.calls.map((c) => ({ object: c.object, where: c.where })); } - expect(reads.single).toEqual(reads.isolated); + const notOrganization = (r: { object: string }) => r.object !== 'sys_organization'; + expect(reads.single.filter(notOrganization)).toEqual(reads.isolated.filter(notOrganization)); + expect(reads.single.filter((r) => !notOrganization(r))).toEqual([{ object: 'sys_organization', where: { slug: 'default' } }]); + expect(reads.isolated.filter((r) => !notOrganization(r))).toEqual([]); expect(reads.single.length).toBeGreaterThan(0); // the fixture really did resolve }); @@ -662,7 +682,8 @@ describe('[#11663 L5] standing across postures — three arms invariant, the leg * row — one derivation site, the posture its parameter. Before this, the anchor * read the organization-less row only, so a promoted rig whose grant D10's * ceremony (or the promotion itself) had given the Default Organization resolved - * a zero-admin deployment. + * a zero-admin deployment. That organization-less row is no anchor now: the + * ceremony attributes it, so one still carrying no organization is invalid state. */ describe('[ADR-0131 D5] under `single` the Default Organization\'s admin_full_access grant is the anchor', () => { const ownedBy = (organizationId: string, organizations: Array>) => ({ @@ -679,8 +700,8 @@ describe('[ADR-0131 D5] under `single` the Default Organization\'s admin_full_ac const grants = await resolveUserAuthzGrants(makeQl(ownedBy('org_default', DEFAULT_ONLY)), 'usr_1', { nowMs: NOW, tenantId }); expect(grants.posture, String(tenantId)).toBe('PLATFORM_ADMIN'); expect(grants.positions[0]).toBe('platform_admin'); - // The anchoring grant applies wherever the principal acts, as the - // organization-less row it replaces did: the set comes with the standing. + // The anchoring grant applies wherever the principal acts, and with no + // organization active: the set comes with the standing. expect(grants.permissions).toContain(ADMIN_FULL_ACCESS); } }); @@ -714,10 +735,24 @@ describe('[ADR-0131 D5] under `single` the Default Organization\'s admin_full_ac expect(ql.calls.map((c) => c.object)).not.toContain('sys_organization'); }); - it('the organization-less row a pre-D5 promotion wrote still anchors, and asks no organization read', async () => { - const ql = makeQl({ ...legacyTables(), sys_organization: DEFAULT_ONLY }); - const grants = await resolveUserAuthzGrants(ql, 'usr_1', { nowMs: NOW }); - expect(grants.posture).toBe('PLATFORM_ADMIN'); - expect(ql.calls.map((c) => c.object)).not.toContain('sys_organization'); + it('⛔ the organization-less row a pre-D5 promotion wrote anchors NOTHING, confers nothing, and asks no organization read', async () => { + // The D10 ownership ceremony attributes that row to the Default Organization + // before an upgraded database boots, so a row still carrying no organization + // is invalid state, not an anchor. The CONTROL is the ⭐ arm above: the same + // grant owned by the Default Organization confers, on the same fixture. + for (const tenantId of [undefined, 'org_default']) { + const ql = makeQl({ + ...legacyTables(), + sys_organization: DEFAULT_ONLY, + sys_user_permission_set: [ + { id: 'ups_1', user_id: 'usr_1', permission_set_id: 'pst_1', permission_set: ADMIN_FULL_ACCESS, organization_id: null }, + ], + }); + const grants = await resolveUserAuthzGrants(ql, 'usr_1', { nowMs: NOW, tenantId }); + expect(grants.posture, String(tenantId)).toBe('MEMBER'); + expect(grants.positions, String(tenantId)).not.toContain('platform_admin'); + expect(grants.permissions, String(tenantId)).not.toContain(ADMIN_FULL_ACCESS); + expect(ql.calls.map((c) => c.object), String(tenantId)).not.toContain('sys_organization'); + } }); }); 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 0f7bec4412c..450e6783d25 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 @@ -85,6 +85,11 @@ function makeTables(): Record { { id: 'u_member', email: 'member@x.com', ai_access: 1 }, { id: 'u_outsider', email: 'outsider@x.com' }, ], + sys_organization: [ + { id: 'org_default', slug: 'default' }, + { id: 'org_a', slug: 'org-a' }, + { id: 'org_b', slug: 'org-b' }, + ], sys_member: [ { user_id: 'u_owner', organization_id: 'org_a', role: 'owner' }, { user_id: 'u_admin', organization_id: 'org_a', role: 'admin' }, @@ -94,7 +99,7 @@ function makeTables(): Record { ], sys_user_position: [ { user_id: 'u_member', position: 'auditor', organization_id: 'org_a' }, - { user_id: 'u_member', position: 'temp_role', organization_id: null, valid_until: iso(T0 + DAY) }, + { user_id: 'u_member', position: 'temp_role', organization_id: 'org_a', valid_until: iso(T0 + DAY) }, { user_id: 'u_outsider', position: 'auditor', organization_id: 'org_a' }, ], sys_position: [ @@ -110,7 +115,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', permission_set: 'admin_full_access', organization_id: null }, + // The `single` posture's platform-standing anchor: owned by the Default Organization. + { user_id: 'u_padmin', permission_set_id: 'ps_admin', permission_set: 'admin_full_access', organization_id: 'org_default' }, { user_id: 'u_member', permission_set_id: 'ps_tools', permission_set: 'org_tools', organization_id: 'org_a' }, ], sys_permission_set: [ @@ -284,7 +290,7 @@ interface CallerClass { const CALLER_CLASSES: CallerClass[] = [ { - name: 'platform administrator — unscoped admin_full_access grant (single posture)', + name: 'platform administrator — the Default Organization\'s admin_full_access grant (single posture)', fixture: sessionOf('u_padmin', 'padmin@x.com', null), expect: (ctx) => { expect(ctx.posture).toBe('PLATFORM_ADMIN'); diff --git a/packages/core/src/security/resolve-authz-context.test.ts b/packages/core/src/security/resolve-authz-context.test.ts index 344f1ba51a8..ea843e0cf86 100644 --- a/packages/core/src/security/resolve-authz-context.test.ts +++ b/packages/core/src/security/resolve-authz-context.test.ts @@ -82,10 +82,10 @@ describe('resolveAuthzContext — single source of truth', () => { const ql = makeQl({ sys_user: [{ id: 'u1', email: 'ada@x.com' }], sys_member: [], - sys_user_position: [{ user_id: 'u1', position: 'contributor', organization_id: null }], + sys_user_position: [{ user_id: 'u1', position: 'contributor', organization_id: 'o1' }], sys_user_permission_set: [], }); - const ctx = await resolveAuthzContext({ userAuthGates: undefined, apiKeyLifecycle: undefined, ql, headers: H(), getSession: session('u1') }); + const ctx = await resolveAuthzContext({ userAuthGates: undefined, apiKeyLifecycle: undefined, ql, headers: H(), getSession: session('u1', { org: 'o1' }) }); expect(ctx.positions).toContain('contributor'); }); @@ -104,29 +104,44 @@ describe('resolveAuthzContext — single source of truth', () => { const ql = makeQl({ sys_user: [{ id: 'u1' }], sys_member: [], - sys_user_position: [{ user_id: 'u1', position: 'contributor', organization_id: null }], + sys_user_position: [{ user_id: 'u1', position: 'contributor', organization_id: 'o1' }], sys_user_permission_set: [], 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'] }], }); - const ctx = await resolveAuthzContext({ userAuthGates: undefined, apiKeyLifecycle: undefined, ql, headers: H(), getSession: session('u1') }); + const ctx = await resolveAuthzContext({ userAuthGates: undefined, apiKeyLifecycle: undefined, ql, headers: H(), getSession: session('u1', { org: 'o1' }) }); expect(ctx.permissions).toContain('contributor_ps'); expect(ctx.systemPermissions).toContain('cap_x'); }); - it('derives platform_admin from an UNSCOPED admin_full_access user grant', async () => { + it('derives platform_admin from the Default Organization\'s admin_full_access user grant', async () => { const ql = makeQl({ sys_user: [{ id: 'u1' }], sys_member: [], + sys_organization: [{ id: 'org_default', slug: 'default' }], sys_user_position: [], - sys_user_permission_set: [{ user_id: 'u1', permission_set_id: 'psA', permission_set: 'admin_full_access', organization_id: null }], + sys_user_permission_set: [{ user_id: 'u1', permission_set_id: 'psA', permission_set: 'admin_full_access', organization_id: 'org_default' }], sys_permission_set: [{ id: 'psA', name: 'admin_full_access' }], }); const ctx = await resolveAuthzContext({ userAuthGates: undefined, apiKeyLifecycle: undefined, ql, headers: H(), getSession: session('u1') }); expect(ctx.positions).toContain('platform_admin'); }); + it('does NOT derive platform_admin from an ORGANIZATION-LESS admin_full_access grant — it anchors nothing', async () => { + const ql = makeQl({ + sys_user: [{ id: 'u1' }], + sys_member: [], + sys_organization: [{ id: 'org_default', slug: 'default' }], + sys_user_position: [], + 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({ userAuthGates: undefined, apiKeyLifecycle: undefined, ql, headers: H(), getSession: session('u1') }); + expect(ctx.positions).not.toContain('platform_admin'); + expect(ctx.permissions).not.toContain('admin_full_access'); + }); + it('does NOT derive platform_admin from an ORG-scoped admin_full_access grant', async () => { const ql = makeQl({ sys_user: [{ id: 'u1' }], @@ -716,12 +731,12 @@ describe('grant validity windows (ADR-0091 D1/D2)', () => { sys_user: [{ id: 'u1' }], sys_member: [], sys_user_position: [ - { user_id: 'u1', position: 'approver', organization_id: null, valid_until: PAST }, - { user_id: 'u1', position: 'contributor', organization_id: null }, + { user_id: 'u1', position: 'approver', organization_id: 'o1', valid_until: PAST }, + { user_id: 'u1', position: 'contributor', organization_id: 'o1' }, ], sys_user_permission_set: [], }); - const ctx = await resolveAuthzContext({ userAuthGates: undefined, apiKeyLifecycle: undefined, ql, headers: H(), getSession: session('u1'), nowMs: NOW }); + const ctx = await resolveAuthzContext({ userAuthGates: undefined, apiKeyLifecycle: undefined, ql, headers: H(), getSession: session('u1', { org: 'o1' }), nowMs: NOW }); expect(ctx.positions).not.toContain('approver'); expect(ctx.positions).toContain('contributor'); // null bounds = unbounded, unchanged }); @@ -730,10 +745,10 @@ describe('grant validity windows (ADR-0091 D1/D2)', () => { const ql = makeQl({ sys_user: [{ id: 'u1' }], sys_member: [], - sys_user_position: [{ user_id: 'u1', position: 'approver', organization_id: null, valid_from: FUTURE }], + sys_user_position: [{ user_id: 'u1', position: 'approver', organization_id: 'o1', valid_from: FUTURE }], sys_user_permission_set: [], }); - const ctx = await resolveAuthzContext({ userAuthGates: undefined, apiKeyLifecycle: undefined, ql, headers: H(), getSession: session('u1'), nowMs: NOW }); + const ctx = await resolveAuthzContext({ userAuthGates: undefined, apiKeyLifecycle: undefined, ql, headers: H(), getSession: session('u1', { org: 'o1' }), nowMs: NOW }); expect(ctx.positions).not.toContain('approver'); }); @@ -742,13 +757,13 @@ describe('grant validity windows (ADR-0091 D1/D2)', () => { sys_user: [{ id: 'u1' }], sys_member: [], sys_user_position: [ - { user_id: 'u1', position: 'stand_in', organization_id: null, valid_from: PAST, valid_until: FUTURE }, + { user_id: 'u1', position: 'stand_in', organization_id: 'o1', valid_from: PAST, valid_until: FUTURE }, // Boundary: valid_until exactly NOW → inactive AT the bound (half-open). - { user_id: 'u1', position: 'boundary', organization_id: null, valid_until: '2026-07-10T12:00:00Z' }, + { user_id: 'u1', position: 'boundary', organization_id: 'o1', valid_until: '2026-07-10T12:00:00Z' }, ], sys_user_permission_set: [], }); - const ctx = await resolveAuthzContext({ userAuthGates: undefined, apiKeyLifecycle: undefined, ql, headers: H(), getSession: session('u1'), nowMs: NOW }); + const ctx = await resolveAuthzContext({ userAuthGates: undefined, apiKeyLifecycle: undefined, ql, headers: H(), getSession: session('u1', { org: 'o1' }), nowMs: NOW }); expect(ctx.positions).toContain('stand_in'); expect(ctx.positions).not.toContain('boundary'); }); @@ -757,9 +772,10 @@ describe('grant validity windows (ADR-0091 D1/D2)', () => { const ql = makeQl({ sys_user: [{ id: 'u1' }], sys_member: [], + sys_organization: [{ id: 'org_default', slug: 'default' }], sys_user_position: [], sys_user_permission_set: [ - { user_id: 'u1', permission_set_id: 'psA', permission_set: 'admin_full_access', organization_id: null, valid_until: PAST }, + { user_id: 'u1', permission_set_id: 'psA', permission_set: 'admin_full_access', organization_id: 'org_default', valid_until: PAST }, ], sys_permission_set: [{ id: 'psA', name: 'admin_full_access' }], }); @@ -772,10 +788,10 @@ describe('grant validity windows (ADR-0091 D1/D2)', () => { const ql = makeQl({ sys_user: [{ id: 'u1' }], sys_member: [], - sys_user_position: [{ user_id: 'u1', position: 'approver', organization_id: null, valid_until: 'not-a-date' }], + sys_user_position: [{ user_id: 'u1', position: 'approver', organization_id: 'o1', valid_until: 'not-a-date' }], sys_user_permission_set: [], }); - const ctx = await resolveAuthzContext({ userAuthGates: undefined, apiKeyLifecycle: undefined, ql, headers: H(), getSession: session('u1'), nowMs: NOW }); + const ctx = await resolveAuthzContext({ userAuthGates: undefined, apiKeyLifecycle: undefined, ql, headers: H(), getSession: session('u1', { org: 'o1' }), nowMs: NOW }); expect(ctx.positions).not.toContain('approver'); }); @@ -953,7 +969,7 @@ describe('audience anchors in the resolver (ADR-0090 D5)', () => { it('every authenticated principal implicitly holds `everyone` (additive, no cliff)', async () => { const ql = makeQl({ sys_member: [{ user_id: 'u1', role: 'member', organization_id: 'o1' }], - sys_user_position: [{ user_id: 'u1', position: 'contributor', organization_id: null }], + sys_user_position: [{ user_id: 'u1', position: 'contributor', organization_id: 'o1' }], }); const ctx = await resolveAuthzContext({ userAuthGates: undefined, apiKeyLifecycle: undefined, ql, headers: H(), getSession: session('u1', { org: 'o1' }) }); // holding an explicit position must NOT cost the baseline anchor @@ -980,13 +996,14 @@ describe('audience anchors in the resolver (ADR-0090 D5)', () => { describe('resolveAuthzContext — posture ladder (ADR-0095 D2/D3)', () => { // Each fixture returns the ql tables + the session getter for one principal. const FIXTURES: Record = { - // Unscoped admin_full_access grant → the platform-admin capability. + // The Default Organization's admin_full_access grant → the platform-admin capability. platform_admin: { ql: makeQl({ sys_user: [{ id: 'pa' }], sys_member: [], + sys_organization: [{ id: 'org_default', slug: 'default' }], sys_user_position: [], - sys_user_permission_set: [{ user_id: 'pa', permission_set_id: 'psA', permission_set: 'admin_full_access', organization_id: null }], + sys_user_permission_set: [{ user_id: 'pa', permission_set_id: 'psA', permission_set: 'admin_full_access', organization_id: 'org_default' }], sys_permission_set: [{ id: 'psA', name: 'admin_full_access' }], }), getSession: session('pa'), @@ -1052,15 +1069,16 @@ describe('resolveAuthzContext — posture ladder (ADR-0095 D2/D3)', () => { }); it('platform-admin grant wins over a co-held org-admin grant (capability, not role)', async () => { - // A principal who is BOTH an org admin (role) AND holds the unscoped - // platform grant resolves PLATFORM_ADMIN — derivation reads the capability, - // so the higher rung wins regardless of the better-auth role. + // A principal who is BOTH an org admin (role) AND holds the Default + // Organization's platform grant resolves PLATFORM_ADMIN — derivation reads + // the capability, so the higher rung wins regardless of the better-auth role. const ql = makeQl({ sys_user: [{ id: 'both' }], sys_member: [{ user_id: 'both', role: 'admin', organization_id: 'o1' }], + sys_organization: [{ id: 'org_default', slug: 'default' }, { id: 'o1', slug: 'o1' }], sys_user_position: [], sys_user_permission_set: [ - { user_id: 'both', permission_set_id: 'psA', permission_set: 'admin_full_access', organization_id: null }, + { user_id: 'both', permission_set_id: 'psA', permission_set: 'admin_full_access', organization_id: 'org_default' }, { user_id: 'both', permission_set_id: 'psO', permission_set: 'organization_admin', organization_id: 'o1' }, ], sys_permission_set: [ @@ -1107,14 +1125,16 @@ describe('resolveUserAuthzGrants — userId-driven authz for non-HTTP surfaces ( const tables = { 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', permission_set: 'contributor_ps', organization_id: null }], + sys_user_position: [{ user_id: 'u1', position: 'contributor', organization_id: 'o1' }], + sys_user_permission_set: [{ user_id: 'u1', permission_set_id: 'ps1', permission_set: 'contributor_ps', organization_id: 'o1' }], 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'] }], }; - const viaHttp = await resolveAuthzContext({ userAuthGates: undefined, apiKeyLifecycle: undefined, ql: makeQl(tables), headers: H(), getSession: session('u1') }); - const viaUser = await resolveUserAuthzGrants(makeQl(tables), 'u1'); + const viaHttp = await resolveAuthzContext({ userAuthGates: undefined, apiKeyLifecycle: undefined, ql: makeQl(tables), headers: H(), getSession: session('u1', { org: 'o1' }) }); + const viaUser = await resolveUserAuthzGrants(makeQl(tables), 'u1', { tenantId: 'o1' }); + // Not vacuous: both surfaces actually resolved the grants. + expect(viaUser.permissions).toContain('contributor_ps'); expect([...viaUser.positions].sort()).toEqual([...viaHttp.positions].sort()); expect([...viaUser.permissions].sort()).toEqual([...viaHttp.permissions].sort()); expect([...viaUser.systemPermissions].sort()).toEqual([...viaHttp.systemPermissions].sort()); @@ -1126,10 +1146,10 @@ 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', permission_set: 'sales_ps', organization_id: null }], + sys_user_permission_set: [{ user_id: 'u1', permission_set_id: 'ps1', permission_set: 'sales_ps', organization_id: 'o1' }], sys_permission_set: [{ id: 'ps1', name: 'sales_ps' }], }); - const grants = await resolveUserAuthzGrants(ql, 'u1', { seedPermissions: ['api:scope'] }); + const grants = await resolveUserAuthzGrants(ql, 'u1', { tenantId: 'o1', seedPermissions: ['api:scope'] }); expect(grants.permissions[0]).toBe('api:scope'); expect(grants.permissions).toContain('sales_ps'); }); @@ -1161,10 +1181,10 @@ 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', permission_set: 'expired_ps', organization_id: null, valid_until: past }], + sys_user_permission_set: [{ user_id: 'u1', permission_set_id: 'psA', permission_set: 'expired_ps', organization_id: 'o1', valid_until: past }], sys_permission_set: [{ id: 'psA', name: 'expired_ps' }], }); - const grants = await resolveUserAuthzGrants(ql, 'u1'); + const grants = await resolveUserAuthzGrants(ql, 'u1', { tenantId: 'o1' }); expect(grants.permissions).not.toContain('expired_ps'); }); }); @@ -1356,7 +1376,7 @@ describe('[#8613] the `active` flag on the grant catalogues (ADR-0049)', () => { const withActive = (v: unknown) => ({ sys_user: [{ id: 'u1' }], sys_member: [], - sys_user_position: [{ user_id: 'u1', position: 'contributor', organization_id: null }], + sys_user_position: [{ user_id: 'u1', position: 'contributor', organization_id: 'o1' }], sys_user_permission_set: [], sys_position: [{ id: 'r1', name: 'contributor' }], sys_position_permission_set: [{ position_id: 'r1', permission_set_id: 'ps1' }], @@ -1372,7 +1392,7 @@ describe('[#8613] the `active` flag on the grant catalogues (ADR-0049)', () => { apiKeyLifecycle: undefined, ql: makeQl(withActive(false)), headers: H(), - getSession: session('u1'), + getSession: session('u1', { org: 'o1' }), }); expect(ctx.permissions).not.toContain('contributor_ps'); expect(ctx.systemPermissions).not.toContain('cap_x'); @@ -1387,7 +1407,7 @@ describe('[#8613] the `active` flag on the grant catalogues (ADR-0049)', () => { apiKeyLifecycle: undefined, ql: makeQl(withActive(false)), headers: H(), - getSession: session('u1'), + getSession: session('u1', { org: 'o1' }), }); expect(ctx.positions).not.toContain('contributor'); // The audience anchor is untouched — it is not the deactivated row. @@ -1400,7 +1420,7 @@ describe('[#8613] the `active` flag on the grant catalogues (ADR-0049)', () => { apiKeyLifecycle: undefined, ql: makeQl(withActive(true)), headers: H(), - getSession: session('u1'), + getSession: session('u1', { org: 'o1' }), }); expect(ctx.positions).toContain('contributor'); expect(ctx.permissions).toContain('contributor_ps'); @@ -1413,7 +1433,7 @@ describe('[#8613] the `active` flag on the grant catalogues (ADR-0049)', () => { apiKeyLifecycle: undefined, ql: makeQl(withActive(undefined)), headers: H(), - getSession: session('u1'), + getSession: session('u1', { org: 'o1' }), }); expect(ctx.positions).toContain('contributor'); expect(ctx.permissions).toContain('contributor_ps'); @@ -1425,7 +1445,7 @@ describe('[#8613] the `active` flag on the grant catalogues (ADR-0049)', () => { apiKeyLifecycle: undefined, ql: makeQl(withActive(0)), headers: H(), - getSession: session('u1'), + getSession: session('u1', { org: 'o1' }), }); expect(off.permissions).not.toContain('contributor_ps'); const on = await resolveAuthzContext({ @@ -1433,7 +1453,7 @@ describe('[#8613] the `active` flag on the grant catalogues (ADR-0049)', () => { apiKeyLifecycle: undefined, ql: makeQl(withActive(1)), headers: H(), - getSession: session('u1'), + getSession: session('u1', { org: 'o1' }), }); expect(on.permissions).toContain('contributor_ps'); }); @@ -1457,8 +1477,8 @@ 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 }, - { user_id: 'u1', position: 'reviewer', organization_id: null }, + { user_id: 'u1', position: 'contributor', organization_id: 'o1' }, + { user_id: 'u1', position: 'reviewer', organization_id: 'o1' }, ], sys_user_permission_set: [], sys_position: [ @@ -1475,7 +1495,7 @@ describe('[#8613] the `active` flag on the grant catalogues (ADR-0049)', () => { { id: 'ps2', name: 'reviewer_ps' }, ], }); - const ctx = await resolveAuthzContext({ userAuthGates: undefined, apiKeyLifecycle: undefined, ql, headers: H(), getSession: session('u1') }); + const ctx = await resolveAuthzContext({ userAuthGates: undefined, apiKeyLifecycle: undefined, ql, headers: H(), getSession: session('u1', { org: 'o1' }) }); expect(ctx.permissions).not.toContain('contributor_ps'); expect(ctx.permissions).toContain('reviewer_ps'); expect(ctx.positions).not.toContain('contributor'); @@ -1489,7 +1509,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', permission_set: 'crm_full', organization_id: null }], + sys_user_permission_set: [{ user_id: 'u1', permission_set_id: 'ps1', permission_set: 'crm_full', organization_id: 'o1' }], sys_permission_set: [{ id: 'ps1', name: 'crm_full', @@ -1498,7 +1518,7 @@ describe('[#8613] the `active` flag on the grant catalogues (ADR-0049)', () => { }], sys_metadata_activation: [off('permission', 'crm_full')], }); - const ctx = await resolveAuthzContext({ userAuthGates: undefined, apiKeyLifecycle: undefined, ql, headers: H(), getSession: session('u1') }); + const ctx = await resolveAuthzContext({ userAuthGates: undefined, apiKeyLifecycle: undefined, ql, headers: H(), getSession: session('u1', { org: 'o1' }) }); expect(ctx.permissions).not.toContain('crm_full'); expect(ctx.systemPermissions).not.toContain('cap_x'); expect(ctx.tabPermissions?.crm).toBeUndefined(); @@ -1508,8 +1528,9 @@ describe('[#8613] the `active` flag on the grant catalogues (ADR-0049)', () => { const ql = makeQl({ sys_user: [{ id: 'u1' }], sys_member: [], + sys_organization: [{ id: 'org_default', slug: 'default' }], sys_user_position: [], - sys_user_permission_set: [{ user_id: 'u1', permission_set_id: 'psA', permission_set: 'admin_full_access', organization_id: null }], + sys_user_permission_set: [{ user_id: 'u1', permission_set_id: 'psA', permission_set: 'admin_full_access', organization_id: 'org_default' }], sys_permission_set: [{ id: 'psA', name: 'admin_full_access', @@ -1533,8 +1554,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', permission_set: 'crm_full', organization_id: null }, - { user_id: 'u1', permission_set_id: 'ps2', permission_set: 'crm_read', organization_id: null }, + { user_id: 'u1', permission_set_id: 'ps1', permission_set: 'crm_full', organization_id: 'o1' }, + { user_id: 'u1', permission_set_id: 'ps2', permission_set: 'crm_read', organization_id: 'o1' }, ], sys_permission_set: [ { id: 'ps1', name: 'crm_full' }, @@ -1542,7 +1563,7 @@ describe('[#8613] the `active` flag on the grant catalogues (ADR-0049)', () => { ], sys_metadata_activation: [off('permission', 'crm_full'), off('permission', 'crm_read', true)], }); - const ctx = await resolveAuthzContext({ userAuthGates: undefined, apiKeyLifecycle: undefined, ql, headers: H(), getSession: session('u1') }); + const ctx = await resolveAuthzContext({ userAuthGates: undefined, apiKeyLifecycle: undefined, ql, headers: H(), getSession: session('u1', { org: 'o1' }) }); expect(ctx.permissions).not.toContain('crm_full'); expect(ctx.permissions).toContain('crm_read'); }); @@ -1553,20 +1574,20 @@ describe('[#8613] the `active` flag on the grant catalogues (ADR-0049)', () => { const ql = makeQl({ 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', permission_set: 'crm_full', organization_id: null }], + sys_user_position: [{ user_id: 'u1', position: 'contributor', organization_id: 'o1' }], + sys_user_permission_set: [{ user_id: 'u1', permission_set_id: 'ps1', permission_set: 'crm_full', organization_id: 'o1' }], sys_position: [{ id: 'r1', name: 'contributor' }], sys_position_permission_set: [{ position_id: 'r1', permission_set_id: 'ps1' }], sys_permission_set: [{ id: 'ps1', name: 'crm_full' }], sys_metadata_activation: [off('position', 'contributor')], }); - const ctx = await resolveAuthzContext({ userAuthGates: undefined, apiKeyLifecycle: undefined, ql, headers: H(), getSession: session('u1') }); + const ctx = await resolveAuthzContext({ userAuthGates: undefined, apiKeyLifecycle: undefined, ql, headers: H(), getSession: session('u1', { org: 'o1' }) }); expect(ctx.permissions).toContain('crm_full'); expect(ctx.positions).not.toContain('contributor'); }); it('resolveUserAuthzGrants enforces it too — the non-HTTP surfaces share the seam', async () => { - const grants = await resolveUserAuthzGrants(makeQl(withActive(false)), 'u1'); + const grants = await resolveUserAuthzGrants(makeQl(withActive(false)), 'u1', { tenantId: 'o1' }); expect(grants.permissions).not.toContain('contributor_ps'); expect(grants.positions).not.toContain('contributor'); }); @@ -1586,13 +1607,13 @@ describe('[#8613] the `active` flag on the grant catalogues (ADR-0049)', () => { return find(object, opts); }, }); - const grants = await resolveUserAuthzGrants(ql, 'u1'); + const grants = await resolveUserAuthzGrants(ql, 'u1', { tenantId: 'o1' }); expect(reads).not.toContain('sys_metadata_activation'); // No ledger in the composition: nothing is switched off. expect(grants.permissions).toContain('contributor_ps'); // CONTROL — the same engine with the ledger registered reads it, and honours it. const registered = Object.assign(makeQl(tables), { registry: { getObject: (name: string) => ({ name }) } }); - expect((await resolveUserAuthzGrants(registered, 'u1')).permissions).not.toContain('contributor_ps'); + expect((await resolveUserAuthzGrants(registered, 'u1', { tenantId: 'o1' })).permissions).not.toContain('contributor_ps'); }); it('[ADR-0131 D3] a catalog ROW\'s `active: false` is not read — the ledger is the switch', async () => { @@ -1607,7 +1628,7 @@ describe('[#8613] the `active` flag on the grant catalogues (ADR-0049)', () => { sys_permission_set: [{ id: 'ps1', name: 'contributor_ps', active: false, system_permissions: ['cap_x'] }], }), headers: H(), - getSession: session('u1'), + getSession: session('u1', { org: 'o1' }), }); expect(ctx.positions).toContain('contributor'); expect(ctx.permissions).toContain('contributor_ps'); @@ -1964,11 +1985,13 @@ describe('[#20580] vetOrganizationClaim — the organization a claim resolves in }); /** - * [#20515] A resolution with NO active organization applies only the GLOBAL - * grants (`organization_id` null). A grant scoped to an organization applies - * only while that organization is the active tenant — §4 (`sys_user_position`) - * and §6 (`sys_user_permission_set`) ask the one predicate, so they cannot - * disagree. + * [#20515] A grant applies only while its organization is the active tenant — + * §4 (`sys_user_position`) and §6 (`sys_user_permission_set`) ask the one + * predicate, so they cannot disagree. A resolution with NO active organization + * applies no stored grant row, and a row with no organization (`null`) applies + * NOWHERE: the ownership ceremony (ADR-0131 D10) attributes every such row to + * an organization and makes the column NOT NULL, so it is invalid state, not a + * "global" grant. * * The population this was measured on: a member removed from an organization * whose session still names it. The session arm drops the claim (#15409 ruling @@ -1980,24 +2003,24 @@ describe('[#20580] vetOrganizationClaim — the organization a claim resolves in * `DELETE /packages/:id`) is pinned in `packages/runtime` * (`packages-orgless-grants-capability-gate.test.ts`). */ -describe('[#20515] with no active organization, only global grants apply', () => { +describe('[#20515] with no active organization, no stored grant applies', () => { const ALPHA = 'org_alpha'; const BETA = 'org_beta'; - /** One set and one position per scope: global, alpha, beta. */ + /** One set and one position per scope: organization-less, alpha, beta. */ const grantRows = (userId: string) => ({ sys_user_position: [ - { user_id: userId, position: 'global_position', organization_id: null }, + { user_id: userId, position: 'orgless_position', organization_id: null }, { user_id: userId, position: 'alpha_position', organization_id: ALPHA }, { user_id: userId, position: 'beta_position', organization_id: BETA }, ], sys_user_permission_set: [ - { user_id: userId, permission_set_id: 'ps_global', permission_set: 'global_set', organization_id: null }, + { user_id: userId, permission_set_id: 'ps_orgless', permission_set: 'orgless_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'] }, + { id: 'ps_orgless', name: 'orgless_set', system_permissions: ['orgless_cap'] }, { id: 'ps_alpha', name: 'alpha_set', system_permissions: ['manage_metadata'] }, { id: 'ps_beta', name: 'beta_set', system_permissions: ['beta_cap'] }, ], @@ -2010,27 +2033,27 @@ describe('[#20515] with no active organization, only global grants apply', () => systemPermissions: [...g.systemPermissions].sort(), }); - it('no tenant: §4 and §6 both keep the global rows and NOTHING scoped to an organization', async () => { + it('no tenant: §4 and §6 both keep NOTHING — neither the organization-less rows nor any organization\'s', async () => { const ql = makeQl({ sys_user: [{ id: 'u1' }], sys_member: [], ...grantRows('u1') }); const grants = await resolveUserAuthzGrants(ql, 'u1', {}); expect(applied(grants)).toEqual({ - positions: ['global_position'], - permissions: ['global_set'], - systemPermissions: ['global_cap'], + positions: [], + permissions: [], + systemPermissions: [], }); }); - it('a tenant: §4 and §6 both keep the global rows plus THAT organization\'s, never another\'s', async () => { + it('a tenant: §4 and §6 both keep THAT organization\'s rows only — never another\'s, never the organization-less ones', async () => { const ql = makeQl({ sys_user: [{ id: 'u1' }], sys_member: [], ...grantRows('u1') }); expect(applied(await resolveUserAuthzGrants(ql, 'u1', { tenantId: ALPHA }))).toEqual({ - positions: ['alpha_position', 'global_position'], - permissions: ['alpha_set', 'global_set'], - systemPermissions: ['global_cap', 'manage_metadata'], + positions: ['alpha_position'], + permissions: ['alpha_set'], + systemPermissions: ['manage_metadata'], }); expect(applied(await resolveUserAuthzGrants(ql, 'u1', { tenantId: BETA }))).toEqual({ - positions: ['beta_position', 'global_position'], - permissions: ['beta_set', 'global_set'], - systemPermissions: ['beta_cap', 'global_cap'], + positions: ['beta_position'], + permissions: ['beta_set'], + systemPermissions: ['beta_cap'], }); }); @@ -2100,22 +2123,22 @@ describe('[#20515] with no active organization, only global grants apply', () => * `u_gone` has no membership left anywhere; both still hold the operator- * authored `manage_metadata` set granted SCOPED to `org_alpha`, which the * removal did not revoke. `u_member` is the control: a current `org_alpha` - * member holding the same grant. `u_global_ex` is removed the same way but - * holds the set GLOBALLY — a global grant is untouched by this card. + * member holding the same grant. `u_orgless_ex` is removed the same way but + * holds the set on an ORGANIZATION-LESS row — which confers nothing anywhere. */ const tables = () => ({ - sys_user: ['u_ex', 'u_gone', 'u_member', 'u_global_ex'].map((id) => ({ id, email: `${id}@x.com` })), + sys_user: ['u_ex', 'u_gone', 'u_member', 'u_orgless_ex'].map((id) => ({ id, email: `${id}@x.com` })), sys_member: [ { user_id: 'u_ex', organization_id: BETA, role: 'member' }, { user_id: 'u_member', organization_id: ALPHA, role: 'member' }, - { user_id: 'u_global_ex', organization_id: BETA, role: 'member' }, + { user_id: 'u_orgless_ex', organization_id: BETA, role: 'member' }, ], sys_user_position: [], sys_user_permission_set: [ { 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 }, + { user_id: 'u_orgless_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'] }], }); @@ -2153,18 +2176,20 @@ describe('[#20515] with no active organization, only global grants apply', () => expect(ctx.permissions).toContain('alpha_metadata_editors'); }); - it('a GLOBAL grant is unchanged: the removed member still holds it with no active organization', async () => { + it('an ORGANIZATION-LESS grant confers nothing: the removed member does not hold it with no active organization', async () => { const ctx = await resolveAuthzContext({ userAuthGates: undefined, apiKeyLifecycle: undefined, - ql: makeQl(tables()), headers: H(), getSession: namingAlpha('u_global_ex'), tenancyPosture: 'isolated', + ql: makeQl(tables()), headers: H(), getSession: namingAlpha('u_orgless_ex'), tenancyPosture: 'isolated', }); + expect(ctx.userId).toBe('u_orgless_ex'); expect(ctx.tenantId).toBeUndefined(); - expect(ctx.systemPermissions).toContain('manage_metadata'); + expect(ctx.systemPermissions).not.toContain('manage_metadata'); + expect(ctx.permissions).not.toContain('alpha_metadata_editors'); }); }); - describe('platform-admin standing is unchanged — it was only ever derived from an UNSCOPED grant', () => { + describe('platform-admin standing is anchored by the Default Organization\'s grant — never an organization-less one', () => { const POSTURE_ENV = ['OS_TENANCY_POSTURE', 'OS_MULTI_ORG_ENABLED', 'OS_PLATFORM_OWNER_EMAIL'] as const; const saved: Record = {}; beforeEach(() => { @@ -2177,17 +2202,23 @@ describe('[#20515] with no active organization, only global grants apply', () => } }); - /** The row `bootstrapPlatformAdmin` mints: `admin_full_access`, organization null. */ + const DEFAULT_ORG = 'org_default'; + /** + * The row `bootstrapPlatformAdmin` mints is `admin_full_access` owned by the + * Default Organization; `null` is the organization-less row an earlier + * release's promotion wrote, which the ownership ceremony attributes. + */ const adminTables = (organizationId: string | null) => ({ sys_user: [{ id: 'u_admin', email: 'admin@x.com' }], + sys_organization: [{ id: DEFAULT_ORG, slug: 'default' }, { id: ALPHA, slug: 'alpha' }], 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', permission_set: 'admin_full_access', organization_id: organizationId }], sys_permission_set: [{ id: 'ps_admin', name: 'admin_full_access', system_permissions: ['manage_metadata'] }], }); - it('the bootstrap-shaped UNSCOPED grant: PLATFORM_ADMIN with no tenant and with one', async () => { - const ql = makeQl(adminTables(null)); + it('the bootstrap-shaped Default Organization grant: PLATFORM_ADMIN with no tenant and with one', async () => { + const ql = makeQl(adminTables(DEFAULT_ORG)); expect(await hasPlatformAdminStanding(ql, 'u_admin')).toBe(true); const orgless = await resolveUserAuthzGrants(ql, 'u_admin', {}); expect(orgless.posture).toBe('PLATFORM_ADMIN'); @@ -2195,6 +2226,17 @@ describe('[#20515] with no active organization, only global grants apply', () => expect((await resolveUserAuthzGrants(ql, 'u_admin', { tenantId: ALPHA })).posture).toBe('PLATFORM_ADMIN'); }); + it('an ORGANIZATION-LESS admin_full_access grant anchors nothing and confers nothing, with no tenant and with one', async () => { + const ql = makeQl(adminTables(null)); + expect(await hasPlatformAdminStanding(ql, 'u_admin')).toBe(false); + for (const opts of [{}, { tenantId: ALPHA }, { tenantId: DEFAULT_ORG }]) { + const grants = await resolveUserAuthzGrants(ql, 'u_admin', opts); + expect(grants.posture, JSON.stringify(opts)).not.toBe('PLATFORM_ADMIN'); + expect(grants.permissions, JSON.stringify(opts)).not.toContain('admin_full_access'); + expect(grants.systemPermissions, JSON.stringify(opts)).not.toContain('manage_metadata'); + } + }); + it('an ORG-scoped admin_full_access grant confers no platform standing, with or without that tenant', async () => { const ql = makeQl(adminTables(ALPHA)); expect(await hasPlatformAdminStanding(ql, 'u_admin')).toBe(false); diff --git a/packages/core/src/security/resolve-authz-context.ts b/packages/core/src/security/resolve-authz-context.ts index df3aadb5b1e..d05eb6b42b4 100644 --- a/packages/core/src/security/resolve-authz-context.ts +++ b/packages/core/src/security/resolve-authz-context.ts @@ -792,11 +792,11 @@ export interface UserAuthzGrants { export interface ResolveUserAuthzGrantsOptions { /** - * Active org/tenant id — scopes org-bound grants (a null-org row is global). - * Omitted ⇒ NO active organization, so only the global grants apply: an - * organization-scoped position or permission-set row applies only while its - * organization is this tenant ({@link grantAppliesInTenant}). There is no - * "every organization" reading of an omitted tenant. + * Active org/tenant id — the organization whose grants apply. A position or + * permission-set row applies only while its organization is this tenant + * ({@link grantAppliesInTenant}). Omitted ⇒ NO active organization, so no + * stored grant row applies (the platform-standing anchor aside, §6b). There + * is no "every organization" reading of an omitted tenant. */ tenantId?: string; /** Clock injection for grant validity windows (tests). */ @@ -832,12 +832,20 @@ export interface ResolveUserAuthzGrantsOptions { } /** - * The ONE organization rule for a stored grant row — `sys_user_position` (§4), - * `sys_user_permission_set` (§6) and the `sys_position` rows whose bindings - * §6a collects all ask it, so none of them can disagree: a row with no - * `organization_id` is GLOBAL and applies in every resolution; a row scoped to - * an organization applies only when that organization is the ACTIVE tenant. - * With no active tenant, an organization-scoped row does not apply at all. + * The ONE organization rule for a stored grant row — `sys_user_position` (§4) + * and `sys_user_permission_set` (§6) both ask it, so neither can disagree: a + * row applies only when its organization is the ACTIVE tenant. With no active + * tenant, no row applies. + * + * `organizationId` is the row's organization as {@link grantOrganizationOf} + * normalizes it. A row with none (`null`) applies NOWHERE: the 18.0 ownership + * ceremony (ADR-0131 D10) attributes every organization-less row before an + * upgraded database boots, and makes the column NOT NULL, so an + * organization-less grant is invalid state rather than a "global" one. The + * reading that let such a row apply in every resolution retired with it. The + * grant that stands for the whole deployment is the Default Organization's + * `admin_full_access` anchor, which §6 reads through + * {@link grantAnchorsPlatformStanding}, not through this rule. * * The spelling both sites carried before, `org && tenantId && org !== tenantId` * as the SKIP condition, read "no tenant" as "every organization": it was false @@ -850,7 +858,7 @@ export interface ResolveUserAuthzGrantsOptions { * * ⛔ There is no "every organization" mode: no option selects one and nothing * falls back to one. A caller that has an organization passes it; a caller - * that has none gets the global grants and nothing else. + * that has none gets no stored grant at all. * * ⚠️ Not applied to §3's `sys_member` role projection, which reads the user's * OWN current memberships rather than a grant row. With no active organization @@ -858,8 +866,8 @@ export interface ResolveUserAuthzGrantsOptions { * CONFER arrives through §6a's per-organization position rows and §6's * organization-scoped grants, and both answer to this rule. */ -function grantAppliesInTenant(organizationId: unknown, tenantId: string | undefined): boolean { - return !organizationId || organizationId === tenantId; +function grantAppliesInTenant(organizationId: string | null, tenantId: string | undefined): boolean { + return organizationId !== null && organizationId === tenantId; } /** @@ -883,9 +891,10 @@ function grantSetNameOf(row: unknown): string | undefined { } /** - * 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. + * The organization a stored grant row belongs to, or `null` for one that names + * none — `NULL`, absent or blank. `null` confers nothing: neither + * {@link grantAppliesInTenant} nor {@link grantAnchorsPlatformStanding} ever + * answers yes for it. */ function grantOrganizationOf(row: unknown): string | null { if (!row || typeof row !== 'object') return null; @@ -904,19 +913,19 @@ function grantOrganizationOf(row: unknown): string | null { * config-derived (`OS_PLATFORM_OWNER_EMAIL`, §6b-config) and nothing else * (#11663 L5); * - `single`: the grant the first-user promotion writes, which is owned by the - * Default Organization ({@link readDefaultOrganizationId}), and the - * organization-less row that promotion wrote before it was. + * Default Organization ({@link readDefaultOrganizationId}). * - * Any other organization's grant of the set confers the set, never standing. + * Any other organization's grant of the set confers the set, never standing. An + * organization-less grant anchors nothing: the ownership ceremony (ADR-0131 + * D10) attributes the one an earlier release's promotion wrote to the Default + * Organization, so the reading that took it as the anchor retired with it. */ function grantAnchorsPlatformStanding( grant: unknown, anchor: { walled: boolean; defaultOrganizationId: string | undefined }, ): boolean { - if (anchor.walled) return false; - const organizationId = grantOrganizationOf(grant); - return organizationId === null - || (anchor.defaultOrganizationId !== undefined && organizationId === anchor.defaultOrganizationId); + if (anchor.walled || anchor.defaultOrganizationId === undefined) return false; + return grantOrganizationOf(grant) === anchor.defaultOrganizationId; } /** @@ -1528,12 +1537,12 @@ export async function resolveUserAuthzGrants( } // 4. [ADR-0057 D4] Platform-owned RBAC role assignments (sys_user_position) — the - // source of truth for custom roles, decoupled from sys_member.role. - // `organization_id = null` = global (cross-tenant); else match active org — - // so with no active org, only the global rows ({@link grantAppliesInTenant}). + // source of truth for custom roles, decoupled from sys_member.role. A row + // applies only in its own organization, so with no active org none does + // ({@link grantAppliesInTenant}). // (read in Leg 1 above) for (const ur of userPositionRows) { - if (!grantAppliesInTenant(ur.organization_id, tenantId)) continue; + if (!grantAppliesInTenant(grantOrganizationOf(ur), tenantId)) continue; if (!isGrantActive(ur, nowMs)) continue; const r = ur.position; if (typeof r === 'string' && r && !grants.positions.includes(r)) grants.positions.push(r); @@ -1551,8 +1560,9 @@ export async function resolveUserAuthzGrants( grants.org_user_ids = Array.from(ids); } - // 6. Permission sets — user-scoped grants (null org = global, else active org; - // with no active org, only the global rows — {@link grantAppliesInTenant}). + // 6. Permission sets — user-scoped grants, each applying only in its own + // organization; with no active org, none does ({@link grantAppliesInTenant}), + // the platform-standing anchor below aside. // Rows outside their validity window are dropped BEFORE any derivation, so // an expired admin_full_access grant cannot yield platform_admin either. // @@ -1578,10 +1588,9 @@ export async function resolveUserAuthzGrants( // resolves to: the `admin_full_access` grant `bootstrapPlatformAdmin` mints for // the first human user, which D5 has OWNED BY THE DEFAULT ORGANIZATION (#11979; // maintainer ruling A on #18707 keeps the zero-config promotion and moves only the - // row's owner), and the organization-less row that promotion wrote before it was — - // the row D10's ceremony attributes to that same organization. ⛔ Reading only the - // organization-less row would leave every promoted `single` rig with NO platform - // administrator once its grant is the Default Organization's. + // row's owner). The organization-less row an earlier release's promotion wrote is + // not read: D10's ceremony attributes it to that same organization before an + // upgraded database boots. // // The REQUESTED posture is the input, not the effective one — the same source and the // same fail-stricter direction the boot side reads: a deployment that ASKED for a wall @@ -1614,8 +1623,8 @@ export async function resolveUserAuthzGrants( // grants, and a held name of either type was already in the question. So // every envelope is unchanged, and so is every read's predicate: the ledger's // and the organization's filters, `grantAppliesInTenant` and - // `grantAnchorsPlatformStanding` (the organization-less readings) are called - // as they were, never edited. Pinned in + // `grantAnchorsPlatformStanding` are called exactly as the envelope-building + // pass below calls them. Pinned in // `resolve-authz-context.request-rounds.test.ts`. const mayApply = (r: unknown): boolean => grantAppliesInTenant(grantOrganizationOf(r), tenantId) @@ -1670,9 +1679,8 @@ export async function resolveUserAuthzGrants( const platformAnchor = { walled, defaultOrganizationId }; const anchorsPlatformStanding = (grant: unknown): boolean => grantSetNameOf(grant) === ADMIN_FULL_ACCESS && grantAnchorsPlatformStanding(grant, platformAnchor); - // An anchoring grant applies in whichever organization the principal acts in, as the - // organization-less row it replaces always did: platform standing is the deployment's, - // never one tenant's. + // An anchoring grant applies in whichever organization the principal acts in, and with + // none active: platform standing is the deployment's, never one tenant's. const applyingGrants = upsRows.filter( (r) => grantAppliesInTenant(grantOrganizationOf(r), tenantId) || anchorsPlatformStanding(r), ); 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 1f3138c933d..4e5c34febbf 100644 --- a/packages/core/src/security/resolve-user-grants-cache.test.ts +++ b/packages/core/src/security/resolve-user-grants-cache.test.ts @@ -132,14 +132,18 @@ type SeamQl = ReturnType; /** Fixed clock. Injected everywhere, so no pin sleeps and no pin races. */ const T = Date.UTC(2026, 5, 1); -/** A principal whose PLATFORM_ADMIN standing hangs on one revocable row. */ +/** + * A principal whose PLATFORM_ADMIN standing hangs on one revocable row: the + * Default Organization's `admin_full_access` grant, the `single` posture's anchor. + */ function adminTables(): Record { return { sys_user: [{ id: 'u1', email: 'u1@x.com' }], + sys_organization: [{ id: 'org_default', slug: 'default' }, { id: 'org_a', slug: 'org-a' }], 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', permission_set: 'admin_full_access', organization_id: null }, + { user_id: 'u1', permission_set_id: 'ps_admin', permission_set: 'admin_full_access', organization_id: 'org_default' }, ], sys_permission_set: [{ id: 'ps_admin', name: 'admin_full_access' }], sys_position: [], @@ -147,6 +151,23 @@ function adminTables(): Record { }; } +/** + * A principal holding one ordinary `org_a` grant and no `admin_full_access`. + * Pin 7 measures THIS module's footprint, so its principal must not reach the + * Default Organization read: that answer has its own per-engine memo (#24523), + * which wires its own write observer onto a seamful engine and serves the + * second resolution's `sys_organization` read — not the grants cache's doing. + */ +function memberTables(): Record { + return { + ...adminTables(), + sys_user_permission_set: [ + { user_id: 'u1', permission_set_id: 'ps_tools', permission_set: 'org_tools', organization_id: 'org_a' }, + ], + sys_permission_set: [{ id: 'ps_tools', name: 'org_tools', system_permissions: ['export_reports'] }], + }; +} + const OPTS: ResolveUserAuthzGrantsOptions = { tenantId: 'org_a', nowMs: T }; const TTL_ENV = 'OS_AUTHZ_GRANTS_CACHE_TTL_MS'; @@ -184,8 +205,10 @@ async function resolveExpectingZeroReads(ql: SeamQl, userId: string, opts: Resol describe('pin 7 — TTL 0 (the shipped default) is a real off path', () => { it('default config: resolves twice with the SAME query multiset, and wires NOTHING onto the engine', async () => { - const ql = makeSeamQl(adminTables()); + const ql = makeSeamQl(memberTables()); const first = await resolveUserAuthzGrants(ql, 'u1', OPTS); + // Not vacuous: the grant really resolved. + expect(first.permissions).toContain('org_tools'); const afterFirst = ql.calls.length; const second = await resolveUserAuthzGrants(ql, 'u1', OPTS); @@ -203,7 +226,7 @@ describe('pin 7 — TTL 0 (the shipped default) is a real off path', () => { it('explicit OS_AUTHZ_GRANTS_CACHE_TTL_MS=0 takes the same off path', async () => { process.env[TTL_ENV] = '0'; - const ql = makeSeamQl(adminTables()); + const ql = makeSeamQl(memberTables()); await resolveUserAuthzGrants(ql, 'u1', OPTS); const afterFirst = ql.calls.length; await resolveUserAuthzGrants(ql, 'u1', OPTS); @@ -287,7 +310,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', permission_set: 'admin_full_access', organization_id: null, + user_id: 'u1', permission_set_id: 'ps_admin', permission_set: 'admin_full_access', organization_id: 'org_default', }); const after = await resolveUserAuthzGrants(ql, 'u1', OPTS); @@ -550,7 +573,7 @@ describe('pin 9 — two nodes over one database', () => { // ── [#20515] the no-tenant rule holds with the cache ON ───────────────────── describe('[#20515] with the cache on, a no-tenant resolution never serves an organization\'s grants', () => { - /** One grant scoped to `org_a`, one global. */ + /** One grant scoped to `org_a`, one organization-less (which applies nowhere). */ function scopedTables(): Record { return { sys_user: [{ id: 'u1', email: 'u1@x.com' }], @@ -558,11 +581,11 @@ describe('[#20515] with the cache on, a no-tenant resolution never serves an org sys_user_position: [], sys_user_permission_set: [ { 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 }, + { user_id: 'u1', permission_set_id: 'ps_orgless', permission_set: 'orgless_readers', organization_id: null }, ], sys_permission_set: [ { id: 'ps_org', name: 'org_a_editors', system_permissions: ['manage_metadata'] }, - { id: 'ps_global', name: 'everyone_readers' }, + { id: 'ps_orgless', name: 'orgless_readers' }, ], sys_position: [], sys_position_permission_set: [], @@ -575,8 +598,11 @@ describe('[#20515] with the cache on, a no-tenant resolution never serves an org const inOrg = await resolveUserAuthzGrants(ql, 'u1', { tenantId: 'org_a', nowMs: T }); expect(inOrg.systemPermissions).toContain('manage_metadata'); + // The organization-less row applies in no organization, the active one included. + expect(inOrg.permissions).toEqual(['org_a_editors']); const orgless = await resolveUserAuthzGrants(ql, 'u1', { nowMs: T }); - expect(orgless.permissions).toEqual(['everyone_readers']); + // With no tenant, no stored grant applies — the organization-less row neither. + expect(orgless.permissions).toEqual([]); expect(orgless.systemPermissions).not.toContain('manage_metadata'); // Both entries are now cached, each under its own tenant — and each hit 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 2ece3a5c3f4..b4e96340ee6 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 @@ -18,8 +18,8 @@ * values are unconstrained, so a tenant can mint an ADR-0057 D4 row spelling * that built-in name, `resolveUserAuthzGrants` §4 pushes it into * `grants.positions`, and the third arm answered `true` while `grants.posture` - * — derived from the unscoped `admin_full_access` grant and nothing else — - * stayed `MEMBER`. + * — derived from the anchoring `admin_full_access` grant (the Default + * Organization's) and nothing else — stayed `MEMBER`. * * ⭐ WHAT THE ESCALATION BUYS, driven rather than argued: the platform arm of * `isOverrideActor` deliberately CROSSES THE TENANT WALL (the tenant-admin arm @@ -33,8 +33,10 @@ * (the console's participant-visibility read, `visibleRequestIds`), for a * NON-slate-holding actor in a DIFFERENT organization from the request, in * three shapes — a D4 row spelling the built-in name with no capability grant - * (`name-only`), a genuine unscoped `admin_full_access` grant (`genuine`), and - * a plain member (`plain`, the floor that proves the arms are not all passing + * (`name-only`), a genuine Default Organization `admin_full_access` grant + * (`genuine`), the same grant carrying NO organization (`orgless`, which + * anchors nothing — a grant applies only in its own organization), and a + * plain member (`plain`, the floor that proves the arms are not all passing * for some unrelated reason). The `name-only` and `genuine` contexts are built * by inserting rows and resolving them through the REAL * `resolveUserAuthzGrants`. @@ -150,10 +152,22 @@ function makeAuthzQl(tables: Record>>) { }, tables); } -type Shape = 'name-only' | 'genuine' | 'plain'; +type Shape = 'name-only' | 'genuine' | 'orgless' | 'plain'; +/** + * Every grant row is owned by `ATTACKER_ORG`, which is also the Default + * Organization: a grant applies only in its own organization (an + * organization-less row confers nothing), and platform standing is anchored + * only by the Default Organization's `admin_full_access` grant. The principal + * is resolved in `ATTACKER_ORG`, so the name-only position row applies — and + * the request under test still belongs to the OTHER organization. + */ function authzTables(shape: Shape) { return { + sys_organization: [ + { id: ATTACKER_ORG, slug: 'default' }, + { id: VICTIM_ORG, slug: 'victim' }, + ], sys_user: [{ id: ATTACKER, email: 'attacker@example.com', email_verified: true }], sys_member: [{ organization_id: ATTACKER_ORG, user_id: ATTACKER, role: 'member' }], sys_user_position: @@ -161,7 +175,7 @@ function authzTables(shape: Shape) { ? [ // Exactly what a tenant admin can write through the `apiEnabled` // `sys_user_position` surface: a row whose NAME is the built-in. - { user_id: ATTACKER, position: BUILTIN_IDENTITY_PLATFORM_ADMIN, organization_id: null }, + { user_id: ATTACKER, position: BUILTIN_IDENTITY_PLATFORM_ADMIN, organization_id: ATTACKER_ORG }, ] : [], // An ACTIVE catalogue row, so ADR-0049's deactivated-position filter is not @@ -172,8 +186,11 @@ function authzTables(shape: Shape) { : [], sys_position_permission_set: [], sys_user_permission_set: - shape === 'genuine' - ? [{ user_id: ATTACKER, permission_set_id: PS_ADMIN, permission_set: ADMIN_FULL_ACCESS, organization_id: null }] + shape === 'genuine' || shape === 'orgless' + ? [{ + user_id: ATTACKER, permission_set_id: PS_ADMIN, permission_set: ADMIN_FULL_ACCESS, + organization_id: shape === 'genuine' ? ATTACKER_ORG : null, + }] : [], sys_permission_set: [{ id: PS_ADMIN, name: ADMIN_FULL_ACCESS, active: true }], }; @@ -335,7 +352,7 @@ describe('[#15981] a D4 `sys_user_position` row spelling `platform_admin` confer }); }); -describe('[#15981] CONTROL — a genuine unscoped admin_full_access grant still overrides', () => { +describe('[#15981] CONTROL — a genuine Default Organization admin_full_access grant still overrides', () => { let engine: ReturnType; let svc: ApprovalService; let n = 0; @@ -404,4 +421,21 @@ describe('[#15981] CONTROL — a genuine unscoped admin_full_access grant still expect(escalation.rung).toBe(false); expect(genuine.rung).toBe(true); }); + + it('the same admin_full_access grant with NO organization anchors nothing, and decides nothing', async () => { + const orgless = await resolve('orgless'); + const req = await openVictimRequest(); + + expect(orgless.context.positions).not.toContain(BUILTIN_IDENTITY_PLATFORM_ADMIN); + expect(orgless.context.permissions).not.toContain(ADMIN_FULL_ACCESS); + expect(orgless.grants.posture).not.toBe('PLATFORM_ADMIN'); + expect(orgless.rung).toBe(false); + + await expect( + svc.decideNode(req.id, { decision: 'approve', actorId: ATTACKER }, orgless.context), + ).rejects.toThrow(/^FORBIDDEN/); + const [row] = await engine.find('sys_approval_request', { where: { id: req.id } }); + const actions = (await svc.listActions(req.id, SYS)).filter((a: any) => a.actor_id === ATTACKER); + expect({ status: row?.status, decided: actions.length > 0 }).toEqual({ status: 'pending', decided: false }); + }); }); 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 a6bb07affcc..5fc1512fa36 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 @@ -24,8 +24,8 @@ // // ── Why the admin is granted, never scalared ──────────────────────────────── // -// ⛔ The subject is made a platform admin the ADR-0068 D2 way — an unscoped -// `admin_full_access` grant — and the fixture ASSERTS the legacy scalar is +// ⛔ The subject is made a platform admin the ADR-0068 D2 way — the Default +// Organization's `admin_full_access` grant — and the fixture ASSERTS the legacy scalar is // absent. A `role = 'admin'` fixture would be answered `true` by the UNSHADED // vendor too: it passes with or without this card's change and measures // nothing (the `admin-impersonate-endpoint.test.ts` discipline). @@ -126,14 +126,16 @@ async function stage() { const users = (engine.tables.get('sys_user') ?? []) as any[]; const adminId = String(users.find((r) => r.email === 'admin.11900@example.com')!.id); - // The ADR-0068 D2 grant — an ORG-LESS `admin_full_access` link. ⛔ NOT the - // legacy scalar (see header). + // The ADR-0068 D2 grant — an `admin_full_access` link owned by the Default + // Organization, the only owner whose grant anchors platform standing. ⛔ NOT + // the legacy scalar (see header). + await engine.insert('sys_organization', { id: 'org_default', name: 'Default Organization', slug: 'default' }); await engine.insert('sys_permission_set', { id: PS_ADMIN, name: ADMIN_FULL_ACCESS }); await engine.insert('sys_user_permission_set', { user_id: adminId, permission_set_id: PS_ADMIN, permission_set: 'admin_full_access', - organization_id: null, + organization_id: 'org_default', }); const bearerFor = async (email: string) => { 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 b61e8e9a5a1..1b5286d5ce2 100644 --- a/packages/plugins/plugin-auth/src/admin-impersonate-endpoint.test.ts +++ b/packages/plugins/plugin-auth/src/admin-impersonate-endpoint.test.ts @@ -115,9 +115,20 @@ const userIdFor = (engine: any, email: string): string => { return String(row.id); }; +/** The Default Organization's id — the row slugged `default`, staged if absent. */ +const defaultOrganizationIdOf = async (engine: any): Promise => { + const existing = (engine.tables.get('sys_organization') ?? []).find((r: any) => r.slug === 'default'); + if (existing) return String(existing.id); + await engine.insert('sys_organization', { id: 'org_default', name: 'Default Organization', slug: 'default' }); + return 'org_default'; +}; + /** * Grant platform admin the ADR-0068 D2 way — a `sys_user_permission_set` row - * pointing at `admin_full_access` with `organization_id = null`. + * pointing at `admin_full_access`, owned by the Default Organization (the + * `sys_organization` row slugged `default`, staged here when the engine holds + * none). Only that organization's grant anchors platform standing; an + * organization-less one anchors nothing. * * ⛔ Deliberately NOT `row.role = 'admin'`. Writing that scalar is what the * 2026-08-18 ruling permanently vetoed, and a fixture that used it would be @@ -132,7 +143,7 @@ const grantPlatformAdmin = async (engine: any, userId: string) => { user_id: userId, permission_set_id: PS_ADMIN, permission_set: 'admin_full_access', - organization_id: null, + organization_id: await defaultOrganizationIdOf(engine), }); }; diff --git a/packages/plugins/plugin-auth/src/auth-manager.test.ts b/packages/plugins/plugin-auth/src/auth-manager.test.ts index ce39a81147c..cc689f88f43 100644 --- a/packages/plugins/plugin-auth/src/auth-manager.test.ts +++ b/packages/plugins/plugin-auth/src/auth-manager.test.ts @@ -3644,9 +3644,10 @@ describe('AuthManager', () => { }); describe('customSession – derived identity and positions array', () => { - // dataEngine stub: `adminLinks` controls whether the user resolves as a + // dataEngine stub: `platformAdmin` controls whether the user resolves as a // platform admin (a sys_user_permission_set row pointing at the - // admin_full_access permission set with no organization scope). + // admin_full_access permission set, owned by the Default Organization — + // the `sys_organization` row slugged `default` the stub serves). const makeDataEngine = (opts: { platformAdmin: boolean }) => bindTestSecurityCatalog({ // Two parameters because the seam has two: `IDataEngine.find(objectName, // query?, options?)`, and every production read reaching this double goes @@ -3656,12 +3657,15 @@ 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', permission_set: 'admin_full_access', organization_id: null }] + ? [{ user_id: 'u-1', permission_set_id: 'ps-admin', permission_set: 'admin_full_access', organization_id: 'org_default' }] : []; } if (object === 'sys_permission_set') { return [{ id: 'ps-admin', name: 'admin_full_access' }]; } + if (object === 'sys_organization') { + return [{ id: 'org_default', slug: 'default' }]; + } return []; }), findOne: vi.fn(), @@ -3753,15 +3757,16 @@ describe('AuthManager', () => { const inner = engine.find; engine.find = vi.fn(async (object: string, q?: any) => { if (object === 'sys_user_position') { - return [{ user_id: 'u-1', position: 'demo_reviewer', organization_id: null }]; + return [{ user_id: 'u-1', position: 'demo_reviewer', organization_id: 'o1' }]; } return inner(object, q); }) as any; + // The assignment applies in its own organization, so the session acts in it. const callback = await getSessionCallback(engine); const result = await callback({ user: { id: 'u-1', email: 'a@b.com', role: 'manager' }, - session: {}, + session: { activeOrganizationId: 'o1' }, }); expect(result.user.positions).toContain('demo_reviewer'); // Still not the scalar — the two axes do not blend. @@ -5066,26 +5071,33 @@ describe('isPlatformAdminUserId – the /sso/register criterion is platform-admi const SECRET = 'test-secret-at-least-32-chars-long'; /** - * Read-only engine stub. `platformAdmin` controls the ADR-0068 org-less - * `admin_full_access` link; `members` are `sys_member` rows, which this + * Read-only engine stub. `platformAdmin` controls the ADR-0068 + * `admin_full_access` link, owned by the Default Organization (`org-default`, + * slugged `default`) unless `grantOrganization` names another owner — `null` + * for an organization-less row; `members` are `sys_member` rows, which this * criterion must now ignore entirely. `find` honours the `where` the judge * passes, so any org-scoping is the product's, not the fixture's. */ - const makeEngine = (opts: { members?: any[]; platformAdmin?: boolean; throws?: boolean; orgScopedGrant?: boolean } = {}) => bindTestSecurityCatalog({ + const makeEngine = (opts: { members?: any[]; platformAdmin?: boolean; throws?: boolean; grantOrganization?: string | null } = {}) => bindTestSecurityCatalog({ find: vi.fn(async (object: string, query?: any) => { if (opts.throws) throw new Error('db down'); + const byWhere = (rows: any[]) => { + const where = query?.where ?? {}; + return rows.filter((row) => + Object.entries(where).every(([k, v]) => { if (k.startsWith('$')) throw new Error(`fake driver: unsupported operator ${k}`); return row[k] === v; }), + ); + }; if (object === 'sys_user_permission_set') { + const organization_id = opts.grantOrganization === undefined ? 'org-default' : opts.grantOrganization; return opts.platformAdmin - ? [{ user_id: 'u-1', permission_set_id: 'ps-admin', permission_set: 'admin_full_access', organization_id: opts.orgScopedGrant ? 'org-1' : null }] + ? [{ user_id: 'u-1', permission_set_id: 'ps-admin', permission_set: 'admin_full_access', organization_id }] : []; } if (object === 'sys_permission_set') return [{ id: 'ps-admin', name: 'admin_full_access' }]; - if (object === 'sys_member') { - const where = query?.where ?? {}; - return (opts.members ?? []).filter((row) => - Object.entries(where).every(([k, v]) => { if (k.startsWith('$')) throw new Error(`fake driver: unsupported operator ${k}`); return row[k] === v; }), - ); + if (object === 'sys_organization') { + return byWhere([{ id: 'org-default', slug: 'default' }, { id: 'org-1', slug: 'org-1' }]); } + if (object === 'sys_member') return byWhere(opts.members ?? []); return []; }), findOne: vi.fn(), @@ -5136,7 +5148,7 @@ describe('isPlatformAdminUserId – the /sso/register criterion is platform-admi // -- (2) the other direction: a real platform admin is still admitted ------ describe('the ADR-0068 platform admin is still ADMITTED', () => { - it('admits an org-less admin_full_access grant even when the membership is a plain member', async () => { + it('admits the Default Organization\'s admin_full_access grant even when the membership is a plain member', async () => { expect(await judge(makeEngine({ platformAdmin: true, members: [memberRow('member')] }))).toBe(true); }); @@ -5151,10 +5163,18 @@ describe('isPlatformAdminUserId – the /sso/register criterion is platform-admi expect(await judge(makeEngine({ members: [memberRow('member')] }))).toBe(false); }); - it('refuses when the admin_full_access grant is ORG-SCOPED, not platform-wide', async () => { - // `organization_id != null` is an org-scoped assignment — ADR-0068 D2 - // reads only the org-less link as platform admin. - expect(await judge(makeEngine({ platformAdmin: true, orgScopedGrant: true }))).toBe(false); + it('refuses when the admin_full_access grant is owned by ANOTHER organization, not the Default one', async () => { + // Any organization's grant other than the Default Organization's is an + // org-scoped assignment — it confers the set there, never platform standing. + expect(await judge(makeEngine({ platformAdmin: true, grantOrganization: 'org-1' }))).toBe(false); + }); + + it('refuses an ORGANIZATION-LESS admin_full_access grant — it anchors nothing', async () => { + // Invalid state since the ownership ceremony made the column NOT NULL; the + // reading that took it as the platform-wide link retired. The Default + // Organization's grant on the same engine is the control (admitted above). + expect(await judge(makeEngine({ platformAdmin: true, grantOrganization: null }))).toBe(false); + expect(await judge(makeEngine({ platformAdmin: true }))).toBe(true); }); it('refuses when the engine read throws (fail CLOSED)', async () => { 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 8888a0238d5..5929f16bf00 100644 --- a/packages/plugins/plugin-auth/src/organization-add-member.test.ts +++ b/packages/plugins/plugin-auth/src/organization-add-member.test.ts @@ -23,7 +23,7 @@ * would pass against a handler that refuses everyone. * * The platform admin here is REAL: `sys_permission_set`(`admin_full_access`) - * + an org-less `sys_user_permission_set` link — the exact rows the + * + a `sys_user_permission_set` link owned by the Default Organization — the exact rows the * customSession override derives `positions[]`/`isPlatformAdmin` from. No * `role = 'admin'` scalar is patched anywhere (re-synthesizing it is * permanently vetoed, maintainer ruling 2026-08-18). @@ -44,6 +44,7 @@ const BASE = 'http://localhost:3000'; const BASE_PATH = '/api/v1/auth'; const ORG_ACME = 'org_acme'; const ORG_BETA = 'org_beta'; +const ORG_DEFAULT = 'org_default'; const PASSWORD = 'S3cure!Passw0rd-9941'; /** @@ -243,16 +244,20 @@ describe('#9941 POST /organization/add-member — mounted, gated, and it clears orgOwner = await signUp(manager, engine, 'org.owner@example.com'); target = await signUp(manager, engine, 'existing.user@example.com'); - // The REAL ADR-0068 platform-admin signal: admin_full_access held with - // organization_id = null — the rows customSession derives positions[] - // from. NOT the legacy role scalar. + // The REAL ADR-0068 platform-admin signal: admin_full_access held by the + // Default Organization's grant — the rows customSession derives + // positions[] from. NOT the legacy role scalar, and not an + // organization-less row (that anchors nothing). The Default Organization + // is staged AFTER the sign-ups, so it is a third organization the admin is + // not a member of, and nothing above saw it. + await engine.insert('sys_organization', { id: ORG_DEFAULT, name: 'Default Organization', slug: 'default' }); await engine.insert('sys_permission_set', { id: 'ps_admin', name: 'admin_full_access' }); await engine.insert('sys_user_permission_set', { id: 'ups_admin', user_id: admin.userId, permission_set_id: 'ps_admin', permission_set: 'admin_full_access', - organization_id: null, + organization_id: ORG_DEFAULT, }); // Org-scoped actors on Acme: a plain member and an org OWNER — the owner 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 9d204e003c0..dae7031b345 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 @@ -211,14 +211,23 @@ interface StandingShape { deactivatedSet?: boolean; /** ADR-0091: the grant carries a `valid_until` already in the past. */ expiredGrant?: boolean; - /** ADR-0068 D2 says PLATFORM standing is the ORG-LESS link; this scopes it. */ - orgScopedGrant?: boolean; + /** + * Who owns the grant. PLATFORM standing is anchored only by the Default + * Organization's grant (the default here); `'other'` scopes it to another + * organization, `'none'` writes the retired organization-less row. The + * Default Organization is staged in every shape, so neither near-miss is + * refused merely for want of one. + */ + grantOwner?: 'default' | 'other' | 'none'; } +const ORG_DEFAULT = 'org_default'; + const seedPlatformAdmin = async (engine: any, userId: string, shape: StandingShape = {}) => { for (let i = 0; i < (shape.decoySets ?? 0); i += 1) { await engine.insert('sys_permission_set', { id: `ps_decoy_${i}`, name: `decoy_set_${i}` }); } + await engine.insert('sys_organization', { id: ORG_DEFAULT, name: 'Default Organization', slug: 'default' }); await engine.insert('sys_permission_set', { id: PS_ADMIN, name: ADMIN_FULL_ACCESS }); if (shape.deactivatedSet) { await engine.insert('sys_metadata_activation', { @@ -232,7 +241,7 @@ const seedPlatformAdmin = async (engine: any, userId: string, shape: StandingSha user_id: userId, permission_set_id: PS_ADMIN, permission_set: 'admin_full_access', - organization_id: shape.orgScopedGrant ? 'org_pin' : null, + organization_id: { default: ORG_DEFAULT, other: 'org_pin', none: null }[shape.grantOwner ?? 'default'], ...(shape.expiredGrant ? { valid_until: new Date(Date.now() - 60_000).toISOString() } : {}), }); }; @@ -335,7 +344,7 @@ afterAll(scrypt.restore); // ─────────────────────────────────────────────────────────────────────────── // PIN 1 — the genuine ADR-0068 D2 platform admin is admitted at BOTH gates. // ─────────────────────────────────────────────────────────────────────────── -describe.concurrent('a genuine platform admin (ADR-0068 D2 org-less admin_full_access) is ADMITTED', () => { +describe.concurrent('a genuine platform admin (ADR-0068 D2 admin_full_access, owned by the Default Organization) is ADMITTED', () => { it('/sso/register admits (and refuses the standing-less control on the same engine)', async () => { const { manager, callerBearer, targetBearer } = await arrange((e, id) => seedPlatformAdmin(e, id)); const v = await ssoVerdict(manager, callerBearer); @@ -365,7 +374,8 @@ describe.concurrent('an org owner / org admin / TENANT_ADMIN principal is NOT ad ['org owner (sys_member.role = owner)', (e, id) => seedOrgAdmin(e, id, 'owner')], ['org admin (sys_member.role = admin)', (e, id) => seedOrgAdmin(e, id, 'admin')], ['TENANT_ADMIN posture (organization_admin capability grant)', seedTenantAdmin], - ['an ORG-SCOPED admin_full_access grant', (e, id) => seedPlatformAdmin(e, id, { orgScopedGrant: true })], + ['an ORG-SCOPED admin_full_access grant', (e, id) => seedPlatformAdmin(e, id, { grantOwner: 'other' })], + ['an ORGANIZATION-LESS admin_full_access grant', (e, id) => seedPlatformAdmin(e, id, { grantOwner: 'none' })], ]; for (const [label, seed] of nearMisses) { 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 a10b7828435..6cd394d089f 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 @@ -195,10 +195,12 @@ const arrange = async (config: Record = {}) => { await engine.insert('sys_user_position', { user_id: memberId, position: 'reviewer', organization_id: ORG }); await engine.insert('sys_user_position', { user_id: leaverId, position: 'reviewer', organization_id: ORG }); - // The platform operator: an UNSCOPED admin_full_access user grant (the - // single-posture anchor; no tenancy posture is configured here). + // The platform operator: an admin_full_access user grant owned by the Default + // Organization (the single-posture anchor; no tenancy posture is configured + // here). The Default Organization is a third organization nobody is a member of. + await engine.insert('sys_organization', { id: 'org_default', name: 'Default Organization', slug: 'default' }); 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', permission_set: 'admin_full_access', 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: 'org_default' }); 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 0ef8353ddc7..29a352bd317 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 @@ -29,7 +29,8 @@ // capability grant. // // ⚠️ POPULATION OF THIS PIN, stated because a pin proves only what it covers: -// it covers the D4-spelled-built-in-name shape and a genuine-grant control. It +// it covers the D4-spelled-built-in-name shape, a genuine-grant control and the +// retired organization-less grant (which anchors nothing). It // does NOT cover the ADR-0091 validity window, the ADR-0049 catalogue flag, or // the catalogue-page shapes — those are `platform-admin-standing.consolidation. // test.ts`'s population, and that suite passing is NOT evidence about this one. @@ -100,17 +101,23 @@ const sessionFor = async (manager: AuthManager, bearer: string) => { /** * `shape: 'name-only'` — the escalation: a plain org member plus a D4 row - * SPELLING the built-in name, and no capability grant behind it. - * `shape: 'genuine'` — the control: a real unscoped `admin_full_access` grant. + * SPELLING the built-in name (in the member's own organization, where it + * applies), and no capability grant behind it. + * `shape: 'genuine'` — the control: a real `admin_full_access` grant owned by + * the Default Organization (`ORG`, slugged `default`) — the only grant that + * anchors platform standing. + * `shape: 'org-less'` — the retired reading: the same grant with NO + * organization, which anchors nothing. */ -const arrange = async (shape: 'name-only' | 'genuine') => { +type Shape = 'name-only' | 'genuine' | 'org-less'; +const arrange = async (shape: Shape) => { const engine = createMemoryEngine(); const manager = makeManager(engine); await signUp(manager, 'subject@example.com', 'Subject'); const userId = userIdFor(engine, 'subject@example.com'); - await engine.insert('sys_organization', { id: ORG, name: 'Rung Org', slug: 'rung-org' }); + await engine.insert('sys_organization', { id: ORG, name: 'Rung Org', slug: 'default' }); await engine.insert('sys_member', { organization_id: ORG, user_id: userId, role: 'member' }); if (shape === 'name-only') { @@ -120,7 +127,7 @@ const arrange = async (shape: 'name-only' | 'genuine') => { await engine.insert('sys_user_position', { user_id: userId, position: 'platform_admin', - organization_id: null, + organization_id: ORG, }); } else { await engine.insert('sys_permission_set', { id: PS_ADMIN, name: ADMIN_FULL_ACCESS }); @@ -128,7 +135,7 @@ const arrange = async (shape: 'name-only' | 'genuine') => { user_id: userId, permission_set_id: PS_ADMIN, permission_set: 'admin_full_access', - organization_id: null, + organization_id: shape === 'genuine' ? ORG : null, }); } @@ -137,7 +144,7 @@ const arrange = async (shape: 'name-only' | 'genuine') => { }; /** All three answers, read off one arranged principal. */ -const verdicts = async (shape: 'name-only' | 'genuine') => { +const verdicts = async (shape: Shape) => { const { engine, manager, userId, bearer } = await arrange(shape); const session = await sessionFor(manager, bearer); return { @@ -183,7 +190,7 @@ describe('a `sys_user_position` row spelling `platform_admin` confers NO platfor }); }); -describe('CONTROL — a genuine unscoped admin_full_access grant still admits', () => { +describe('CONTROL — a genuine Default Organization admin_full_access grant still admits', () => { it('all three answers are TRUE, and they agree with each other', async () => { const v = await verdicts('genuine'); expect( @@ -198,6 +205,24 @@ describe('CONTROL — a genuine unscoped admin_full_access grant still admits', }); }); +describe('an ORGANIZATION-LESS admin_full_access grant confers NO platform standing', () => { + it('all three answers are FALSE — and the Default Organization grant on the same rig is TRUE', async () => { + const v = await verdicts('org-less'); + expect( + { alias: v.alias, gate: v.gate, rung: v.rung }, + `positions=${JSON.stringify(v.positions)}`, + ).toEqual({ alias: false, gate: false, rung: false }); + expect(v.positions).not.toContain('platform_admin'); + + const control = await verdicts('genuine'); + expect({ alias: control.alias, gate: control.gate, rung: control.rung }).toEqual({ + alias: true, + gate: true, + rung: true, + }); + }); +}); + describe('the two shapes are INDISTINGUISHABLE by name and separable only by the rung', () => { it('both carry `platform_admin` in positions[]; only the granted one has standing', async () => { const escalation = await verdicts('name-only'); diff --git a/packages/plugins/plugin-auth/src/session-positions-security-axis.test.ts b/packages/plugins/plugin-auth/src/session-positions-security-axis.test.ts index e9f222794d2..b74b593418a 100644 --- a/packages/plugins/plugin-auth/src/session-positions-security-axis.test.ts +++ b/packages/plugins/plugin-auth/src/session-positions-security-axis.test.ts @@ -155,14 +155,15 @@ const arrange = async (opts: { assignPosition?: boolean } = {}) => { await engine.insert('sys_member', { organization_id: ORG, user_id: bystanderId, role: 'member' }); // The security-layer position, and the ADR-0057 D4 assignment row that is its - // source of truth. `organization_id: null` = a global assignment, so the - // fixture does not also depend on the session carrying an active org. + // source of truth. The assignment is owned by `ORG` — a grant applies only in + // its own organization — and the holder's session acts in `ORG` (stamped + // from the membership above at sign-in). await engine.insert('sys_position', { id: 'pos_reviewer', name: POSITION, label: 'Demo Reviewer' }); if (opts.assignPosition !== false) { await engine.insert('sys_user_position', { user_id: holderId, position: POSITION, - organization_id: null, + organization_id: ORG, }); } 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 46b4de6031e..a38d8535d0c 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 @@ -38,7 +38,7 @@ * * The platform admin is made one the way a real deployment does it — a * `sys_user_permission_set` row pointing at the `admin_full_access` - * `sys_permission_set`, with `organization_id = null` — and the case ASSERTS + * `sys_permission_set`, owned by the Default Organization — and the case ASSERTS * that the legacy `sys_user.role` scalar is NOT `'admin'`. Without that second * assertion the suite could pass while riding the retired D2 channel, which is * precisely the channel this family is closing; the pin would then survive the @@ -187,8 +187,8 @@ async function bootEngine(): Promise { engine.registry.registerObject(sysPosition as never, '@objectstack/plugin-auth'); engine.registry.registerObject(sysUserPosition as never, '@objectstack/plugin-auth'); await engine.syncSchemas(); - // [ADR-0131 D3/D4] The platform's `admin_full_access` definition, which an - // org-less grant of it resolves through (no security plugin binds one here). + // [ADR-0131 D3/D4] The platform's `admin_full_access` definition, which a + // grant of it resolves through (no security plugin binds one here). return bindTestSecurityCatalog(engine, { permissions: [{ name: ADMIN_FULL_ACCESS, ...ADMIN_FULL_ACCESS_CAPABILITIES }] }); } @@ -249,11 +249,22 @@ async function createOrg( return id; } -/** Grant platform admin the ADR-0068 D2 way: an ORG-LESS `admin_full_access` link. */ +/** + * Grant platform admin the ADR-0068 D2 way: an `admin_full_access` link owned by + * the Default Organization — the only grant that anchors platform standing (an + * organization-less one anchors nothing). The Default Organization is written + * through the engine, so the resolver's Default Organization answer sees it. + */ async function grantPlatformAdmin(engine: ObjectQL, userId: string): Promise { - // [C8 stage 8b] The legacy organization-less catalogue row and grant, landed - // beneath the engine: under `single` the engine refuses an organization-less - // platform insert while the install holds no organization (ADR-0131 D9). + await engine.insert( + 'sys_organization', + { id: 'org_default', name: 'Default Organization', slug: 'default' }, + SYSTEM as never, + ); + // [C8 stage 8b] The legacy organization-less catalogue row, and the grant, + // landed beneath the engine as before: under `single` the engine refuses an + // organization-less platform insert while the install holds no organization + // (ADR-0131 D9). const residue = (object: string, row: Record) => (engine as any).getDriver(object).create(object, row); await residue('sys_permission_set', { id: 'ps_admin_full_access', name: ADMIN_FULL_ACCESS }); @@ -262,7 +273,7 @@ async function grantPlatformAdmin(engine: ObjectQL, userId: string): Promise { return String(row.id); }; -/** ADR-0068 D2 standing — the unscoped `admin_full_access` grant, never the legacy `role` scalar. */ +/** The Default Organization (the `sys_organization` row slugged `default`), staged when the engine holds none. */ +const defaultOrganizationIdOf = async (engine: any): Promise => { + const existing = (engine.tables.get('sys_organization') ?? []).find((r: any) => r.slug === 'default'); + if (existing) return String(existing.id); + await engine.insert('sys_organization', { id: 'org_default', name: 'Default Organization', slug: 'default' }); + return 'org_default'; +}; + +/** + * ADR-0068 D2 standing — the Default Organization's `admin_full_access` grant + * (ADR-0131 D5), never the legacy `role` scalar. An organization-less grant + * anchors nothing. + */ const grantPlatformAdmin = async (engine: any, userId: string) => { if (!(engine.tables.get('sys_permission_set') ?? []).some((r: any) => r.id === PS_ADMIN)) { await engine.insert('sys_permission_set', { id: PS_ADMIN, name: ADMIN_FULL_ACCESS }); @@ -96,7 +108,7 @@ const grantPlatformAdmin = async (engine: any, userId: string) => { user_id: userId, permission_set_id: PS_ADMIN, permission_set: 'admin_full_access', - organization_id: null, + organization_id: await defaultOrganizationIdOf(engine), }); }; diff --git a/packages/plugins/plugin-auth/src/vendor-admin-refusal-envelope.test.ts b/packages/plugins/plugin-auth/src/vendor-admin-refusal-envelope.test.ts index 5b2e8267b07..ae619b63404 100644 --- a/packages/plugins/plugin-auth/src/vendor-admin-refusal-envelope.test.ts +++ b/packages/plugins/plugin-auth/src/vendor-admin-refusal-envelope.test.ts @@ -249,13 +249,15 @@ describe('#10349 — through AuthManager.handleRequest on the real vendor pipeli const adminId = idFor('padmin.10349@example.com'); const targetId = idFor('target.10349@example.com'); - // ADR-0068 D2 grant — deliberately NOT the legacy `role` scalar. + // ADR-0068 D2 grant — deliberately NOT the legacy `role` scalar — owned by + // the Default Organization, the only owner whose grant anchors standing. + await engine.insert('sys_organization', { id: 'org_default', name: 'Default Organization', slug: 'default' }); await engine.insert('sys_permission_set', { id: PS_ADMIN, name: ADMIN_FULL_ACCESS }); await engine.insert('sys_user_permission_set', { user_id: adminId, permission_set_id: PS_ADMIN, permission_set: 'admin_full_access', - organization_id: null, + organization_id: 'org_default', }); const signIn = await post(manager, '/sign-in/email', { 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 3f7b5da4f06..df0ab4aee27 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 @@ -19,7 +19,8 @@ // halves: the issue's own repro (a position-bound set surfaces), and the // semantics that come free with the delegation and would have to be re-written // by hand otherwise — the implicit `everyone` anchor (ADR-0090 D5), null-org = -// global, active-org matching, and the ADR-0091 validity window. +// nowhere (a grant applies only in its own organization), active-org matching, +// and the ADR-0091 validity window. // // Every negative case here carries a co-present VALID grant and asserts it // surfaced. A negative asserted on its own would pass in the pre-fix world for @@ -259,17 +260,24 @@ describe('position-bound permission sets reach /auth/me/permissions (#6334)', () expect(body.positions).toContain('org_member'); }); - it('treats a null-org position row as global (resolves under any active org)', async () => { + it('drops a null-org position row (an organization-less grant applies nowhere), keeping the active-org one', async () => { const app = mount({ tables: baseTables({ - sys_user_position: [{ id: 'up1', user_id: USER, position: 'ops', organization_id: null }], + sys_user_position: [ + { id: 'up1', user_id: USER, position: 'ops', organization_id: null }, + { id: 'up2', user_id: USER, position: 'auditor', organization_id: ACTIVE_ORG }, + ], }), }); const body = await permissionsOf(app); - expect(body.positions).toContain('ops'); - expect(body.systemPermissions).toContain('showcase.export_data'); + // The co-present valid grant is asserted so the negative below cannot + // pass merely because nothing resolved. + expect(body.positions).toContain('auditor'); + expect(body.systemPermissions).toContain('showcase.audit_read'); + expect(body.positions).not.toContain('ops'); + expect(body.systemPermissions).not.toContain('showcase.export_data'); }); it('drops a position row scoped to another organization, keeping the active-org one', async () => { 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 96c69a3fea0..d2b8d73f6a6 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 @@ -20,16 +20,18 @@ import type { PluginContext } from '@objectstack/core'; import { bindTestSecurityCatalog } from './__tests__/security-catalog.testkit'; /** - * Fake data engine: an UNSCOPED `admin_full_access` grant for `admin1` (the - * seeded platform admin) vs. a `member_default` grant for `member1`, plus the - * `widget` rows the data route returns. Every other read resolves empty. + * Fake data engine: an `admin_full_access` grant owned by the Default + * Organization for `admin1` (the seeded platform admin — the only grant that + * anchors platform standing) vs. a `member_default` grant for `member1`, plus + * the Default Organization row and the `widget` rows the data route returns. + * Every other read resolves empty. */ const makeQl = () => bindTestSecurityCatalog({ 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', 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 }]; + if (where.user_id === 'admin1') return [{ permission_set_id: 'ps_admin', permission_set: 'admin_full_access', organization_id: 'org_default' }]; + if (where.user_id === 'member1') return [{ permission_set_id: 'ps_member', permission_set: 'member_default', organization_id: 'org_default' }]; return []; } if (object === 'sys_permission_set') { @@ -41,6 +43,8 @@ const makeQl = () => bindTestSecurityCatalog({ ids.includes('ps_member') || names.includes('member_default') ? { id: 'ps_member', name: 'member_default' } : null, ].filter(Boolean); } + // The Default Organization — by slug, or as the only organization. + if (object === 'sys_organization') return [{ id: 'org_default', slug: 'default' }]; if (object === 'widget') return [{ id: '1', name: 'w' }]; // sys_member, sys_user — nothing to contribute. return []; diff --git a/packages/plugins/plugin-security/src/bootstrap-platform-admin-existing-holder-scan.test.ts b/packages/plugins/plugin-security/src/bootstrap-platform-admin-existing-holder-scan.test.ts index cbf1a3b10b9..c8db8e5b254 100644 --- a/packages/plugins/plugin-security/src/bootstrap-platform-admin-existing-holder-scan.test.ts +++ b/packages/plugins/plugin-security/src/bootstrap-platform-admin-existing-holder-scan.test.ts @@ -30,7 +30,7 @@ * * | fixture | on the base | after the fix | * |---|---|---| - * | 60 org-scoped grants + 1 unscoped human grant sorting LAST (61 rows) | `adminPromoted: true`, **two** unscoped grant rows | `already_have_admin`, **one** unscoped grant row | + * | 60 org-scoped grants + 1 anchoring human grant sorting LAST (61 rows) | `adminPromoted: true`, **two** anchoring grant rows | `already_have_admin`, **one** anchoring grant row | * | the SAME fixture with 9 org-scoped grants (10 rows, under the cap) | `already_have_admin` | `already_have_admin` — unchanged | * * ⛔ The 60-row failure on its own would be half a test. The 10-row row is the @@ -41,19 +41,23 @@ * Both rows were predicted in writing first and then observed. The measured * base run is quoted on the PR. * - * ## Why the fix is TWO reads, and why this file pins the second one + * ## Whose grant is the anchor, and what the two reads ask ([ADR-0131] D5) * - * The card's suggested `organization_id: null` in the `where` was measured - * before it was taken. Null matching IS uniform across the driver families - * that can be measured — but the narrowed read is NOT equivalent to the - * predicate this code applies. `organization_id: ''` is storable and reads - * back as `''`: `!organization_id` counts it UNSCOPED, `where: { - * organization_id: null }` does not return it. A narrowing that REPLACED the - * client-side predicate would therefore have stopped seeing a legacy unscoped - * holder stored that way — firing the guard LESS often and minting exactly the - * second grant this card is about. ⛔ This card only tightens, so the - * `''`-shaped holder has its own case below: it is the one the ordered, - * bounded scan exists for. + * The anchoring grant is the one the DEFAULT ORGANIZATION owns — the row a + * `single` promotion writes, and the only one the derivation site reads. Leg A + * asks the driver for exactly that, ONCE: `{ permission_set: + * 'admin_full_access', organization_id: }`. Leg B, + * only when leg A found nobody, is the ordered, bounded scan of every grant of + * the set, applying the same predicate client-side. + * + * An ORGANIZATION-LESS row (`null`, or the `''` shape both SQL families store) + * is never a holder: the 18.0 ownership ceremony (ADR-0131 D10) attributes every + * such row to an organization before an upgraded database boots, and a fresh + * install never writes one. This card's earlier revision pinned the opposite — + * leg A asking a second time for the `null` row, and leg B still seeing the + * `''`-shaped one; both cases below are rewritten to the rule that replaced it. + * Where no Default Organization can be named — under a wall, or with no + * organization to name — the scan asks the grant table NOTHING. * * ## Why the row ORDER is permuted rather than a second driver package * @@ -87,6 +91,8 @@ const SYSTEM_CTX = { isSystem: true }; const OWNER_ENV = 'OS_PLATFORM_OWNER_EMAIL'; /** The permission-set id the fixture pre-seeds, so grant rows can point at it. */ const ADMIN_PS_ID = 'ps_admin_full'; +/** [ADR-0131 D5] The Default Organization {@link boot} creates — the anchoring grant's owner. */ +const DEFAULT_ORG = 'org_default'; const engines: ObjectQL[] = []; @@ -212,25 +218,26 @@ async function seedUser( /** * The card's population: a tenant whose `admin_full_access` rows are dominated - * by ORGANIZATION-scoped grants, plus the one unscoped human holder that IS its - * platform admin. + * by OTHER organizations' grants, plus the one Default Organization grant whose + * human holder IS its platform admin. * - * The unscoped grant row's id collates AFTER every org-scoped one, so the real + * The anchoring grant row's id collates AFTER every other one, so the real * driver's own unordered window is exactly the window the card describes: full - * of rows that fail `!organization_id`, with the row that decides outside it. + * of rows that fail the anchor predicate, with the row that decides outside it. * * `usr_orgadmin_001` is the OLDEST authenticable human — an org admin imported * before the founder signed up, which is what a tenant with sixty org admins * actually looks like. So when the guard fails, the promotion does not merely * duplicate the founder's grant: it hands platform admin to an ORG admin. * - * @param orgGrants how many organization-scoped grants of the same set exist - * @param unscopedOrganizationId the value the founder's unscoped grant stores + * @param orgGrants how many other organizations' grants of the same set exist + * @param founderOrganizationId the value the founder's grant stores — the + * Default Organization (the anchor), or an organization-less legacy shape */ async function seedTenant( ql: any, orgGrants: number, - unscopedOrganizationId: null | '' = null, + founderOrganizationId: typeof DEFAULT_ORG | null | '' = DEFAULT_ORG, ): Promise { await ql.insert( 'sys_permission_set', @@ -241,17 +248,18 @@ async function seedTenant( // The founder — this deployment's platform admin, and the row the guard has // to find. Inserted FIRST so the insertion-order family puts it in row 1. await seedUser(ql, 'usr_founder', 'founder@tenant.example', '2026-01-01T00:00:00.000Z', true); - // [C8 stage 8b] The unscoped grant is legacy residue, landed straight through - // the driver: under `single` the engine stamps an organization-less platform - // insert with the Default Organization (ADR-0131 D9), which is where 8b-0a - // already puts a new promotion's grant. An unranked row sorts first in the - // insertion-order family, as it did when it was inserted first. + // [C8 stage 8b] Landed straight through the driver, so the value stored is + // exactly `founderOrganizationId`: under `single` the engine would stamp an + // organization-less platform insert with the Default Organization (ADR-0131 + // D9), and the organization-less shapes are what the negative case needs. + // An unranked row sorts first in the insertion-order family, as it did when + // it was inserted first. const real: any = ENGINE_OF.get(ql) ?? ql; await real.getDriver('sys_user_permission_set').create('sys_user_permission_set', { id: 'ups_zzz_founder', user_id: 'usr_founder', permission_set: 'admin_full_access', - organization_id: unscopedOrganizationId, + organization_id: founderOrganizationId, }); for (let i = 1; i <= orgGrants; i++) { @@ -281,11 +289,11 @@ async function findRows(engine: ObjectQL, object: string, where: any = {}): Prom return Array.isArray(rows) ? rows : []; } -/** Every `admin_full_access` grant row that no organization scopes. */ -async function unscopedGrants(engine: ObjectQL): Promise { +/** Every `admin_full_access` grant row the Default Organization owns — the anchoring ones. */ +async function anchorGrants(engine: ObjectQL): Promise { const links = await findRows(engine, 'sys_user_permission_set', { permission_set: 'admin_full_access' }); expect(links.length, 'ANTI-VACUITY: the fixture must have written grant rows').toBeGreaterThan(0); - return links.filter((r) => !r.organization_id); + return links.filter((r) => r.organization_id === DEFAULT_ORG); } function collectingLogger() { @@ -304,12 +312,102 @@ function collectingLogger() { }; } +/** + * A synthetic grant population — the one case whose subject is a row COUNT + * larger than any fixture worth storing. Every row is ORGANIZATION-scoped, + * which is the shape that fills the window in the first place, and the + * generator serves `offset`/`limit` so the paging is exercised for real. + */ +function makeSyntheticQl( + grantCount: number, + organizations: any[] = [{ id: DEFAULT_ORG, slug: 'default' }], +) { + const permissionSets: any[] = []; + const inserted: any[] = []; + /** The `where` of every read of the grant table, in order. */ + const grantReads: Record[] = []; + return { + inserted, + grantReads, + async find(object: string, q: any) { + const bound = (rows: any[]) => + typeof q?.limit === 'number' ? rows.slice(0, q.limit) : rows; + const where = q?.where ?? {}; + if (object === 'sys_permission_set') { + return bound( + permissionSets.filter((r) => + Object.entries(where).every(([k, v]) => { + // Refuse loudly rather than reading a combinator as a field + // name — a matcher that answers `false` for `$or` reports a + // row it never understood as absent. + if (k.startsWith('$')) throw new Error(`fake driver: unsupported operator ${k}`); + return r[k] === v; + }), + ), + ); + } + for (const k of Object.keys(where)) { + if (k.startsWith('$')) throw new Error(`fake driver: unsupported operator ${k}`); + } + if (object === 'sys_user_permission_set') { + grantReads.push(where); + // Every synthetic row is another organization's, so the narrow leg + // (the Default Organization's grants) legitimately answers with + // nothing. + if ('organization_id' in where && where.organization_id === DEFAULT_ORG) return []; + if ('organization_id' in where) throw new Error(`fake driver: unexpected organization_id ${String(where.organization_id)}`); + const offset = q?.offset ?? 0; + const limit = q?.limit ?? 100; + const out: any[] = []; + for (let i = offset; i < Math.min(offset + limit, grantCount); i++) { + out.push({ + id: `ups_${String(i).padStart(6, '0')}`, + user_id: `usr_${String(i).padStart(6, '0')}`, + permission_set: 'admin_full_access', + organization_id: `org_${i}`, + }); + } + return out; + } + if (object === 'sys_user') { + if (Object.keys(where).length > 0) return []; + const offset = q?.offset ?? 0; + return offset === 0 + ? [{ id: 'usr_promotable', email: 'p@demo.example', created_at: '2026-01-01T00:00:00.000Z' }] + : []; + } + if (object === 'sys_account') return [{ id: 'acc_p', user_id: 'usr_promotable' }]; + // [ADR-0131 D5] The Default Organization, which owns the promotion's grant. + if (object === 'sys_organization') { + return bound( + organizations.filter((r) => + Object.entries(where).every(([k, v]) => { + if (k.startsWith('$')) throw new Error(`fake driver: unsupported operator ${k}`); + return r[k] === v; + }), + ), + ); + } + return []; + }, + async insert(object: string, data: any) { + if (object === 'sys_permission_set') permissionSets.push({ ...data }); + inserted.push({ object, data }); + return { id: data.id }; + }, + async update(_object: string, data: any, options?: any) { + assertEngineUpdateDispatch(data, options); + return 0; + }, + }; +} + describe('#16861 — an existing platform admin is found, not sampled for', () => { // ───────────────────────────────────────────────────────────────────────── // ANTI-VACUITY: the truncated window really is driver-shaped // ───────────────────────────────────────────────────────────────────────── - it('ANTI-VACUITY: the old unordered 50-row read really does hide the unscoped grant', async () => { + it('ANTI-VACUITY: the old unordered 50-row read really does hide the anchoring grant', async () => { // Without this the 60-row case could be green because the fixture happens // to be shaped some other way, rather than because the guard was repaired. const engine = await boot(); @@ -328,9 +426,9 @@ describe('#16861 — an existing platform admin is found, not sampled for', () = expect(window50).toHaveLength(50); expect(window50.some((r: any) => r.id === 'ups_zzz_founder')).toBe(false); // …and every row it DID return fails the predicate that decides. - expect(window50.every((r: any) => !!r.organization_id)).toBe(true); + expect(window50.every((r: any) => !!r.organization_id && r.organization_id !== DEFAULT_ORG)).toBe(true); // The row that decides exists all the same. - expect((await unscopedGrants(engine)).map((r) => r.id)).toEqual(['ups_zzz_founder']); + expect((await anchorGrants(engine)).map((r) => r.id)).toEqual(['ups_zzz_founder']); }); // ───────────────────────────────────────────────────────────────────────── @@ -353,8 +451,8 @@ describe('#16861 — an existing platform admin is found, not sampled for', () = expect(report.adminPromoted).toBe(false); expect(report.reason).toBe('already_have_admin'); - // ⭐ The harm, stated as the card states it: no SECOND unscoped grant. - expect((await unscopedGrants(engine)).map((r) => r.id)).toEqual(['ups_zzz_founder']); + // ⭐ The harm, stated as the card states it: no SECOND anchoring grant. + expect((await anchorGrants(engine)).map((r) => r.id)).toEqual(['ups_zzz_founder']); // …and nothing was re-owned, because nothing was promoted. expect(info.join('\n')).not.toContain('promoted to platform admin'); }); @@ -363,25 +461,38 @@ describe('#16861 — an existing platform admin is found, not sampled for', () = }); // ───────────────────────────────────────────────────────────────────────── - // 2. The leg the `where` narrowing alone could not have covered + // 2. An organization-less row is never a holder, in either stored shape // ───────────────────────────────────────────────────────────────────────── - it("a legacy holder storing organization_id '' is still seen — the fix only tightens", async () => { - // Measured on both SQL families through ObjectQL and these real objects: - // `organization_id: ''` is storable, reads back as `''`, is UNSCOPED to - // `!organization_id`, and is NOT returned by `where: { organization_id: - // null }`. So the ordered bounded scan — not the narrowed read — is what - // answers here, and dropping it would have RELAXED this guard. - const engine = await boot(); - const ql = withNaturalOrder(engine, 'AS_RETURNED'); - await seedTenant(ql, 60, ''); - - const { logger } = collectingLogger(); - const report = await bootstrapPlatformAdmin(ql, defaultPermissionSets, { logger }); - - expect(report.reason).toBe('already_have_admin'); - expect((await unscopedGrants(engine)).map((r) => r.id)).toEqual(['ups_zzz_founder']); - }); + for (const [shape, organizationLess] of [['null', null], ["''", '']] as const) { + it(`an organization-less grant (organization_id ${shape}) is never a holder — the promotion proceeds`, async () => { + // What this proves: the scan reads the founder's row and REFUSES it — leg + // B examines every one of the 61 rows, the organization-less one among + // them — and the deployment is promoted exactly as one with no grant at + // all would be. ⛔ An organization-less `admin_full_access` row anchors + // nothing (ADR-0131 D5/D10): the 18.0 ceremony attributes it to an + // organization before an upgraded database boots. The CONTROL is the + // card's cell above — the same fixture with the founder's grant owned by + // the Default Organization answers `already_have_admin`. + const engine = await boot(); + const ql = withNaturalOrder(engine, 'AS_RETURNED'); + await seedTenant(ql, 60, organizationLess); + + const { logger } = collectingLogger(); + const report = await bootstrapPlatformAdmin(ql, defaultPermissionSets, { logger }); + + expect(report.adminGrantRowsExamined).toBe(61); + expect(report.adminPromoted).toBe(true); + // The age rule answers, as on a fresh install: the oldest authenticable + // human, who is not the founder (the fixture's seconds wrap at 60, so + // that is `usr_orgadmin_060`, created at second :00). + expect(report.adminUserId).toBe('usr_orgadmin_060'); + expect((await anchorGrants(engine)).map((r) => r.user_id)).toEqual(['usr_orgadmin_060']); + // The organization-less row itself is left exactly as it was. + const [legacy] = await findRows(engine, 'sys_user_permission_set', { id: 'ups_zzz_founder' }); + expect(legacy?.organization_id).toBe(organizationLess); + }); + } // ───────────────────────────────────────────────────────────────────────── // 3. The guard says how many rows it examined @@ -397,7 +508,7 @@ describe('#16861 — an existing platform admin is found, not sampled for', () = expect(report.reason).toBe('already_have_admin'); // The narrow leg answers this fixture on its own, so the number is the - // unscoped population — not "up to 50, whichever the driver produced first". + // anchoring population — not "up to 50, whichever the driver produced first". expect(report.adminGrantRowsExamined).toBe(1); }); @@ -417,7 +528,7 @@ describe('#16861 — an existing platform admin is found, not sampled for', () = // 4. The system row still never counts — the fix did not over-tighten // ───────────────────────────────────────────────────────────────────────── - it('an unscoped grant held by usr_system does NOT block promotion', async () => { + it('an anchoring grant held by usr_system does NOT block promotion', async () => { // Self-healing on restart: a DB where the seed identity was wrongly // promoted must not block every real admin forever. The scan sees the row // and the predicate still refuses it. @@ -430,7 +541,7 @@ describe('#16861 — an existing platform admin is found, not sampled for', () = ); await ql.insert( 'sys_user_permission_set', - { id: 'ups_system', user_id: 'usr_system', permission_set: 'admin_full_access', organization_id: null }, + { id: 'ups_system', user_id: 'usr_system', permission_set: 'admin_full_access', organization_id: DEFAULT_ORG }, { context: SYSTEM_CTX }, ); await seedUser(ql, 'usr_real', 'real@tenant.example', '2026-03-01T00:00:00.000Z', true); @@ -447,79 +558,6 @@ describe('#16861 — an existing platform admin is found, not sampled for', () = // ───────────────────────────────────────────────────────────────────────── describe('the grant scan is bounded but never silent', () => { - /** - * A synthetic grant population — the one case whose subject is a row COUNT - * larger than any fixture worth storing. Every row is ORGANIZATION-scoped, - * which is the shape that fills the window in the first place, and the - * generator serves `offset`/`limit` so the paging is exercised for real. - */ - function makeSyntheticQl(grantCount: number) { - const permissionSets: any[] = []; - const inserted: any[] = []; - return { - inserted, - async find(object: string, q: any) { - const bound = (rows: any[]) => - typeof q?.limit === 'number' ? rows.slice(0, q.limit) : rows; - const where = q?.where ?? {}; - if (object === 'sys_permission_set') { - return bound( - permissionSets.filter((r) => - Object.entries(where).every(([k, v]) => { - // Refuse loudly rather than reading a combinator as a field - // name — a matcher that answers `false` for `$or` reports a - // row it never understood as absent. - if (k.startsWith('$')) throw new Error(`fake driver: unsupported operator ${k}`); - return r[k] === v; - }), - ), - ); - } - for (const k of Object.keys(where)) { - if (k.startsWith('$')) throw new Error(`fake driver: unsupported operator ${k}`); - } - if (object === 'sys_user_permission_set') { - // Every synthetic row is another organization's, so the narrow leg - // (`organization_id: null`, and the Default Organization's) - // legitimately answers with nothing. - if ('organization_id' in where && (where.organization_id === null || where.organization_id === 'org_default')) return []; - const offset = q?.offset ?? 0; - const limit = q?.limit ?? 100; - const out: any[] = []; - for (let i = offset; i < Math.min(offset + limit, grantCount); i++) { - out.push({ - id: `ups_${String(i).padStart(6, '0')}`, - user_id: `usr_${String(i).padStart(6, '0')}`, - permission_set: 'admin_full_access', - organization_id: `org_${i}`, - }); - } - return out; - } - if (object === 'sys_user') { - if (Object.keys(where).length > 0) return []; - const offset = q?.offset ?? 0; - return offset === 0 - ? [{ id: 'usr_promotable', email: 'p@demo.example', created_at: '2026-01-01T00:00:00.000Z' }] - : []; - } - if (object === 'sys_account') return [{ id: 'acc_p', user_id: 'usr_promotable' }]; - // [ADR-0131 D5] The Default Organization, which owns the promotion's grant. - if (object === 'sys_organization') return [{ id: 'org_default', slug: 'default' }]; - return []; - }, - async insert(object: string, data: any) { - if (object === 'sys_permission_set') permissionSets.push({ ...data }); - inserted.push({ object, data }); - return { id: data.id }; - }, - async update(_object: string, data: any, options?: any) { - assertEngineUpdateDispatch(data, options); - return 0; - }, - }; - } - it('warns, naming the ceiling and the number examined, when the scan stops short', async () => { const ql = makeSyntheticQl( PLATFORM_ADMIN_GRANT_SCAN_CEILING + PLATFORM_ADMIN_GRANT_PAGE_SIZE, @@ -554,4 +592,56 @@ describe('#16861 — an existing platform admin is found, not sampled for', () = expect(warn.join('\n')).not.toContain('stopped at its ceiling'); }); }); + + // ───────────────────────────────────────────────────────────────────────── + // 6. What leg A asks — and that nothing is asked when no owner can be named + // ───────────────────────────────────────────────────────────────────────── + + describe('the scan asks the Default Organization, once — and nothing when none can be named', () => { + it("leg A is ONE read, for the Default Organization's grants of the set; no organization-less read is made", async () => { + const ql = makeSyntheticQl(10); + const report = await bootstrapPlatformAdmin(ql as any, defaultPermissionSets, { logger: collectingLogger().logger }); + + // Everyone is another organization's grant: the scan saw all ten and promoted. + expect(report.adminPromoted).toBe(true); + expect(report.adminGrantRowsExamined).toBe(10); + const narrow = ql.grantReads.filter((where) => 'organization_id' in where); + expect(narrow).toEqual([{ permission_set: 'admin_full_access', organization_id: DEFAULT_ORG }]); + // …and it is the first question asked; leg B's pages follow it. + expect(ql.grantReads[0]).toEqual({ permission_set: 'admin_full_access', organization_id: DEFAULT_ORG }); + expect(ql.grantReads.length).toBeGreaterThan(1); + }); + + it('no nameable Default Organization (several organizations, none `default`) ⇒ zero grant reads, nobody found', async () => { + const ql = makeSyntheticQl(10, [{ id: 'org_a', slug: 'a' }, { id: 'org_b', slug: 'b' }]); + const report = await bootstrapPlatformAdmin(ql as any, defaultPermissionSets, { logger: collectingLogger().logger }); + + expect(ql.grantReads).toEqual([]); + expect(report.adminGrantRowsExamined).toBe(0); + // Nobody found, and no grant is written without an owner. + expect(report.adminPromoted).toBe(false); + expect(report.reason).toBe('default_organization_unresolved'); + expect(ql.inserted.filter((i) => i.object === 'sys_user_permission_set')).toEqual([]); + }); + + describe('under a wall', () => { + const OLD_POSTURE = process.env.OS_TENANCY_POSTURE; + afterEach(() => { + if (OLD_POSTURE === undefined) delete process.env.OS_TENANCY_POSTURE; + else process.env.OS_TENANCY_POSTURE = OLD_POSTURE; + }); + + it('a walled posture ⇒ zero grant reads, whatever the Default Organization', async () => { + process.env.OS_TENANCY_POSTURE = 'isolated'; + // The Default Organization exists here — the wall alone is why nothing is asked. + const ql = makeSyntheticQl(10); + const report = await bootstrapPlatformAdmin(ql as any, defaultPermissionSets, { logger: collectingLogger().logger }); + + expect(ql.grantReads).toEqual([]); + expect(report.adminPromoted).toBe(false); + expect(report.reason).toBe('walled_owner_email_undeclared'); + expect(ql.inserted.filter((i) => i.object === 'sys_user_permission_set')).toEqual([]); + }); + }); + }); }); diff --git a/packages/plugins/plugin-security/src/bootstrap-platform-admin-walled-owner.test.ts b/packages/plugins/plugin-security/src/bootstrap-platform-admin-walled-owner.test.ts index 7e8bad5acdf..d7544ae83ce 100644 --- a/packages/plugins/plugin-security/src/bootstrap-platform-admin-walled-owner.test.ts +++ b/packages/plugins/plugin-security/src/bootstrap-platform-admin-walled-owner.test.ts @@ -70,9 +70,13 @@ function makeQl(seed: { users?: any[]; grants?: any[]; sets?: any[]; accounts?: ['sys_organization', [{ id: 'org_default', slug: 'default' }]], ]); const rowsOf = (object: string) => tables.get(object) ?? []; + /** Every read, by object — so a case can say which tables a pass asked. */ + const reads: string[] = []; return { tables, + reads, async find(object: string, q: any) { + reads.push(object); const where = q?.where ?? {}; return rowsOf(object).filter((r) => Object.entries(where).every(([k, v]) => { @@ -388,7 +392,7 @@ describe('walled posture — the legacy grant is no longer an anchor (#11663 L5) expect(sinkWarns).toEqual([]); // Nothing is revoked and nothing new is minted: the one legacy row stays // exactly where it is. ⛔ Retiring the ANCHOR is not deleting the ROW — - // that row's ownership is ADR-0131 C3's, on the v18 line. + // that row's ownership is ADR-0131 D10's ceremony's. expect(ql.grants()).toHaveLength(1); expect(r1.reason).toBe('walled_config_derived'); expect(r2.reason).toBe('walled_config_derived'); @@ -407,28 +411,38 @@ describe('walled posture — the legacy grant is no longer an anchor (#11663 L5) expect(log.error).toHaveBeenCalledTimes(1); const line = String(log.error.mock.calls[0][0]); expect(line).toContain('OS_PLATFORM_OWNER_EMAIL'); - // …and it names the row and its holder, so the operator can tell WHOSE - // standing just ended rather than only that nobody is declared. - expect(line).toContain('u_legacy'); - expect(line).toContain('admin_full_access'); + // The line no longer names the row or its holder: under a wall no grant + // anchors standing, so the holder scan asks NOTHING — not one read of the + // grant table — and the line is the general one, byte-for-byte what a rig + // holding no such row is told (the CONTROL below). + expect(ql.reads).not.toContain('sys_user_permission_set'); + expect(line).not.toContain('u_legacy'); + expect(line).toBe(await undeclaredLineWithoutLegacyRow()); // The row is untouched, and no deprecation pointer came back through the // module sink either. expect(ql.grants()).toHaveLength(1); expect(sinkWarns).toEqual([]); }); - it('the holder clause is CONDITIONAL — a walled rig with no legacy row says only the general line', async () => { - // The control for the arm above: the extra sentence must come from the row - // actually being there, not from the message having grown unconditionally. - process.env.OS_TENANCY_POSTURE = 'isolated'; + /** The fail-closed line a walled rig holding NO legacy row is told. */ + async function undeclaredLineWithoutLegacyRow(): Promise { const log = logger(); const ql = makeQl({ users: [user('u_stranger', 'stranger@evil.example', '2026-08-23T01:00:00Z')], }); const r = await bootstrapPlatformAdmin(ql as any, [adminFullAccess()], { logger: log }); expect(r.reason).toBe('walled_owner_email_undeclared'); - const line = String(log.error.mock.calls[0][0]); + expect(log.error).toHaveBeenCalledTimes(1); + return String(log.error.mock.calls[0][0]); + } + + it('CONTROL: a walled rig with no legacy row says the same general line, naming the variable', async () => { + // The control for the arm above: the line it is compared to is a real + // fail-closed line naming the remedy, not an empty string both sides share. + process.env.OS_TENANCY_POSTURE = 'isolated'; + const line = await undeclaredLineWithoutLegacyRow(); expect(line).toContain('OS_PLATFORM_OWNER_EMAIL'); + expect(line).toContain('ZERO config-derived platform administrators'); expect(line).not.toContain('legacy unscoped'); }); @@ -540,10 +554,12 @@ describe('single posture — "first user is owner" is ruled reasonable and UNCHA }); it('an existing human admin short-circuits as already_have_admin — the single-posture no-op-forever shape', async () => { + // [ADR-0131 D5] The anchoring grant is the Default Organization's — the + // only grant the holder scan reads. const ql = makeQl({ sets: [{ id: 'ps_admin', name: 'admin_full_access', active: true }], users: [user('u_admin', 'admin@corp.example', '2026-08-23T01:00:00Z')], - grants: [{ id: 'ups_1', user_id: 'u_admin', permission_set_id: 'ps_admin', permission_set: 'admin_full_access', organization_id: null }], + grants: [{ id: 'ups_1', user_id: 'u_admin', permission_set_id: 'ps_admin', permission_set: 'admin_full_access', organization_id: 'org_default' }], }); const r = await bootstrapPlatformAdmin(ql as any, [adminFullAccess()], { logger: logger() }); expect(r.adminPromoted).toBe(false); diff --git a/packages/plugins/plugin-security/src/bootstrap-platform-admin.ts b/packages/plugins/plugin-security/src/bootstrap-platform-admin.ts index f9123b5ffce..c6161b5e9f7 100644 --- a/packages/plugins/plugin-security/src/bootstrap-platform-admin.ts +++ b/packages/plugins/plugin-security/src/bootstrap-platform-admin.ts @@ -13,8 +13,9 @@ * maintainer's ruling A on #18707): the zero-config promotion stays, * and ⛔ no NULL-organization grant row is written. The holder scan * and the derivation site (`resolve-authz-context.ts` §6) read that - * row — and the organization-less row an earlier release wrote, until - * D10's ceremony attributes it to the same organization. A deployment + * row and no other: the organization-less row an earlier release + * wrote is attributed to the same organization by D10's ceremony + * before an upgraded database boots. A deployment * whose Default Organization cannot be named is not promoted: it says * so, loudly, rather than writing the row without an owner. * [#14348] "First user" means the oldest @@ -474,19 +475,17 @@ const PLATFORM_ADMIN_PERMISSION_SET_NAME = 'admin_full_access'; * ## Whose grant is the anchor ([ADR-0131] D5) * * Under `single` the promotion writes its grant OWNED BY THE DEFAULT - * ORGANIZATION (`defaultOrganizationId`), so that row is a holder; so is the - * organization-less row an earlier release's promotion wrote, until D10's - * ceremony attributes it to the same organization. The derivation site reads - * the same two (`grantAnchorsPlatformStanding`, `resolve-authz-context.ts`), - * so the holder this scan finds is the account the resolver grades - * PLATFORM_ADMIN — ⛔ a scan reading only the NULL row would find nobody on a - * promoted deployment the ceremony has run on and mint a SECOND grant on every - * boot. A user holding both rows is one holder; whichever row sorts first by - * `id` answers. Any other organization's grant of the set is an organization - * administrator's and never a holder. `defaultOrganizationId` is `undefined` - * under a wall, where only the legacy organization-less row is asked about (and - * only to report it — it anchors nothing there), and where the Default - * Organization cannot be named. + * ORGANIZATION (`defaultOrganizationId`), so that row is the holder, and the + * only one. The derivation site reads the same one + * (`grantAnchorsPlatformStanding`, `resolve-authz-context.ts`), so the holder + * this scan finds is the account the resolver grades PLATFORM_ADMIN. The + * organization-less row an earlier release's promotion wrote is not read: D10's + * ceremony attributes it to the Default Organization before an upgraded + * database boots, and the column is NOT NULL after it. Any other organization's + * grant of the set is an organization administrator's and never a holder. + * `defaultOrganizationId` is `undefined` under a wall, where no grant anchors + * standing, and where the Default Organization cannot be named; the scan then + * asks nothing and finds nobody. * * The read was once `tryFind(ql, 'sys_user_permission_set', { * permission_set_id: adminPsId }, 50)` — no `orderBy`, cap 50 — with the @@ -503,38 +502,23 @@ const PLATFORM_ADMIN_PERMISSION_SET_NAME = 'admin_full_access'; * #14348 case D: 「Moving an already-granted platform admin is reserved to the * maintainer.」 * - * ## Why this is TWO reads and not `organization_id: null` in the `where` + * ## Why this is TWO reads * - * The card's suggested one-line narrowing was MEASURED before it was taken, - * and on its own it would have RELAXED this guard. Null matching itself is - * uniform across the families that can be measured — each answers "the column - * holds no value": - * - * driver-sql (better-sqlite3) via ObjectQL + these real objects -> the unscoped row only - * driver-sqlite-wasm via ObjectQL + these real objects -> the unscoped row only - * driver-memory driver face -> null-valued AND key-absent rows - * driver-mongodb translator (its own live suites -> `{organization_id: null}`, - * need a 123 MB binary download) Mongo's null-or-missing reading - * - * What is NOT uniform is the narrowed read against THIS code's own predicate. - * `organization_id: ''` is storable on both SQL families and reads back as - * `''`: `!organization_id` counts that row UNSCOPED, and `where: { - * organization_id: null }` does NOT return it. A narrowing that REPLACED the - * client-side predicate would therefore stop seeing a legacy unscoped holder - * stored that way, fire less often, and mint the second grant. ⛔ So the - * predicate is untouched and the READ is what changed: - * - * Leg A — ask the driver the narrow question. Independent of how many - * org-scoped grants exist, so no org-admin count can crowd the - * answer out of a window. + * Leg A — ask the driver the narrow question: the Default Organization's + * grants of the set. Independent of how many other organizations' + * grants exist, so no org-admin count can crowd the answer out of a + * window. * Leg B — only when leg A found nobody: scan the grant population for this * set, ORDERED so each page is a deterministic slice rather than * "whatever the driver produced first", bounded, and reporting - * `truncated` at the bound with the number of rows examined. This is - * the leg that still sees a `''`-shaped legacy row. + * `truncated` at the bound with the number of rows examined. It + * applies the exact predicate client-side, so a driver whose `where` + * matching differs from it cannot make the guard fire less often. * - * Both legs are strictly ADDITIVE to what the old read could see, so the - * guard can only fire MORE often than before, never less. + * Both legs ask the one predicate, so the guard can only fire MORE often than + * either leg alone, never less. (The organization-less reading — and with it + * the `''`-shaped legacy row only the scan could see — retired with the 18.0 + * ownership ceremony, ADR-0131 D10.) * * ## Several holders * @@ -582,9 +566,13 @@ async function findPlatformAdminGrantHolder( adminGrantRowsExamined: number; truncated: boolean; }> { + // No owner can be named (a wall, or no Default Organization): no grant + // anchors, so nothing is asked. + if (defaultOrganizationId === undefined) { + return { holder: undefined, unnamedHolder: undefined, adminGrantRowsExamined: 0, truncated: false }; + } const isAnchorHumanHolder = (r: any) => - (!r.organization_id || (defaultOrganizationId !== undefined && r.organization_id === defaultOrganizationId)) - && r.user_id !== SystemUserId.SYSTEM; + r.organization_id === defaultOrganizationId && r.user_id !== SystemUserId.SYSTEM; // Counted by row IDENTITY, not by read: the two legs overlap by construction // (leg A's rows are a subset of leg B's population), and a number that @@ -600,22 +588,17 @@ async function findPlatformAdminGrantHolder( }; let truncated = false; - // Leg A — the narrow question, asked of the driver: once per anchoring owner - // (the organization-less row, and the Default Organization's), each answer - // ordered by `id`, and the row whose `id` sorts first of the two wins. - let holder: any | undefined; - for (const organizationId of defaultOrganizationId === undefined ? [null] : [null, defaultOrganizationId]) { - const anchorGrantRows = await tryFind( - ql, - 'sys_user_permission_set', - { [GRANT_SET_NAME_FIELD]: PLATFORM_ADMIN_PERMISSION_SET_NAME, organization_id: organizationId }, - PLATFORM_ADMIN_GRANT_PAGE_SIZE, - ADMIN_GRANT_SCAN_ORDER, - ); - countExamined(anchorGrantRows); - const found = anchorGrantRows.find(isAnchorHumanHolder); - if (found && (!holder || String(found.id) < String(holder.id))) holder = found; - } + // Leg A — the narrow question, asked of the driver: the Default + // Organization's grants of the set, ordered by `id`. + const anchorGrantRows = await tryFind( + ql, + 'sys_user_permission_set', + { [GRANT_SET_NAME_FIELD]: PLATFORM_ADMIN_PERMISSION_SET_NAME, organization_id: defaultOrganizationId }, + PLATFORM_ADMIN_GRANT_PAGE_SIZE, + ADMIN_GRANT_SCAN_ORDER, + ); + countExamined(anchorGrantRows); + let holder: any | undefined = anchorGrantRows.find(isAnchorHumanHolder); /** * The ordered, bounded scan of one grant population, stopping at the first @@ -699,7 +682,7 @@ export async function findExistingPlatformAdmin( /** * [ADR-0131 D5] The owner of the grant the `single` promotion writes, and of - * the grant the holder scan reads beside the organization-less one: the + * the grant the holder scan reads: the * Default Organization, by the definition plugin-auth's boot invariant creates * it by (`resolveDefaultOrganizationId` — `slug = 'default'`, else the only * organization) and the derivation site reads it by (`resolve-authz-context.ts` @@ -733,7 +716,7 @@ export async function bootstrapPlatformAdmin( /** Count of seeded rows re-owned to the freshly-promoted admin. */ ownershipClaimed?: number; /** - * WHO holds the unscoped `admin_full_access` grant after this pass — the user + * WHO holds the anchoring `admin_full_access` grant after this pass — the user * this pass promoted, or the holder the `already_have_admin` short-circuit * found. Present on both, absent on every other return and under walled * postures (where no grant row exists and standing is config-derived at @@ -801,8 +784,8 @@ export async function bootstrapPlatformAdmin( const walled = postureEnforcesWall(resolveTenancyPosture()); // [ADR-0131 D5] Under `single` the promotion's grant is the Default - // Organization's, so that is the owner the holder scan asks about beside the - // organization-less row and the owner a promotion writes. Under a wall no + // Organization's, so that is the owner the holder scan asks about and the + // owner a promotion writes. Under a wall no // grant anchors standing and none is written, so nothing is asked. const defaultOrganizationId = walled ? undefined : await readPromotionGrantOwner(ql); @@ -829,7 +812,7 @@ export async function bootstrapPlatformAdmin( // ⛔ The truncation is never silent (commit 1c83ca226). Reaching the ceiling is the one // way this scan still answers "no platform admin yet" while one exists, and - // that answer does not merely skip a log line — it MINTS A SECOND unscoped + // that answer does not merely skip a log line — it MINTS A SECOND anchoring // grant and hands it the seeded business records. So it says the number it // examined rather than letting the promotion below read as a statement about // the whole table. @@ -868,7 +851,7 @@ export async function bootstrapPlatformAdmin( }; } - // [ADR-0131 D4] An unscoped grant that names no set yet — on an upgraded + // [ADR-0131 D4] An anchoring grant that names no set yet — on an upgraded // deployment, possibly its platform administrator's grant, written before // the name column existed. It is read as an administrator who may already // exist: promoting now could mint the second grant @@ -877,7 +860,7 @@ export async function bootstrapPlatformAdmin( // for a boot that reads the grant by its name. if (!walled && unnamedHolder) { const withheld = - '[security] platform-admin promotion WITHHELD: an unscoped grant exists whose permission-set name ' + '[security] platform-admin promotion WITHHELD: a Default Organization grant exists whose permission-set name ' + '(sys_user_permission_set.permission_set) was never written — a grant from a release before that ' + 'column existed. Which set it holds can no longer be read, and it may be the platform administrator\'s, ' + 'so this pass will not mint a second one. The organization-ownership migration\'s id-to-name rewrite ' @@ -904,9 +887,9 @@ export async function bootstrapPlatformAdmin( // posture, so on these rigs standing is CONFIG-DERIVED and nothing else. // // ⛔ Nothing here writes, deletes or re-owns that row — its ownership is - // ADR-0131 C3's, on the v18 line. `anchorHolder` therefore still means - // exactly what it meant: a row exists. What changed is what the row BUYS, - // which under a wall is now nothing. + // ADR-0131 D10's ceremony's. Under a wall the holder scan asks nothing + // (no grant anchors standing here), so `anchorHolder` is always undefined + // on this branch. // Fail-closed backstop for an unusable config (unset, blank, or a list // REFUSED for an unparseable entry — #11663 Choice 2B folds all three @@ -920,10 +903,10 @@ export async function bootstrapPlatformAdmin( // It used to be, and the reason it used to be has expired: L4's skip was // justified by 「the deployment HAS an administrator, on the old anchor」 and // by the deprecation pointer carrying the remedy instead. Both premises died - // with the walled dual read. A walled rig holding an unscoped - // `admin_full_access` row and declaring nobody now has ZERO platform - // administrators — exactly the state this line exists to announce — so - // staying quiet for it would be the silent half of a fail-closed guard. + // with the walled dual read. A walled rig holding an `admin_full_access` + // grant and declaring nobody has ZERO platform administrators — exactly + // the state this line exists to announce — so staying quiet for it would + // be the silent half of a fail-closed guard. const platformAdminConfig = resolvePlatformAdminEmails(); if (platformAdminConfig.emails.length === 0) { const message = @@ -933,19 +916,7 @@ export async function bootstrapPlatformAdmin( 'postures the first registrant is never promoted and no grant row is written; ' + `platform admin standing is derived from ${PLATFORM_OWNER_EMAIL_ENV} at request ` + "time. Set it to the operator's email address (or a comma-separated list of " + - 'addresses) and make sure the account verifies its email.' + - // [#11663 L5] The migration window's last word, said where an operator can - // act on it. This rig HAS an unscoped `admin_full_access` row and used to - // be administered through it; under a wall that row stopped conferring - // when the dual read retired, so saying only "declare somebody" would hide - // the fact that somebody has just lost standing. - (anchorHolder - ? " This deployment also holds a legacy unscoped 'admin_full_access' grant row" + - ` (user ${String(anchorHolder.user_id)}). Under a walled posture that row is NO LONGER` + - ` an anchor for platform admin standing — the migration window announced since 17.3.0 has` + - ` closed — so declaring that account's verified address in ${PLATFORM_OWNER_EMAIL_ENV} is` + - ' what restores it. The row itself is left exactly as it is.' - : ''); + 'addresses) and make sure the account verifies its email.'; if (logger?.error) logger.error(message); else logger?.warn?.(message); return { diff --git a/packages/plugins/plugin-security/src/claim-seed-ownership-seed-settle-rerun.test.ts b/packages/plugins/plugin-security/src/claim-seed-ownership-seed-settle-rerun.test.ts index 5e5035ce74c..6bba91c296d 100644 --- a/packages/plugins/plugin-security/src/claim-seed-ownership-seed-settle-rerun.test.ts +++ b/packages/plugins/plugin-security/src/claim-seed-ownership-seed-settle-rerun.test.ts @@ -307,11 +307,17 @@ describe('seed-ownership claim — the one-shot pass and the seed it races', () user_id: ADMIN, permission_set_id: 'ps_admin_full_access', permission_set: 'admin_full_access', - organization_id: null, + // [ADR-0131 D5] Owned by the Default Organization — the only grant the + // holder scan reads (an organization-less one is never a holder). + organization_id: 'org_default', }); rig.tables.sys_permission_set.push({ id: 'ps_admin_full_access', name: 'admin_full_access' }); await rig.fire('kernel:ready'); + // The promotion DID short-circuit: no second grant was minted. Without this + // a pass that ignored the existing grant and promoted the same (only) human + // would satisfy the claim assertion below for the wrong reason. + expect(rig.tables.sys_user_permission_set.map((r) => r.id)).toEqual(['ups_existing']); rig.landSeedRows('crm_contract', seedRow('con_late')); rig.settlement.settleOne(); await rig.fire('app:seeded', { appId: 'com.example.crm', overBudget: true }); diff --git a/packages/plugins/plugin-security/src/controlled-by-parent-chain-middle-master.test.ts b/packages/plugins/plugin-security/src/controlled-by-parent-chain-middle-master.test.ts index 0db87c56aab..f48bdf0a27f 100644 --- a/packages/plugins/plugin-security/src/controlled-by-parent-chain-middle-master.test.ts +++ b/packages/plugins/plugin-security/src/controlled-by-parent-chain-middle-master.test.ts @@ -133,6 +133,9 @@ const SETS = [MEMBER_DEFAULT, MANAGER_SET, ROOT_READER_SET, MIDDLE_READER_SET, Q /** The execution context `resolveAuthzContext` hands the middleware for an organization member holding the sets. */ const memberOf = (userId: string, ...sets: string[]) => ({ userId, + // The organization the request runs in — and so the one a delegator's stored + // grants are resolved in (`resolveDelegatorContext`). + tenantId: ORG, positions: ['org_member'], permissions: [...sets, 'member_default'], posture: 'MEMBER', @@ -143,7 +146,7 @@ const MIDDLE_READER_CALLER = memberOf(MIDDLE_READER, MIDDLE_READER_SET.name); const LOCKED_CALLER = memberOf(LOCKED, MANAGER_SET.name, QUOTE_OPEN_ONLY_SET.name); const PLAIN_CALLER = memberOf(PLAIN, PLAIN_SET.name); /** Holds the manager's grants and NO `org_member`, so no floor binds it. */ -const AGENT_UNBOUND_CALLER = { userId: AGENT_UNBOUND, positions: [], permissions: [MANAGER_SET.name], posture: 'MEMBER' }; +const AGENT_UNBOUND_CALLER = { userId: AGENT_UNBOUND, tenantId: ORG, positions: [], permissions: [MANAGER_SET.name], posture: 'MEMBER' }; const onBehalfOf = (caller: object, delegator: string) => ({ ...caller, onBehalfOf: { userId: delegator } }); type Answer = { kind: 'admitted' } | { kind: 'refused'; code?: string; status?: number; message: string }; @@ -225,21 +228,24 @@ beforeAll(async () => { // The two delegators exist as users, so the on-behalf-of link resolves rather // than failing closed. Each holds the manager's set through a stored position - // row; only DELEGATOR_MEMBER is an organization member, so only it carries the floor. + // row owned by ORG — the organization the delegated request runs in, the only + // one a grant row applies in (ADR-0131 D10: an organization-less row applies + // nowhere); only DELEGATOR_MEMBER is an organization member, so only it + // carries the floor. await engine.insert('sys_user', [ { id: DELEGATOR_UNBOUND, name: 'Delegator, not a member', email: 'delegator-unbound@cbc.test' }, { id: DELEGATOR_MEMBER, name: 'Delegator, a member', email: 'delegator-member@cbc.test' }, ], SYS); await engine.insert('sys_organization', [{ id: ORG, name: 'CBC', slug: 'cbc' }], SYS); await engine.insert('sys_member', [{ id: 'mem_delegator', user_id: DELEGATOR_MEMBER, organization_id: ORG, role: 'member' }], SYS); - // [C8 stage 8b] Organization-less position rows are landed straight through the - // driver, as the residue an older release left: under `single` the engine now - // stamps such a system insert with the one organization (ADR-0131 D9), which - // would tie DELEGATOR_UNBOUND's position to an organization it is not a member of. + // Landed straight through the driver, so each row stores exactly the + // organization named here. DELEGATOR_UNBOUND's row is ORG's without a + // membership: under `single` the session's organization is kept without one, + // and no `sys_member` row means no `org_member` floor. const positions = (engine as any).getDriver('sys_user_position'); for (const row of [ - { id: 'up_unbound', user_id: DELEGATOR_UNBOUND, position: MANAGER_SET.name }, - { id: 'up_member', user_id: DELEGATOR_MEMBER, position: MANAGER_SET.name }, + { id: 'up_unbound', user_id: DELEGATOR_UNBOUND, position: MANAGER_SET.name, organization_id: ORG }, + { id: 'up_member', user_id: DELEGATOR_MEMBER, position: MANAGER_SET.name, organization_id: ORG }, ]) { await positions.create('sys_user_position', row); } diff --git a/packages/plugins/plugin-security/src/delegated-admin-gate-holding-organization.test.ts b/packages/plugins/plugin-security/src/delegated-admin-gate-holding-organization.test.ts index 22493f8a1e3..5a76dc67d38 100644 --- a/packages/plugins/plugin-security/src/delegated-admin-gate-holding-organization.test.ts +++ b/packages/plugins/plugin-security/src/delegated-admin-gate-holding-organization.test.ts @@ -20,12 +20,13 @@ * * ## The rule the read now follows — the runtime's own * - * `resolveAuthzContext` (step 4, `@objectstack/core`) keeps a holding when its - * `organization_id` is empty OR equals the active organization, and drops it - * when it names a DIFFERENT one. The gate agrees: a holding stamped for another - * organization never counts; an organization-less holding counts in every - * organization, exactly as it grants in every organization. An - * organization-less caller (`single` posture) drops nothing, as before. + * `resolveAuthzContext` (step 4, `@objectstack/core`) keeps a holding only + * when its `organization_id` equals the active organization. The gate agrees: a + * holding stamped for another organization never counts; an organization-less + * holding counts nowhere, exactly as it grants nowhere (the 18.0 ownership + * ceremony, ADR-0131 D10, attributes every such row before an upgraded database + * boots, so one is invalid state); and a caller whose context carries no + * organization holds nothing to delegate. */ import { describe, it, expect, afterEach } from 'vitest'; @@ -209,12 +210,16 @@ describe('DelegatedAdminGate — rule 4 counts only holdings the caller\'s organ await expect(selfDelegate(gateOn(engine), ORG_A)).resolves.toBeUndefined(); }); - it('an organization-less holding counts in org A — the runtime grants it in every organization', async () => { + it('an organization-less holding counts nowhere — not in org A, where the runtime grants it nothing', async () => { const engine = await bootEngine(); await seedOrganization(engine, ORG_B, '0'); await seedOrganization(engine, ORG_A, 'a'); await hold(engine, undefined, [{ id: 'up_none', user_id: USER }]); + await expectRefused(selfDelegate(gateOn(engine), ORG_A), /you do not currently hold 'field_lead'/); + await expectRefused(selfDelegate(gateOn(engine), ORG_B), /you do not currently hold 'field_lead'/); + // Control: the same user's holding OWNED by org A makes the same request stand. + await hold(engine, ORG_A, [{ id: 'up_a', user_id: USER }]); await expect(selfDelegate(gateOn(engine), ORG_A)).resolves.toBeUndefined(); }); @@ -245,11 +250,14 @@ describe('DelegatedAdminGate — rule 4 counts only holdings the caller\'s organ await expect(selfDelegate(gateOn(engine), ORG_A, a.salesEast)).resolves.toBeUndefined(); }); - it('a `single`-posture caller (no organization in context) keeps counting every holding', async () => { + it('a caller whose context carries no organization counts no holding — owned or organization-less', async () => { const engine = await bootEngine(); await seedOrganization(engine, ORG_B, '0'); await hold(engine, ORG_B, [{ id: 'up_b', user_id: USER }]); + await hold(engine, undefined, [{ id: 'up_none', user_id: USER }]); - await expect(selfDelegate(gateOn(engine), undefined)).resolves.toBeUndefined(); + await expectRefused(selfDelegate(gateOn(engine), undefined), /you do not currently hold 'field_lead'/); + // Control: the same holding counts once the request runs in the organization that owns it. + await expect(selfDelegate(gateOn(engine), ORG_B)).resolves.toBeUndefined(); }); }); diff --git a/packages/plugins/plugin-security/src/delegated-admin-gate.test.ts b/packages/plugins/plugin-security/src/delegated-admin-gate.test.ts index 6d884d5c0e7..b8e98052876 100644 --- a/packages/plugins/plugin-security/src/delegated-admin-gate.test.ts +++ b/packages/plugins/plugin-security/src/delegated-admin-gate.test.ts @@ -426,7 +426,15 @@ const iso = (ms: number) => new Date(ms).toISOString(); * plain_pos (NOT delegatable) ← u_boss holds directly * spare_pos (delegatable) ← u_boss does NOT hold * approver ← u_relay holds via delegation + * + * Every holding is owned by {@link DELEGATION_ORG} and every delegation runs in + * it: a holding takes effect only in its own organization (`resolveAuthzContext` + * step 4, `grantAppliesInTenant`), so an organization-less holding, or a context + * carrying no organization, holds nothing to delegate — pinned at the end of + * the describe below. */ +const DELEGATION_ORG = 'org_1'; + function makeDelegationHarness(nowMs = T0) { const tables: Record = { sys_position: [ @@ -445,10 +453,10 @@ function makeDelegationHarness(nowMs = T0) { { id: 'b_admin', position_id: 'p_admin', permission_set_id: 's_sub' }, ], sys_user_position: [ - { id: 'h1', user_id: 'u_boss', position: 'approver' }, - { id: 'h2', user_id: 'u_boss', position: 'plain_pos' }, - { id: 'h3', user_id: 'u_boss', position: 'admin_pos' }, - { id: 'h4', user_id: 'u_relay', position: 'approver', delegated_from: 'u_boss', valid_until: iso(nowMs + 20 * DAY) }, + { id: 'h1', user_id: 'u_boss', position: 'approver', organization_id: DELEGATION_ORG }, + { id: 'h2', user_id: 'u_boss', position: 'plain_pos', organization_id: DELEGATION_ORG }, + { id: 'h3', user_id: 'u_boss', position: 'admin_pos', organization_id: DELEGATION_ORG }, + { id: 'h4', user_id: 'u_relay', position: 'approver', delegated_from: 'u_boss', valid_until: iso(nowMs + 20 * DAY), organization_id: DELEGATION_ORG }, ], sys_user: [{ id: 'u_boss' }, { id: 'u_relay' }, { id: 'u_deleg' }], }; @@ -474,8 +482,12 @@ function makeDelegationHarness(nowMs = T0) { resolveSets: async () => [{ name: 'member_default', objects: {} }], now: () => nowMs, }); - const delegate = (userId: string, row: any) => - gate.assert({ object: 'sys_user_position', operation: 'insert', data: row, context: { userId, positions: [] } }); + /** `organizationId` null = a context carrying no organization. */ + const delegate = (userId: string, row: any, organizationId: string | null = DELEGATION_ORG) => + gate.assert({ + object: 'sys_user_position', operation: 'insert', data: row, + context: { userId, positions: [], ...(organizationId ? { organizationId } : {}) }, + }); return { gate, ql, tables, delegate }; } @@ -567,6 +579,20 @@ describe('DelegatedAdminGate — self-service delegation of duty (ADR-0091 D3)', await expect(d.delegate('u_boss', { user_id: 'u_deleg', position: 'everyone', delegated_from: 'u_boss', valid_until: iso(T0 + 5 * DAY), reason: 'x' })) .rejects.toThrow(/audience anchor/); }); + + it('an organization-less holding counts nowhere, and a context with no organization holds nothing', async () => { + const d = makeDelegationHarness(); + const row = () => ({ user_id: 'u_deleg', position: 'approver', delegated_from: 'u_boss', valid_until: iso(T0 + 5 * DAY), reason: 'x' }); + // Control: the holding owned by the organization the request runs in counts. + await expect(d.delegate('u_boss', row())).resolves.toBeUndefined(); + // The same request from a context that carries no organization holds nothing. + await expect(d.delegate('u_boss', row(), null)).rejects.toThrow(/do not currently hold/); + // A holding with no organization — invalid state since the 18.0 ownership + // ceremony — counts in no organization. + d.tables.sys_user_position.find((r: any) => r.id === 'h1').organization_id = null; + await expect(d.delegate('u_boss', row())).rejects.toThrow(/do not currently hold/); + await expect(d.delegate('u_boss', row(), null)).rejects.toThrow(/do not currently hold/); + }); }); // ── [cloud#830 follow-up] Self-delegation anchor containment ──────────────── @@ -588,6 +614,9 @@ describe('DelegatedAdminGate — self-service delegation of duty (ADR-0091 D3)', // ├── east (bu_east) ← u_boss's own approver anchor // │ └── east_sales (bu_es) // └── west (bu_west) +// +// Every row is owned by DELEGATION_ORG and every delegation runs in it (see +// makeDelegationHarness). function makeAnchoredDelegationHarness(nowMs = T0) { const tables: Record = { sys_business_unit: [ @@ -604,6 +633,9 @@ function makeAnchoredDelegationHarness(nowMs = T0) { sys_business_unit_member: [{ id: 'm_boss', user_id: 'u_boss', business_unit_id: 'bu_es' }], sys_user: [{ id: 'u_boss' }, { id: 'u_deleg' }], }; + for (const object of ['sys_business_unit', 'sys_user_position', 'sys_business_unit_member']) { + for (const r of tables[object]) r.organization_id = DELEGATION_ORG; + } const matches = (row: any, where: any): boolean => Object.entries(where ?? {}).every(([k, v]) => { if (k.startsWith('$')) throw new Error(`fake driver: unsupported operator ${k}`); @@ -627,7 +659,10 @@ function makeAnchoredDelegationHarness(nowMs = T0) { now: () => nowMs, }); const delegate = (userId: string, row: any) => - gate.assert({ object: 'sys_user_position', operation: 'insert', data: row, context: { userId, positions: [] } }); + gate.assert({ + object: 'sys_user_position', operation: 'insert', data: row, + context: { userId, positions: [], organizationId: DELEGATION_ORG }, + }); const base = (extra: any) => ({ user_id: 'u_deleg', position: 'approver', delegated_from: 'u_boss', valid_until: iso(nowMs + 5 * DAY), reason: 'coverage', ...extra, @@ -669,7 +704,7 @@ describe('DelegatedAdminGate — self-delegation anchor containment (cloud#830 f await expect(d.delegate('u_boss', d.base({ business_unit_id: 'bu_west' }))) .rejects.toThrow(/outside your own effective anchor/); // …and a would-be west holder cannot reach east either (no east holding to source it). - d.tables.sys_user_position.push({ id: 'hb', user_id: 'u_deleg', position: 'approver', business_unit_id: 'bu_west' }); + d.tables.sys_user_position.push({ id: 'hb', user_id: 'u_deleg', position: 'approver', business_unit_id: 'bu_west', organization_id: DELEGATION_ORG }); await expect(d.delegate('u_deleg', { user_id: 'u_boss', position: 'approver', delegated_from: 'u_deleg', valid_until: iso(T0 + 5 * DAY), reason: 'x', business_unit_id: 'bu_east' })) .rejects.toThrow(/outside your own effective anchor/); }); @@ -698,7 +733,7 @@ describe('DelegatedAdminGate — self-delegation anchor containment (cloud#830 f const d = makeAnchoredDelegationHarness(); // u_relay holds approver only via delegation, anchored east. d.tables.sys_user.push({ id: 'u_relay' }); - d.tables.sys_user_position.push({ id: 'hd', user_id: 'u_relay', position: 'approver', business_unit_id: 'bu_east', delegated_from: 'u_boss', valid_until: iso(T0 + 20 * DAY) }); + d.tables.sys_user_position.push({ id: 'hd', user_id: 'u_relay', position: 'approver', business_unit_id: 'bu_east', delegated_from: 'u_boss', valid_until: iso(T0 + 20 * DAY), organization_id: DELEGATION_ORG }); await expect(d.delegate('u_relay', { user_id: 'u_deleg', position: 'approver', delegated_from: 'u_relay', valid_until: iso(T0 + 5 * DAY), reason: 'x', business_unit_id: 'bu_es' })) .rejects.toThrow(/only via delegation/); }); @@ -1377,8 +1412,10 @@ function makeOwnNameHarness() { ], sys_user_position: [ { id: 'held_rep', user_id: 'u_east_1', position: 'rep_pos', business_unit_id: 'bu_es' }, - { id: 'boss_duty', user_id: 'u_boss', position: 'duty_pos' }, - { id: 'boss_plain', user_id: 'u_boss', position: 'duty_plain' }, + // A holding takes effect only in its own organization, so the delegator's + // holdings are stamped with the one the delegation below runs in. + { id: 'boss_duty', user_id: 'u_boss', position: 'duty_pos', organization_id: DELEGATION_ORG }, + { id: 'boss_plain', user_id: 'u_boss', position: 'duty_plain', organization_id: DELEGATION_ORG }, ], sys_business_unit_member: [{ id: 'm1', business_unit_id: 'bu_es', user_id: 'u_east_1' }], sys_user: [{ id: 'u_east_1' }, { id: 'u_boss' }, { id: 'u_deleg' }], @@ -1488,7 +1525,7 @@ describe("DelegatedAdminGate — a delegate is judged on the permission set of t it('a delegatable position whose own-name set carries an adminScope cannot be self-delegated', async () => { const delegation = (position: string) => n.gate.assert({ - object: 'sys_user_position', operation: 'insert', context: { userId: 'u_boss', positions: [], principal: 'member' }, + object: 'sys_user_position', operation: 'insert', context: { userId: 'u_boss', positions: [], principal: 'member', organizationId: DELEGATION_ORG }, data: { user_id: 'u_deleg', position, delegated_from: 'u_boss', valid_until: iso(T0 + 5 * DAY), reason: 'cover' }, }); const err = await deniedOf(() => delegation('duty_pos')); diff --git a/packages/plugins/plugin-security/src/delegated-admin-gate.ts b/packages/plugins/plugin-security/src/delegated-admin-gate.ts index 55a88523eb3..98039ed8a9d 100644 --- a/packages/plugins/plugin-security/src/delegated-admin-gate.ts +++ b/packages/plugins/plugin-security/src/delegated-admin-gate.ts @@ -124,9 +124,12 @@ function callerOrganizationId(context: any): string | undefined { * Does a `sys_user_position` row take effect in `organizationId`? * * The runtime resolver's own answer (`resolveAuthzContext` step 4, in - * `@objectstack/core`): a holding stamped with a DIFFERENT organization grants - * nothing there; an organization-less holding grants in every organization; - * `organizationId` undefined (a context carrying no organization) drops nothing. + * `@objectstack/core`, `grantAppliesInTenant`): a holding takes effect only in + * its OWN organization. A holding stamped with a different organization grants + * nothing here; an organization-less holding grants nowhere — the 18.0 + * ownership ceremony (ADR-0131 D10) attributes every such row before an + * upgraded database boots, so one is invalid state, not a "global" holding; + * and a context carrying no organization holds nothing. * * Every holding the gate reasons about goes through this, because * `sys_user_position.position` is a position NAME, the same string in every @@ -134,13 +137,12 @@ function callerOrganizationId(context: any): string | undefined { * a walled posture another organization's holding of the position is the same * string. The * gate must count exactly the holdings the runtime grants: counting another - * organization's lets a user self-delegate a position they do not hold there; - * dropping organization-less ones refuses holdings the runtime honours. + * organization's, or one the runtime reads as belonging to none, lets a user + * self-delegate a position they do not hold there. */ function holdingTakesEffectIn(row: any, organizationId?: string): boolean { - if (!organizationId) return true; const org = rowOrganizationId(row); - return !org || org === organizationId; + return !!organizationId && !!org && org === organizationId; } /** Coerce a stored timestamp to epoch ms; undefined = absent, NaN = unparseable. */ @@ -716,9 +718,8 @@ export class DelegatedAdminGate { * `sys_user_permission_set` rows of the caller's own organization: an insert, * or a delete of one row by id. A row inserted with no organization is * stamped with the caller's here, once admitted, so the right never mints an - * organization-less grant (one that applies in every organization); a row - * naming another organization, or a removal of an organization-less or - * foreign row, is refused. That the row's user is a member of the + * organization-less grant; a row naming another organization, or a removal + * of a row that is not the caller's organization's, is refused. That the row's user is a member of the * organization is judged for every writer by the grant-holder membership * hooks (`grant-holder-membership-refusal.ts`), not again here. * @@ -756,7 +757,7 @@ export class DelegatedAdminGate { return { reason: rowOrg ? `the row belongs to organization '${rowOrg}', not the one you administer` - : 'the existing row carries no organization — it applies in every organization, so only a platform administrator may remove it', + : 'the row carries no organization, so it is not one of the organization you administer', meta: { organizationId: rowOrg }, }; } @@ -1297,11 +1298,11 @@ export class DelegatedAdminGate { * 2. the candidates that come back are reduced to the caller's OWN row by * {@link resolveOwnOrganizationRow}. That arm is load-bearing on its own: * a driver with no native tenant scoping returns every organization's - * rows, organization-less ones included. (The SQL driver's NULL arm, - * which also returned organization-less rows, is retired — ADR-0131 D8.) Under a walled posture an + * rows, organization-less ones included (the SQL driver's NULL arm, which + * also returned organization-less rows, is retired — ADR-0131 D8), and an * organization-less business unit is invalid state - * (`organization-scope.ts`), so neither class may answer a - * delegation boundary. + * (`organization-scope.ts`), so neither class may answer a delegation + * boundary. * * Fail closed on both axes: a name that resolves to no row of the caller's * own organization approves nothing, exactly as a misconfigured scope does. diff --git a/packages/plugins/plugin-security/src/explain-asker-gate.test.ts b/packages/plugins/plugin-security/src/explain-asker-gate.test.ts index 5d2ce28d622..a1fc8b712f8 100644 --- a/packages/plugins/plugin-security/src/explain-asker-gate.test.ts +++ b/packages/plugins/plugin-security/src/explain-asker-gate.test.ts @@ -72,6 +72,14 @@ const MEMBER_DEFAULT = defaultPermissionSets.find((p) => p.name === 'member_defa const BOARD = 'qa_ag_board'; const MEMBER = 'usr_ag_member'; +/** + * The organization the rig runs in. A grant applies only in its own + * organization, so the member's grant rows are this organization's and every + * asker acts in it: the explained member is resolved in the asker's + * organization (#20515), and under `single` the claim stands without a + * membership row. + */ +const ORG = 'org_ag'; /** * The member's grant: every verb on the board; a READ policy that admits only @@ -98,17 +106,17 @@ const USER_ADMIN_SET = PermissionSetSchema.parse({ /** `manage_users` alone: no data grant at all (it is not a data-read grant). */ const USERS_ONLY_SET = PermissionSetSchema.parse({ name: 'qa_ag_users_only', objects: {}, systemPermissions: ['manage_users'] }); -const SELF = { userId: MEMBER, positions: [] as string[], permissions: [MEMBER_SET.name] }; -const ADMIN = { userId: 'usr_ag_admin', positions: [] as string[], permissions: [USER_ADMIN_SET.name] }; +const SELF = { userId: MEMBER, tenantId: ORG, positions: [] as string[], permissions: [MEMBER_SET.name] }; +const ADMIN = { userId: 'usr_ag_admin', tenantId: ORG, positions: [] as string[], permissions: [USER_ADMIN_SET.name] }; /** A user administrator held to the member's read policy. */ -const BLIND_ADMIN = { userId: 'usr_ag_blind', positions: [] as string[], permissions: [MEMBER_SET.name, USERS_ONLY_SET.name] }; +const BLIND_ADMIN = { userId: 'usr_ag_blind', tenantId: ORG, positions: [] as string[], permissions: [MEMBER_SET.name, USERS_ONLY_SET.name] }; /** A delegated administrator (D12, no `manage_users`) held to the member's read policy. */ -const DELEGATE = { userId: 'usr_ag_delegate', positions: [] as string[], permissions: [MEMBER_SET.name] }; -const SYSTEM = { isSystem: true }; +const DELEGATE = { userId: 'usr_ag_delegate', tenantId: ORG, positions: [] as string[], permissions: [MEMBER_SET.name] }; +const SYSTEM = { isSystem: true, tenantId: ORG }; /** [#22851] A user administrator with no read grant on the object: its own read of every row is REFUSED. */ -const REFUSED_ADMIN = { userId: 'usr_ag_refused_admin', positions: [] as string[], permissions: [USERS_ONLY_SET.name] }; +const REFUSED_ADMIN = { userId: 'usr_ag_refused_admin', tenantId: ORG, positions: [] as string[], permissions: [USERS_ONLY_SET.name] }; /** [#22851] A delegated administrator (D12) with no read grant on the object: its own read is refused too. */ -const REFUSED_DELEGATE = { userId: 'usr_ag_refused_delegate', positions: [] as string[], permissions: [] as string[] }; +const REFUSED_DELEGATE = { userId: 'usr_ag_refused_delegate', tenantId: ORG, positions: [] as string[], permissions: [] as string[] }; /** * [#22851] Edits, deletes and transfers boards, and may not read them: the * principal whose self-explanation keeps the write path's notion under ruling @@ -119,7 +127,7 @@ const EDIT_ONLY_SET = PermissionSetSchema.parse({ objects: { [BOARD]: { allowEdit: true, allowDelete: true, allowTransfer: true } }, }); const EDIT_ONLY_USER = 'usr_ag_edit_only'; -const EDIT_ONLY = { userId: EDIT_ONLY_USER, positions: [] as string[], permissions: [EDIT_ONLY_SET.name] }; +const EDIT_ONLY = { userId: EDIT_ONLY_USER, tenantId: ORG, positions: [] as string[], permissions: [EDIT_ONLY_SET.name] }; /** Per write verb, a board the edit-only principal's own door writes, so a door that lands changes no other case's row. */ const editOnlyRowOf = (verb: ExplainOperation) => `b_edit_only_${verb}`; @@ -169,10 +177,10 @@ beforeAll(async () => { for (const id of [MEMBER, ADMIN.userId, BLIND_ADMIN.userId, DELEGATE.userId, REFUSED_ADMIN.userId, REFUSED_DELEGATE.userId, EDIT_ONLY_USER]) { await e.insert('sys_user', { id, name: id, email: `${id}@example.test` }, SYS); } - // [C8 stage 8b] The grant-side rows below carry no organization: under `single` - // the engine stamps or refuses such a platform insert (ADR-0131 D9), and this rig - // holds no organization, so they are landed straight through the driver, as the - // residue an older release left. The explainer reads them as before. + // [C8 stage 8b] The catalogue rows below carry no organization: under `single` + // the engine stamps or refuses such a platform insert (ADR-0131 D9), so the + // grant-side rows are landed straight through the driver. The user grants are + // owned by the rig's organization: an organization-less grant applies nowhere. const residue = (o: string, row: Record) => (engine as any).getDriver(o).create(o, row); // The explained member's grant, as the explainer resolves it: a grant row // pointing at a catalogue row; what the set confers is declared in metadata. @@ -181,14 +189,14 @@ beforeAll(async () => { active: true, object_permissions: JSON.stringify({}), system_permissions: JSON.stringify([]), }); await residue('sys_user_permission_set', { - id: 'ups_ag_member', user_id: MEMBER, permission_set: MEMBER_SET.name, + id: 'ups_ag_member', user_id: MEMBER, permission_set: MEMBER_SET.name, organization_id: ORG, }); await residue('sys_permission_set', { id: 'ps_ag_edit_only', name: EDIT_ONLY_SET.name, label: EDIT_ONLY_SET.name, organization_id: null, managed_by: 'admin', active: true, object_permissions: JSON.stringify({}), system_permissions: JSON.stringify([]), }); await residue('sys_user_permission_set', { - id: 'ups_ag_edit_only', user_id: EDIT_ONLY_USER, permission_set: EDIT_ONLY_SET.name, + id: 'ups_ag_edit_only', user_id: EDIT_ONLY_USER, permission_set: EDIT_ONLY_SET.name, organization_id: ORG, }); await e.insert(BOARD, [ { id: HIDDEN, name: 'hidden', owner_id: 'usr_ag_owner' }, diff --git a/packages/plugins/plugin-security/src/explain-engine.test.ts b/packages/plugins/plugin-security/src/explain-engine.test.ts index 87d23d8cb9c..4753e82ea83 100644 --- a/packages/plugins/plugin-security/src/explain-engine.test.ts +++ b/packages/plugins/plugin-security/src/explain-engine.test.ts @@ -444,7 +444,7 @@ describe('explainAccess — record-grained (C2 / ADR-0095)', () => { expect(d.record).toMatchObject({ recordId: 'r1', visible: true }); }); - it('derives PLATFORM_ADMIN posture from the unscoped-grant flag', async () => { + it('derives PLATFORM_ADMIN posture from the platform-grant flag', async () => { // [#15981] Was "from the platform_admin position". The NAME is no longer // that evidence: `positions[]` is the security axis and carries ADR-0057 D4 // `sys_user_position` names from an `apiEnabled` table, so a tenant can mint @@ -801,13 +801,14 @@ describe('posture derivation aligns with enforcement (label-drift elimination)', }); it('a merely-SCOPED admin_full_access grant no longer over-labels as PLATFORM_ADMIN', async () => { - // Name present in `permissions` but NOT held as an unscoped grant and NOT the - // projected platform_admin position → MEMBER, matching enforcement (which - // requires hasPlatformAdminGrant = unscoped admin_full_access user grant). + // Name present in `permissions` but NOT held as the anchoring grant and NOT + // the projected platform_admin position → MEMBER, matching enforcement (which + // requires hasPlatformAdminGrant = the Default Organization's + // admin_full_access user grant). expect(await postureOf({ userId: 'a1', tenantId: 'org1', positions: ['everyone'], permissions: ['admin_full_access'] })).toBe('MEMBER'); }); - it('the unscoped-grant flag (hasPlatformAdminGrant) DOES yield PLATFORM_ADMIN', async () => { + it('the platform-grant flag (hasPlatformAdminGrant) DOES yield PLATFORM_ADMIN', async () => { expect(await postureOf({ userId: 'a1', tenantId: 'org1', positions: ['everyone'], permissions: ['admin_full_access'], hasPlatformAdminGrant: true })).toBe('PLATFORM_ADMIN'); }); @@ -815,7 +816,7 @@ describe('posture derivation aligns with enforcement (label-drift elimination)', // This assertion is INVERTED from what it pinned before, deliberately. It // used to read "the PROJECTED platform_admin built-in position still yields // PLATFORM_ADMIN", whose premise was that the name is only ever projected - // from the unscoped `admin_full_access` grant. Ruling A made `positions[]` + // from the anchoring `admin_full_access` grant. Ruling A made `positions[]` // the security axis, so it now also carries ADR-0057 D4 // `sys_user_position` names — and that table is `apiEnabled` with // unconstrained values, so the name can be MINTED by a tenant. The genuine @@ -897,19 +898,25 @@ function makeGrantQl(tables: Rows) { const NOW = Date.parse('2026-07-10T12:00:00Z'); describe('buildContextForUser', () => { + // A grant applies only in its own organization (an organization-less row + // applies nowhere), so the fixtures below own their grant rows by ORG and + // explain the user IN it, unless a case says otherwise. + const ORG = 'org1'; const ql = makeGrantQl({ - sys_user_position: [{ user_id: 'u2', position: 'hr_specialist' }], - sys_user_permission_set: [{ user_id: 'u2', permission_set_id: 'ps1', permission_set: 'payroll_reader' }], + sys_user_position: [{ user_id: 'u2', organization_id: ORG, position: 'hr_specialist' }], + sys_user_permission_set: [{ user_id: 'u2', organization_id: ORG, 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', permission_set: 'admin_full_access' }], + it('derives hasPlatformAdminGrant from the DEFAULT ORGANIZATION\'s admin_full_access user grant (matches resolveAuthzContext)', async () => { + const qlAnchored = makeGrantQl({ + sys_organization: [{ id: 'org_default', slug: 'default' }], + sys_user_permission_set: [{ user_id: 'u2', permission_set_id: 'psAdmin', permission_set: 'admin_full_access', organization_id: 'org_default' }], sys_permission_set: [{ id: 'psAdmin', name: 'admin_full_access' }], }); - const ctx = await buildContextForUser(qlUnscoped, 'u2'); + // Explained with NO active organization: the anchoring grant applies + // whatever the tenant, because platform standing is the deployment's. + const ctx = await buildContextForUser(qlAnchored, 'u2'); expect(ctx.hasPlatformAdminGrant).toBe(true); expect(ctx.permissions).toContain('admin_full_access'); // The resolver PROJECTS the built-in position from the same grant (ADR-0068 @@ -918,6 +925,22 @@ describe('buildContextForUser', () => { expect(ctx.posture).toBe('PLATFORM_ADMIN'); }); + it('an organization-LESS admin_full_access user grant anchors nothing and confers nothing, in no organization or in one', async () => { + const qlOrphan = makeGrantQl({ + sys_organization: [{ id: 'org_default', slug: 'default' }], + // organization_id absent → the row belongs to no organization + 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' }], + }); + for (const tenantId of [undefined, 'org_default']) { + const ctx = await buildContextForUser(qlOrphan, 'u2', NOW, tenantId); + expect(ctx.hasPlatformAdminGrant, String(tenantId)).toBe(false); + expect(ctx.permissions, String(tenantId)).not.toContain('admin_full_access'); + expect(ctx.positions, String(tenantId)).not.toContain('platform_admin'); + expect(ctx.posture, String(tenantId)).toBe('MEMBER'); + } + }); + it('[#11974 / #11663 L4, P8] CONFIG-derived standing reaches the panel: a declared+verified admin with ZERO grant rows explains as PLATFORM_ADMIN', async () => { // Under walled postures the bootstrap mints no grant row any more, so this // is the ONLY shape a fresh walled deployment's administrator has. Explain @@ -965,7 +988,7 @@ describe('buildContextForUser', () => { }); it('reconstructs positions + direct grants + the everyone anchor', async () => { - const ctx = await buildContextForUser(ql, 'u2'); + const ctx = await buildContextForUser(ql, 'u2', NOW, ORG); expect(ctx).toEqual({ userId: 'u2', positions: ['hr_specialist', 'everyone'], @@ -990,11 +1013,11 @@ describe('buildContextForUser', () => { it('surfaces delegation provenance for a position held via a delegated_from row (ADR-0091 D3)', async () => { const qlDelegated = makeGrantQl({ sys_user_position: [ - { user_id: 'u2', position: 'hr_specialist' }, - { user_id: 'u2', position: 'approver', delegated_from: 'u_boss', valid_until: '2026-07-20T00:00:00Z' }, + { user_id: 'u2', organization_id: ORG, position: 'hr_specialist' }, + { user_id: 'u2', organization_id: ORG, position: 'approver', delegated_from: 'u_boss', valid_until: '2026-07-20T00:00:00Z' }, ], }); - const ctx = await buildContextForUser(qlDelegated, 'u2', NOW); + const ctx = await buildContextForUser(qlDelegated, 'u2', NOW, ORG); expect(ctx.positions).toEqual(['hr_specialist', 'approver', 'everyone']); expect(ctx.delegatedPositions).toEqual([ { name: 'approver', from: 'u_boss', until: '2026-07-20T00:00:00Z' }, @@ -1004,15 +1027,15 @@ describe('buildContextForUser', () => { it('filters grants outside their validity window and reports them as expired (ADR-0091 D2)', async () => { const qlWindowed = makeGrantQl({ sys_user_position: [ - { user_id: 'u2', position: 'hr_specialist' }, - { user_id: 'u2', position: 'payroll_approver', valid_until: '2026-07-01T00:00:00Z' }, + { user_id: 'u2', organization_id: ORG, position: 'hr_specialist' }, + { user_id: 'u2', organization_id: ORG, position: 'payroll_approver', valid_until: '2026-07-01T00:00:00Z' }, // Pending (future valid_from) is filtered but NOT reported as expired. - { user_id: 'u2', position: 'auditor', valid_from: '2026-08-01T00:00:00Z' }, + { user_id: 'u2', organization_id: ORG, position: 'auditor', valid_from: '2026-08-01T00:00:00Z' }, ], sys_user_permission_set: [ - { user_id: 'u2', permission_set_id: 'ps1', permission_set: 'payroll_reader' }, + { user_id: 'u2', organization_id: ORG, permission_set_id: 'ps1', permission_set: 'payroll_reader' }, { - user_id: 'u2', permission_set_id: 'ps2', permission_set: 'quarter_close_admin', + user_id: 'u2', organization_id: ORG, permission_set_id: 'ps2', permission_set: 'quarter_close_admin', valid_until: '2026-06-01T00:00:00Z', }, ], @@ -1021,7 +1044,7 @@ describe('buildContextForUser', () => { { id: 'ps2', name: 'quarter_close_admin' }, ], }); - const ctx = await buildContextForUser(qlWindowed, 'u2', NOW); + const ctx = await buildContextForUser(qlWindowed, 'u2', NOW, ORG); expect(ctx.positions).toEqual(['hr_specialist', 'everyone']); expect(ctx.permissions).toEqual(['payroll_reader']); expect(ctx.droppedGrants).toEqual([ @@ -1037,15 +1060,15 @@ describe('buildContextForUser', () => { it('reports a directly-granted permission set whose catalogue row is deactivated (ADR-0049 / #8714)', async () => { const qlDeactivated = makeGrantQl({ sys_user_permission_set: [ - { user_id: 'u2', permission_set_id: 'ps1', permission_set: 'payroll_reader' }, - { user_id: 'u2', permission_set_id: 'psOff', permission_set: 'crm_full' }, + { user_id: 'u2', organization_id: ORG, permission_set_id: 'ps1', permission_set: 'payroll_reader' }, + { user_id: 'u2', organization_id: ORG, permission_set_id: 'psOff', permission_set: 'crm_full' }, ], sys_permission_set: [ { id: 'ps1', name: 'payroll_reader' }, { id: 'psOff', name: 'crm_full', active: false }, ], }); - const ctx = await buildContextForUser(qlDeactivated, 'u2', NOW); + const ctx = await buildContextForUser(qlDeactivated, 'u2', NOW, ORG); // The resolver's fail-closed dropping is untouched… expect(ctx.permissions).toEqual(['payroll_reader']); // …and the dropped row is REPORTED, with the deactivated reason. @@ -1057,8 +1080,8 @@ describe('buildContextForUser', () => { it('reports a held position whose sys_position row is deactivated — including the 0/1 storage shape (ADR-0049 / #8714)', async () => { const qlDeactivated = makeGrantQl({ sys_user_position: [ - { user_id: 'u2', position: 'hr_specialist' }, - { user_id: 'u2', position: 'field_auditor' }, + { user_id: 'u2', organization_id: ORG, position: 'hr_specialist' }, + { user_id: 'u2', organization_id: ORG, position: 'field_auditor' }, ], // 0 is what the primary driver hands back for a stored false — the // shared isRowActive predicate reads it as deactivated (#8613). @@ -1067,7 +1090,7 @@ describe('buildContextForUser', () => { { id: 'pos_fa', name: 'field_auditor', active: 0 }, ], }); - const ctx = await buildContextForUser(qlDeactivated, 'u2', NOW); + const ctx = await buildContextForUser(qlDeactivated, 'u2', NOW, ORG); expect(ctx.positions).toEqual(['hr_specialist', 'everyone']); expect(ctx.droppedGrants).toEqual([ { kind: 'position', name: 'field_auditor', state: 'deactivated' }, @@ -1077,11 +1100,11 @@ describe('buildContextForUser', () => { it('a row both expired AND deactivated reports expired — one reason per row, resolver drop order (#8714)', async () => { const qlBoth = makeGrantQl({ sys_user_permission_set: [ - { user_id: 'u2', permission_set_id: 'psOff', permission_set: 'crm_full', valid_until: '2026-06-01T00:00:00Z' }, + { user_id: 'u2', organization_id: ORG, permission_set_id: 'psOff', permission_set: 'crm_full', valid_until: '2026-06-01T00:00:00Z' }, ], sys_permission_set: [{ id: 'psOff', name: 'crm_full', active: false }], }); - const ctx = await buildContextForUser(qlBoth, 'u2', NOW); + const ctx = await buildContextForUser(qlBoth, 'u2', NOW, ORG); expect(ctx.droppedGrants).toEqual([ { kind: 'permission_set', name: 'crm_full', state: 'expired', until: '2026-06-01T00:00:00Z' }, ]); @@ -1093,16 +1116,16 @@ describe('buildContextForUser', () => { // and the explainer must side with what the resolver enforces. it('a set and a position the LEDGER switched off are reported deactivated, whatever their rows say', async () => { const ql = makeGrantQl({ - sys_user_position: [{ user_id: 'u2', position: 'field_auditor' }], + sys_user_position: [{ user_id: 'u2', organization_id: ORG, position: 'field_auditor' }], sys_position: [{ id: 'pos_fa', name: 'field_auditor', active: true }], - sys_user_permission_set: [{ user_id: 'u2', permission_set_id: 'psOff', permission_set: 'crm_full' }], + sys_user_permission_set: [{ user_id: 'u2', organization_id: ORG, permission_set_id: 'psOff', permission_set: 'crm_full' }], sys_permission_set: [{ id: 'psOff', name: 'crm_full', active: true }], sys_metadata_activation: [ { metadata_type: 'position', name: 'field_auditor', active: false }, { metadata_type: 'permission', name: 'crm_full', active: 0 }, ], }); - const ctx = await buildContextForUser(ql, 'u2', NOW); + const ctx = await buildContextForUser(ql, 'u2', NOW, ORG); // Enforcement dropped both… expect(ctx.positions).toEqual(['everyone']); expect(ctx.permissions).toEqual([]); @@ -1115,13 +1138,13 @@ describe('buildContextForUser', () => { it("a catalog ROW's `active: false` with no ledger row is in effect, and explain reports nothing dropped", async () => { const ql = makeGrantQl({ - sys_user_position: [{ user_id: 'u2', position: 'field_auditor' }], + sys_user_position: [{ user_id: 'u2', organization_id: ORG, position: 'field_auditor' }], sys_position: [{ id: 'pos_fa', name: 'field_auditor', active: false }], - sys_user_permission_set: [{ user_id: 'u2', permission_set_id: 'psOff', permission_set: 'crm_full' }], + sys_user_permission_set: [{ user_id: 'u2', organization_id: ORG, permission_set_id: 'psOff', permission_set: 'crm_full' }], sys_permission_set: [{ id: 'psOff', name: 'crm_full', active: false }], sys_metadata_activation: [], }); - const ctx = await buildContextForUser(ql, 'u2', NOW); + const ctx = await buildContextForUser(ql, 'u2', NOW, ORG); expect(ctx.positions).toEqual(['field_auditor', 'everyone']); expect(ctx.permissions).toEqual(['crm_full']); expect(ctx.droppedGrants).toEqual([]); @@ -1132,23 +1155,23 @@ describe('buildContextForUser', () => { // neither name; the ledger switches one of them off all the same. const ql = makeGrantQl({ sys_user_permission_set: [ - { user_id: 'u2', permission_set_id: 'ps_gone', permission_set: 'gone_set', valid_until: '2026-06-01T00:00:00Z' }, - { user_id: 'u2', permission_set_id: 'ps_gone2', permission_set: 'gone_off' }, + { user_id: 'u2', organization_id: ORG, permission_set_id: 'ps_gone', permission_set: 'gone_set', valid_until: '2026-06-01T00:00:00Z' }, + { user_id: 'u2', organization_id: ORG, permission_set_id: 'ps_gone2', permission_set: 'gone_off' }, ], sys_metadata_activation: [{ metadata_type: 'permission', name: 'gone_off', active: false }], }); - const ctx = await buildContextForUser(ql, 'u2', NOW); + const ctx = await buildContextForUser(ql, 'u2', NOW, ORG); expect(ctx.droppedGrants).toEqual([]); }); it('an absent `active` column keeps granting and is NOT reported — absent is ACTIVE (ADR-0049)', async () => { const qlAbsent = makeGrantQl({ - sys_user_position: [{ user_id: 'u2', position: 'hr_specialist' }], + sys_user_position: [{ user_id: 'u2', organization_id: ORG, position: 'hr_specialist' }], sys_position: [{ id: 'pos_hr', name: 'hr_specialist' }], - sys_user_permission_set: [{ user_id: 'u2', permission_set_id: 'ps1', permission_set: 'payroll_reader' }], + sys_user_permission_set: [{ user_id: 'u2', organization_id: ORG, permission_set_id: 'ps1', permission_set: 'payroll_reader' }], sys_permission_set: [{ id: 'ps1', name: 'payroll_reader' }], }); - const ctx = await buildContextForUser(qlAbsent, 'u2', NOW); + const ctx = await buildContextForUser(qlAbsent, 'u2', NOW, ORG); expect(ctx.positions).toEqual(['hr_specialist', 'everyone']); expect(ctx.permissions).toEqual(['payroll_reader']); expect(ctx.droppedGrants).toEqual([]); @@ -1194,13 +1217,16 @@ describe('buildContextForUser ↔ resolveUserAuthzGrants parity (#6352)', () => // (ADR-0057 D4), a permission set the POSITION carries // (`sys_position_permission_set`), and the cloud ADR-0024 `ai_seat` synthesis. name: 'org role + position-bound permission set + ai_seat', + // A grant applies only in its own organization, so the grant rows are + // org1's and both sides resolve in org1. + tenantId: 'org1', tables: { sys_user: [{ id: 'u2', email: 'u2@example.com', ai_access: true }], sys_member: [{ user_id: 'u2', organization_id: 'org1', role: 'admin' }], - sys_user_position: [{ user_id: 'u2', position: 'hr_specialist' }], + sys_user_position: [{ user_id: 'u2', organization_id: 'org1', 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', permission_set: 'payroll_reader' }], + sys_user_permission_set: [{ user_id: 'u2', organization_id: 'org1', 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'] }, @@ -1236,10 +1262,13 @@ describe('buildContextForUser ↔ resolveUserAuthzGrants parity (#6352)', () => }, }, { - // [ADR-0068 D2] The platform_admin derivation, both polarities. - name: 'unscoped admin_full_access derives platform_admin', + // [ADR-0068 D2] The platform_admin derivation, both polarities. The + // anchor is the Default Organization's grant, and it applies with no + // active organization. + name: 'the Default Organization\'s admin_full_access derives platform_admin', tables: { - sys_user_permission_set: [{ user_id: 'u2', permission_set_id: 'psAdmin', permission_set: 'admin_full_access' }], + sys_organization: [{ id: 'org_default', slug: 'default' }], + sys_user_permission_set: [{ user_id: 'u2', permission_set_id: 'psAdmin', permission_set: 'admin_full_access', organization_id: 'org_default' }], sys_permission_set: [{ id: 'psAdmin', name: 'admin_full_access' }], }, expected: { @@ -1251,6 +1280,24 @@ describe('buildContextForUser ↔ resolveUserAuthzGrants parity (#6352)', () => hasPlatformAdminGrant: true, }, }, + { + // ...and an organization-less grant of the same set anchors nothing and + // applies nowhere: it confers neither the rung nor the set. + name: 'organization-less admin_full_access derives nothing', + tables: { + sys_organization: [{ id: 'org_default', slug: 'default' }], + 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: { + positions: ['everyone'], + permissions: [], + systemPermissions: [], + accessible_org_ids: [], + posture: 'MEMBER', + hasPlatformAdminGrant: false, + }, + }, { name: 'org-scoped admin_full_access does NOT derive platform_admin', // [#20515] Resolved IN org1, where the scoped set applies — with no active @@ -1292,18 +1339,21 @@ describe('buildContextForUser ↔ resolveUserAuthzGrants parity (#6352)', () => }, { // [ADR-0091 D2] Validity windows drop rows on BOTH sides, including the - // platform_admin derivation: an EXPIRED unscoped admin_full_access grant - // must not confer the rung. Only the explain-side annotation survives. + // platform_admin derivation: an EXPIRED admin_full_access grant of the + // Default Organization (org1 here) must not confer the rung. Only the + // explain-side annotation survives. name: 'ADR-0091 windows: expired admin grant confers nothing', + tenantId: 'org1', tables: { + sys_organization: [{ id: 'org1', slug: 'default' }], sys_user_position: [ - { user_id: 'u2', position: 'hr_specialist' }, - { user_id: 'u2', position: 'payroll_approver', valid_until: '2026-07-01T00:00:00Z' }, - { user_id: 'u2', position: 'auditor', valid_from: '2026-08-01T00:00:00Z' }, + { user_id: 'u2', organization_id: 'org1', position: 'hr_specialist' }, + { user_id: 'u2', organization_id: 'org1', position: 'payroll_approver', valid_until: '2026-07-01T00:00:00Z' }, + { user_id: 'u2', organization_id: 'org1', position: 'auditor', valid_from: '2026-08-01T00:00:00Z' }, ], sys_user_permission_set: [ - { 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' }, + { user_id: 'u2', organization_id: 'org1', permission_set_id: 'ps1', permission_set: 'payroll_reader' }, + { user_id: 'u2', organization_id: 'org1', permission_set_id: 'psAdmin', permission_set: 'admin_full_access', valid_until: '2026-06-01T00:00:00Z' }, ], sys_permission_set: [ { id: 'ps1', name: 'payroll_reader' }, @@ -1349,28 +1399,31 @@ describe('buildContextForUser ↔ resolveUserAuthzGrants parity (#6352)', () => it('the explain-only surface is ADDITIVE — it annotates rows, it never changes the verdict', async () => { const tables: Rows = { sys_user_position: [ - { user_id: 'u2', position: 'hr_specialist' }, - { user_id: 'u2', position: 'approver', delegated_from: 'u_boss', valid_until: '2026-07-20T00:00:00Z' }, - { user_id: 'u2', position: 'payroll_approver', valid_until: '2026-07-01T00:00:00Z' }, + { user_id: 'u2', organization_id: 'org1', position: 'hr_specialist' }, + { user_id: 'u2', organization_id: 'org1', position: 'approver', delegated_from: 'u_boss', valid_until: '2026-07-20T00:00:00Z' }, + { user_id: 'u2', organization_id: 'org1', position: 'payroll_approver', valid_until: '2026-07-01T00:00:00Z' }, // [commit 42b05af89] a held position whose catalogue row is switched off - { user_id: 'u2', position: 'field_auditor' }, + { user_id: 'u2', organization_id: 'org1', position: 'field_auditor' }, ], sys_position: [{ id: 'pos_fa', name: 'field_auditor', active: false }], sys_user_permission_set: [ { - user_id: 'u2', permission_set_id: 'ps2', permission_set: 'quarter_close_admin', + user_id: 'u2', organization_id: 'org1', permission_set_id: 'ps2', permission_set: 'quarter_close_admin', valid_until: '2026-06-01T00:00:00Z', }, // [commit 42b05af89] a held direct grant whose SET's catalogue row is switched off - { user_id: 'u2', permission_set_id: 'psOff', permission_set: 'crm_full' }, + { user_id: 'u2', organization_id: 'org1', permission_set_id: 'psOff', permission_set: 'crm_full' }, ], sys_permission_set: [ { id: 'ps2', name: 'quarter_close_admin' }, { id: 'psOff', name: 'crm_full', active: false }, ], }; - const grants = await resolveUserAuthzGrants(makeGrantQl(tables), 'u2', { nowMs: NOW }); - const ctx = await buildContextForUser(makeGrantQl(tables), 'u2', NOW); + // Both sides resolve in org1, the organization the grant rows are owned by. + const grants = await resolveUserAuthzGrants(makeGrantQl(tables), 'u2', { nowMs: NOW, tenantId: 'org1' }); + const ctx = await buildContextForUser(makeGrantQl(tables), 'u2', NOW, 'org1'); + // Non-vacuity: the live grant the comparison below is about does resolve. + expect(ctx.positions).toContain('hr_specialist'); // The annotations are non-empty… expect(ctx.delegatedPositions).toEqual([ @@ -1528,8 +1581,11 @@ describe('buildContextForUser bypasses the #11971 grants cache (ruled bypass lis const saved = process.env[TTL_ENV]; process.env[TTL_ENV] = '3600000'; // one hour — nothing here may pass by expiry try { + // The Default Organization's grant: it anchors platform standing and + // applies with no active organization, so the cached answer is the rung. const tables: Rows = { - sys_user_permission_set: [{ user_id: 'u2', permission_set_id: 'psAdmin', permission_set: 'admin_full_access' }], + sys_organization: [{ id: 'org_default', slug: 'default' }], + sys_user_permission_set: [{ user_id: 'u2', permission_set_id: 'psAdmin', permission_set: 'admin_full_access', organization_id: 'org_default' }], sys_permission_set: [{ id: 'psAdmin', name: 'admin_full_access' }], }; const ql = makeSeamGrantQl(tables); @@ -1537,6 +1593,7 @@ describe('buildContextForUser bypasses the #11971 grants cache (ruled bypass lis // Populate the cache through the SAME resolver every request uses. const cached = await resolveUserAuthzGrants(ql, 'u2', { nowMs: NOW }); expect(cached.permissions).toContain('admin_full_access'); + expect(cached.posture).toBe('PLATFORM_ADMIN'); // Revoke WITHOUT an engine write: no seam reports it, so nothing // retires the entry and the cached path keeps the old answer… @@ -1835,8 +1892,9 @@ describe('[#18253] explain refuses an object that does not exist', () => { // ─── [#20515] the explainer resolves IN an organization, as enforcement does ── // -// The resolver applies an organization-scoped grant only while that -// organization is the active tenant; with none, only global grants. The +// The resolver applies a grant only while its organization is the active +// tenant; with none, no stored grant applies, and an organization-less row +// applies nowhere. The // explainer used to reach every organization's grants by passing NO tenant — // the very resolution that let a member removed from an organization keep that // organization's `manage_metadata`. There is no "every organization" option to @@ -1850,38 +1908,40 @@ describe('[#20515] buildContextForUser and resolveDelegatorContext resolve in an sys_user_permission_set: [ { 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' }, + // Organization-less: invalid state since the 18.0 ownership ceremony, and + // it confers nothing in any resolution. + { user_id: 'u_x', permission_set_id: 'ps_orphan', permission_set: 'orphan_readers' }, ], sys_permission_set: [ { id: 'ps_alpha', name: 'alpha_metadata_editors', system_permissions: ['manage_metadata'] }, { id: 'ps_beta', name: 'beta_readers' }, - { id: 'ps_global', name: 'global_readers' }, + { id: 'ps_orphan', name: 'orphan_readers' }, ], }); - it('with no organization it explains what enforcement answers with none: the global grants only', async () => { + it('with no organization it explains what enforcement answers with none: no stored grant at all', async () => { const ctx = await buildContextForUser(makeGrantQl(tables()), 'u_x', NOW); - expect([...ctx.permissions].sort()).toEqual(['global_readers']); + expect([...ctx.permissions].sort()).toEqual([]); expect(ctx.systemPermissions).not.toContain('manage_metadata'); // …and it is the enforcement resolver's own answer for that state. const enforced = await resolveUserAuthzGrants(makeGrantQl(tables()), 'u_x', { nowMs: NOW }); expect(ctx.permissions).toEqual(enforced.permissions); }); - it('in an organization it explains that organization\'s grants beside the global ones, never another\'s', async () => { + it('in an organization it explains that organization\'s grants alone, never another\'s nor the organization-less one', async () => { const inAlpha = await buildContextForUser(makeGrantQl(tables()), 'u_x', NOW, ALPHA); - expect([...inAlpha.permissions].sort()).toEqual(['alpha_metadata_editors', 'global_readers']); + expect([...inAlpha.permissions].sort()).toEqual(['alpha_metadata_editors']); expect(inAlpha.systemPermissions).toContain('manage_metadata'); const inBeta = await buildContextForUser(makeGrantQl(tables()), 'u_x', NOW, BETA); - expect([...inBeta.permissions].sort()).toEqual(['beta_readers', 'global_readers']); + expect([...inBeta.permissions].sort()).toEqual(['beta_readers']); expect(inBeta.systemPermissions).not.toContain('manage_metadata'); }); /** * The delegator leg is an ENFORCEMENT input (the D10 intersection), not only * an explanation: `SecurityPlugin` resolves it through this function on every - * on-behalf-of request. Resolved with no tenant it would hold only the - * delegator's global grants — never what the delegator holds in the + * on-behalf-of request. Resolved with no tenant it would hold none of the + * delegator's stored grants — never what the delegator holds in the * organization the request runs in. */ describe('resolveDelegatorContext: the delegator is resolved in the live principal\'s organization', () => { @@ -1905,15 +1965,15 @@ describe('[#20515] buildContextForUser and resolveDelegatorContext resolve in an ); expect(res.kind).toBe('resolved'); const ctx = (res as { kind: 'resolved'; context: any }).context; - expect([...ctx.permissions].sort()).toEqual(['alpha_metadata_editors', 'global_readers']); + expect([...ctx.permissions].sort()).toEqual(['alpha_metadata_editors']); expect(ctx.tenantId).toBe(ALPHA); }); - it('live principal with no organization → the delegator\'s global grants only', async () => { + it('live principal with no organization → none of the delegator\'s stored grants', async () => { const res = await resolveDelegatorContext(delegatorQl(), { userId: 'agent_1', onBehalfOf: { userId: 'u_x' } }, NOW); expect(res.kind).toBe('resolved'); const ctx = (res as { kind: 'resolved'; context: any }).context; - expect([...ctx.permissions].sort()).toEqual(['global_readers']); + expect([...ctx.permissions].sort()).toEqual([]); expect(ctx.systemPermissions).not.toContain('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 505f5628a4f..2277b202566 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 @@ -122,13 +122,22 @@ function makeAuthzQl(tables: Record>>) { }, tables); } +/** + * Both shapes' grant rows are owned by `HOME_ORG`, which is also the Default + * Organization: a grant applies only in its own organization (an + * organization-less row confers nothing), and platform standing is anchored + * only by the Default Organization's `admin_full_access` grant. The principal + * is resolved in `HOME_ORG` ({@link buildInHome}), so the name-only position + * row applies and reaches the array the panel prints. + */ function authzTables(shape: 'name-only' | 'genuine') { return { + sys_organization: [{ id: HOME_ORG, slug: 'default' }], sys_user: [{ id: USER, email: 'subject@example.com', email_verified: true }], sys_member: [{ organization_id: HOME_ORG, user_id: USER, role: 'member' }], sys_user_position: shape === 'name-only' - ? [{ user_id: USER, position: BUILTIN_IDENTITY_PLATFORM_ADMIN, organization_id: null }] + ? [{ user_id: USER, position: BUILTIN_IDENTITY_PLATFORM_ADMIN, organization_id: HOME_ORG }] : [], sys_position: shape === 'name-only' @@ -137,16 +146,19 @@ function authzTables(shape: 'name-only' | 'genuine') { sys_position_permission_set: [], sys_user_permission_set: shape === 'genuine' - ? [{ user_id: USER, permission_set_id: PS_ADMIN, permission_set: ADMIN_FULL_ACCESS, organization_id: null }] + ? [{ user_id: USER, permission_set_id: PS_ADMIN, permission_set: ADMIN_FULL_ACCESS, organization_id: HOME_ORG }] : [], sys_permission_set: [{ id: PS_ADMIN, name: ADMIN_FULL_ACCESS, active: true }], }; } +/** The real principal builder, resolving the user in `HOME_ORG`. */ +const buildInHome = (ql: unknown) => buildContextForUser(ql as any, USER, Date.now(), HOME_ORG); + describe('[#15981] the SHIPPING explain path was already gated — a regression guard, not a repair', () => { it('a D4 row spelling the built-in name is REPORTED as MEMBER, and the rung agrees', async () => { const ql = makeAuthzQl(authzTables('name-only')); - const context = await buildContextForUser(ql as any, USER); + const context = await buildInHome(ql); // The premise: the name really is on the array the panel prints. expect(context.positions, JSON.stringify(context.positions)).toContain( @@ -159,9 +171,9 @@ describe('[#15981] the SHIPPING explain path was already gated — a regression expect(await postureOf(context)).toBe('MEMBER'); }); - it('CONTROL — a genuine unscoped admin_full_access grant is still reported as PLATFORM_ADMIN', async () => { + it('CONTROL — a genuine Default Organization admin_full_access grant is still reported as PLATFORM_ADMIN', async () => { const ql = makeAuthzQl(authzTables('genuine')); - const context = await buildContextForUser(ql as any, USER); + const context = await buildInHome(ql); expect(context.positions).toContain(BUILTIN_IDENTITY_PLATFORM_ADMIN); expect(context.hasPlatformAdminGrant).toBe(true); @@ -169,11 +181,23 @@ describe('[#15981] the SHIPPING explain path was already gated — a regression expect(await postureOf(context)).toBe('PLATFORM_ADMIN'); }); + it('the same admin_full_access grant with NO organization anchors nothing, and is reported as MEMBER', async () => { + const tables = authzTables('genuine'); + tables.sys_user_permission_set = tables.sys_user_permission_set.map((r) => ({ ...r, organization_id: null })) as never; + const ql = makeAuthzQl(tables); + const context = await buildInHome(ql); + + expect(context.positions).not.toContain(BUILTIN_IDENTITY_PLATFORM_ADMIN); + expect(context.hasPlatformAdminGrant).toBe(false); + expect(await hasPlatformAdminStanding(ql as any, USER)).toBe(false); + expect(await postureOf(context)).toBe('MEMBER'); + }); + it('the two shapes are INDISTINGUISHABLE by name and separable only by the rung', async () => { const nameOnly = makeAuthzQl(authzTables('name-only')); const genuine = makeAuthzQl(authzTables('genuine')); - const a = await buildContextForUser(nameOnly as any, USER); - const b = await buildContextForUser(genuine as any, USER); + const a = await buildInHome(nameOnly); + const b = await buildInHome(genuine); expect(a.positions).toContain(BUILTIN_IDENTITY_PLATFORM_ADMIN); expect(b.positions).toContain(BUILTIN_IDENTITY_PLATFORM_ADMIN); @@ -200,7 +224,7 @@ describe('[#15981] the FALLBACK branch — reachable on a posture-less context, }); }); - it('the unscoped-grant flag alone STILL yields PLATFORM_ADMIN — the evidence that survives', async () => { + it('the platform-grant flag alone STILL yields PLATFORM_ADMIN — the evidence that survives', async () => { // `buildContextForUser` sets this from `grants.posture === 'PLATFORM_ADMIN'`, // so the fallback's remaining input is the rung itself. expect( diff --git a/packages/plugins/plugin-security/src/explain-principal-person.test.ts b/packages/plugins/plugin-security/src/explain-principal-person.test.ts index 05d0f396ca3..ec632f1a1be 100644 --- a/packages/plugins/plugin-security/src/explain-principal-person.test.ts +++ b/packages/plugins/plugin-security/src/explain-principal-person.test.ts @@ -68,16 +68,23 @@ const GONE = 'usr_pp_gone'; const LIN = 'usr_pp_lin'; const DELEGATED_POSITION = 'qa_pp_approver'; const DELEGATED_UNTIL = '2099-01-01T00:00:00.000Z'; - -const DIRECTORY_ADMIN = { userId: 'usr_pp_directory_admin', positions: [] as string[], permissions: [DIRECTORY_SET.name] }; -const MASKED_ADMIN = { userId: 'usr_pp_masked_admin', positions: [] as string[], permissions: [MASKED_DIRECTORY_SET.name] }; -const BLIND_ADMIN = { userId: 'usr_pp_blind_admin', positions: [] as string[], permissions: [USERS_ONLY_SET.name] }; -const SYSTEM = { isSystem: true }; +/** + * The organization the rig runs in. A grant applies only in its own + * organization, so Grace's and Lin's grant rows are this organization's and + * every asker acts in it; under `single` the claim stands without a + * membership row. + */ +const ORG = 'org_pp'; + +const DIRECTORY_ADMIN = { userId: 'usr_pp_directory_admin', tenantId: ORG, positions: [] as string[], permissions: [DIRECTORY_SET.name] }; +const MASKED_ADMIN = { userId: 'usr_pp_masked_admin', tenantId: ORG, positions: [] as string[], permissions: [MASKED_DIRECTORY_SET.name] }; +const BLIND_ADMIN = { userId: 'usr_pp_blind_admin', tenantId: ORG, positions: [] as string[], permissions: [USERS_ONLY_SET.name] }; +const SYSTEM = { isSystem: true, tenantId: ORG }; /** Ada explaining herself, holding the directory. */ -const ADA_SELF = { userId: ADA, positions: [] as string[], permissions: [DIRECTORY_SET.name] }; +const ADA_SELF = { userId: ADA, tenantId: ORG, positions: [] as string[], permissions: [DIRECTORY_SET.name] }; /** An agent holding the directory, acting on behalf of `delegator` (ADR-0090 D10). */ const agentFor = (delegator: string) => ({ - userId: AGENT, positions: [] as string[], permissions: [DIRECTORY_SET.name], + userId: AGENT, tenantId: ORG, positions: [] as string[], permissions: [DIRECTORY_SET.name], principalKind: 'agent', onBehalfOf: { userId: delegator }, }); /** The same agent without the directory: its own leg holds the baseline alone. */ @@ -129,22 +136,23 @@ beforeAll(async () => { ], SYS); // Grace holds the directory too, so an agent acting for her keeps it under // the D10 intersection; the plain user holds nothing beyond the baseline. - // [C8 stage 8b] The three grant-side rows below carry no organization: under + // [C8 stage 8b] The catalogue row below carries no organization: under // `single` the engine stamps or refuses such a platform insert (ADR-0131 D9), - // and this rig holds no organization, so they are landed straight through the - // driver, as the residue an older release left. The explainer reads them as before. + // so the three grant-side rows are landed straight through the driver. The two + // user grants are owned by the rig's organization: an organization-less grant + // applies nowhere. const residue = (o: string, row: Record) => (engine as any).getDriver(o).create(o, row); await residue('sys_permission_set', { id: 'ps_pp_directory', name: DIRECTORY_SET.name, label: DIRECTORY_SET.name, organization_id: null, managed_by: 'admin', active: true, object_permissions: JSON.stringify({}), system_permissions: JSON.stringify([]), }); await residue('sys_user_permission_set', { - id: 'ups_pp_grace', user_id: GRACE, permission_set: DIRECTORY_SET.name, + id: 'ups_pp_grace', user_id: GRACE, permission_set: DIRECTORY_SET.name, organization_id: ORG, }); // A position Lin holds because Grace delegated it, until a date far ahead. await residue('sys_user_position', { id: 'up_pp_lin_delegated', user_id: LIN, position: DELEGATED_POSITION, - delegated_from: GRACE, valid_until: DELEGATED_UNTIL, + delegated_from: GRACE, valid_until: DELEGATED_UNTIL, organization_id: ORG, }); await e.insert(BOARD, [{ id: 'b_pp_1', name: 'one' }], SYS); 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 ab1b99a0a1d..95c551b04d9 100644 --- a/packages/plugins/plugin-security/src/grant-permission-set-name.ts +++ b/packages/plugins/plugin-security/src/grant-permission-set-name.ts @@ -176,20 +176,6 @@ export function grantSetNameOf(row: unknown): string | undefined { 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 the way every grant reader already reads it - * (`!organization_id`). - */ -export 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); -} - interface GrantNameEngine { registerHook?: ( event: string, diff --git a/packages/plugins/plugin-security/src/grant-readers-unnamed-grant.test.ts b/packages/plugins/plugin-security/src/grant-readers-unnamed-grant.test.ts index 13633aaf183..3e7218d9aeb 100644 --- a/packages/plugins/plugin-security/src/grant-readers-unnamed-grant.test.ts +++ b/packages/plugins/plugin-security/src/grant-readers-unnamed-grant.test.ts @@ -13,7 +13,7 @@ * Each reader takes its fail-closed direction: * * - **explain** reports nothing about it — it names no set; - * - **the platform-admin bootstrap** reads an unscoped one as an administrator + * - **the platform-admin bootstrap** reads a Default Organization one as an administrator * who may already exist, and withholds the promotion — without naming it as * the holder; * - **the organization-administrator reconcile** reads grants by name alone: @@ -204,7 +204,7 @@ describe('[ADR-0131 D4] a grant that names nothing — the platform-admin bootst expect(await findExistingPlatformAdmin(engine, defaultPermissionSets)).toBe('usr_admin'); }); - it('unnamed, the promotion is WITHHELD — no second unscoped grant is minted for the oldest account', async () => { + it('unnamed, the promotion is WITHHELD — no second anchoring grant is minted for the oldest account', async () => { const engine = await standingAdminUnnamed(); await unname(engine, { id: 'g_admin' }); // An existing database: the platform-bucket rows an earlier release seeded. @@ -247,7 +247,7 @@ describe('[ADR-0131 D4] a grant that names nothing — the platform-admin bootst const [message] = withheld; expect(message).toBe( - '[security] platform-admin promotion WITHHELD: an unscoped grant exists whose permission-set name ' + '[security] platform-admin promotion WITHHELD: a Default Organization grant exists whose permission-set name ' + '(sys_user_permission_set.permission_set) was never written — a grant from a release before that ' + 'column existed. Which set it holds can no longer be read, and it may be the platform administrator\'s, ' + 'so this pass will not mint a second one. ' + REWRITE_SENTENCE, diff --git a/packages/plugins/plugin-security/src/objects/default-row-scope-declarations.test.ts b/packages/plugins/plugin-security/src/objects/default-row-scope-declarations.test.ts index b4e1518f70e..f932323af05 100644 --- a/packages/plugins/plugin-security/src/objects/default-row-scope-declarations.test.ts +++ b/packages/plugins/plugin-security/src/objects/default-row-scope-declarations.test.ts @@ -437,7 +437,9 @@ async function bootBaseline(declared: boolean) { for (const schema of declarers) await knex(schema.name).insert(fixtureRowsFor(schema)); // What an agent's delegator resolves from (ADR-0090 D10: the delegator leg is // rebuilt from the store): the memberships, and the grants the viewer and the - // two administrators hold. + // two administrators hold — each owned by `org_x`, the organization every + // agent's request runs in and so the one its delegator's grants are resolved + // in (an organization-less grant applies nowhere, ADR-0131 D10). const member = objects.get('sys_member')!; await knex('sys_member').insert([ ['u_x', 'org_x', 'member'], ['u_x2', 'org_x', 'member'], ['a_x', 'org_x', 'admin'], ['u_y', 'org_y', 'member'], @@ -445,7 +447,7 @@ async function bootBaseline(declared: boolean) { await knex('sys_user_permission_set').insert([ { id: 'ups:u_x2', user_id: 'u_x2', permission_set: 'viewer_readonly', organization_id: 'org_x' }, { id: 'ups:a_x', user_id: 'a_x', permission_set: 'organization_admin', organization_id: 'org_x' }, - { id: 'ups:p_admin', user_id: 'p_admin', permission_set: 'admin_full_access', organization_id: null }, + { id: 'ups:p_admin', user_id: 'p_admin', permission_set: 'admin_full_access', organization_id: 'org_x' }, ]); const served = async (object: string, persona: Persona): Promise => { diff --git a/packages/plugins/plugin-security/src/organization-scope.ts b/packages/plugins/plugin-security/src/organization-scope.ts index 2c6e5b08eac..73d70c95365 100644 --- a/packages/plugins/plugin-security/src/organization-scope.ts +++ b/packages/plugins/plugin-security/src/organization-scope.ts @@ -2,9 +2,12 @@ /** * Organization scoping for the RBAC catalog's per-organization passes: the - * system context one pass runs under and the one read that tells an - * organization's own row apart from organization-less residue (the sweep and - * the reap guard retired with their one caller, the seeder; #15204 stage 6b-2). + * system context one pass runs under and the one read that picks an + * organization's own row out of a scoped read (the sweep and the reap guard + * retired with their one caller, the seeder; #15204 stage 6b-2 — and the + * organization-less residue it reported retired with the 18.0 ownership + * ceremony, ADR-0131 D10, which attributes every such row before an upgraded + * database boots). * * ## Why the catalog is materialized per organization * @@ -30,56 +33,19 @@ * * ## The doctrine this file implements * - * An organization-less row is INVALID STATE under a walled posture — refuse or - * warn loudly, never treat it as a platform-wide default. The older reading, in - * which a NULL organization marked a platform row visible to every tenant, is - * retired at every layer: ADR-0131 D8 removed the driver's - * `OR organization_id IS NULL` arm from `applyTenantScope`, and - * `check-tenant-chokepoint` refuses it. A per-organization pass therefore no - * longer SEES an organization-less row at all — the tenant predicate is - * `organization_id = :tenant` and nothing else — and the 18.x upgrade ceremony - * (ADR-0131 D10) attributes or retires such rows before a walled deployment - * boots. - * - * ## What replaces a reap - * - * #8617 reaped its pre-fix organization-less rows. This catalog does not, and - * the difference is deliberate rather than an omission: - * - * - a fresh walled deployment never mints an organization-less catalog row - * FROM THESE FOUR SEEDERS once they run per organization, so there is nothing - * of theirs to migrate. It does NOT follow that a fresh walled deployment - * holds none, and this file used to claim it did (#11532). - * `bootstrapPlatformAdmin` is a FIFTH seeder, outside the four converted - * here, and it writes one organization-less `sys_permission_set` row per - * `defaultPermissionSets` entry on EVERY boot — before any organization - * exists (measured on a fresh walled rig: 8 rows, 1.3 s ahead of the first - * `sys_organization`). That is the RULED outcome rather than a leak: the - * 2026-08-20 maintainer ruling on #10103 keeps that platform bucket - * "unreaped and loudly warned about under walled posture", and PLATFORM_ADMIN - * is derived from an unscoped grant pointing at its `admin_full_access` row - * BY ROW ID, so a reap would silently demote every platform admin. The guard - * below therefore has to tell the two classes apart; - * - a `single`-posture deployment is where organization-less rows are the - * CORRECT shape, and the carve-out below leaves it byte-for-byte unchanged; - * - the rows a reap would delete are grant TARGETS — `sys_user_position`, - * `sys_position_permission_set` and `sys_user_permission_set` all point at - * them by row id — so deleting them revokes standing access with no signal at - * the moment of loss. #8617's reap could promise "NO grant changes" precisely - * because it never touched a junction table; here the junctions ARE the - * grants. - * - * So the pass said so instead. Its guard (retired with the seeder) named the - * rows and named the remedy, and — this is the load-bearing half — the pass - * still CREATES the organization's own copy. The failure shape it exists to - * prevent is the silent no-op: a tenant-threaded pass that saw the pre-fix - * organization-less row through the driver's v17 NULL arm (retired by ADR-0131 - * D8) read the name as already represented, took the update branch and created - * nothing, leaving the deployment exactly as broken as before while reporting - * success. - * {@link resolveOwnOrganizationRow} is the one read that distinguishes "this - * organization has its row" from "somebody's organization-less row is visible - * here", and every seeder in this catalog routes through it. + * An organization-less row is INVALID STATE — never a platform-wide default. + * The older reading, in which a NULL organization marked a platform row visible + * to every tenant, is retired at every layer: ADR-0131 D8 removed the driver's + * `OR organization_id IS NULL` arm from `applyTenantScope` (and + * `check-tenant-chokepoint` refuses it), so a per-organization pass no longer + * sees an organization-less row at all; the 18.0 ownership ceremony (ADR-0131 + * D10) attributes every one an earlier release wrote before an upgraded + * database boots, and makes the column NOT NULL; and a fresh install that + * registers the organization object never writes one (ADR-0131 D9: a system + * write with no organization of its own is stamped with the derivable one, or + * refused). So {@link resolveOwnOrganizationRow} answers one question — which + * row is this organization's own — and a row of any other shape that a read + * happens to return never answers for the organization. */ /** @@ -113,33 +79,21 @@ export function rowOrganizationId(row: any): string | null { /** * Resolve THIS organization's own row for a name, out of what a tenant-scoped - * read returned. + * read returned: the row stamped with `organizationId`, or `null`. * * A scoped read passes through `applyTenantScope`, which since ADR-0131 D8 - * returns the caller's rows only — the driver's NULL arm is retired. A row - * set can still mix the two classes when it comes from a driver with no - * native tenant scoping or from an unscoped read, and they are not - * interchangeable; the seeders must never treat them as such: - * - * - a row stamped with `organizationId` is this organization's — update it; - * - an organization-less row is a PRE-FIX residue that merely happens to be - * in the row set. Reading it as "already seeded" is the silent no-op this - * catalog exists to prevent, so it is reported separately and never returned - * as the organization's own row. + * returns the caller's rows only — the driver's NULL arm is retired. Any other + * row a read can still return — another organization's or an organization-less + * one, from a driver with no native tenant scoping or from an unscoped read — + * is never the organization's own and never answers for it. An + * organization-less row is invalid state after the ownership ceremony + * (ADR-0131 D10), so it is not reported either: there is no residue to report. * - * Under a `single`-posture pass (`organizationId` undefined) the - * organization-less row IS the row, which is the carve-out, so it is returned - * as `own` and nothing is flagged. + * Under a pass with no organization (`organizationId` undefined) there is + * nothing to tell apart, and the first row is the row. */ -export function resolveOwnOrganizationRow( - rows: any[], - organizationId?: string, -): { own: any | null; organizationLessResidue: any | null } { +export function resolveOwnOrganizationRow(rows: any[], organizationId?: string): { own: any | null } { const list = Array.isArray(rows) ? rows : []; - if (!organizationId) { - return { own: list[0] ?? null, organizationLessResidue: null }; - } - const own = list.find((r) => rowOrganizationId(r) === organizationId) ?? null; - const residue = list.find((r) => rowOrganizationId(r) === null) ?? null; - return { own, organizationLessResidue: residue }; + if (!organizationId) return { own: list[0] ?? null }; + return { own: list.find((r) => rowOrganizationId(r) === organizationId) ?? 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 b2eb89e8a98..586caade527 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 @@ -29,12 +29,15 @@ * ## The rule pinned here * * A row scoped to an organization applies only while that organization is - * active; an organization-less row applies everywhere — the rule - * `resolveUserAuthzGrants` already applies to grant rows. So the negative - * pins below are paired with preservation pins: the global grants an - * organization-less principal really holds (a global position folded onto a - * global same-named set, a global user grant) keep resolving, and an - * organization's own set still reaches its own members while it is active. + * active — the rule `resolveUserAuthzGrants` applies to grant rows + * (`grantAppliesInTenant`). An organization-less grant row applies nowhere: + * the 18.0 ownership ceremony (ADR-0131 D10) attributes every such row before + * an upgraded database boots, so one is invalid state, not a "global" grant. + * So the negative pins below are paired with preservation pins: the grants a + * principal holds in its own organization (a position folded onto the + * catalog's same-named set, a user grant) resolve while that organization is + * active and confer nothing with none active, and a legacy organization-less + * grant row confers nothing either way. * * ## The rig * @@ -68,8 +71,10 @@ const ORG_OTHER = 'org_other'; const ORG_HOME = 'org_home'; /** A member of `ORG_HOME` only. */ const USER_HOME = 'usr_home_member'; -/** A member of `ORG_HOME` only, holding two GLOBAL grants. */ +/** A member of `ORG_HOME` only, holding two grants owned by `ORG_HOME`. */ const USER_GLOBAL = 'usr_home_global'; +/** A member of `ORG_HOME` only, holding the same two grants as organization-less legacy rows. */ +const USER_LEGACY = 'usr_home_legacy'; /** A member of `ORG_OTHER` — the control that the authored set resolves at all. */ const USER_OTHER = 'usr_other_member'; @@ -96,11 +101,10 @@ afterEach(async () => { }); /** - * [C8 stage 8b] An organization-less GLOBAL row is legacy residue: under - * `single` the engine stamps or refuses an organization-less platform insert - * (ADR-0131 D9), so a row with `organization_id` NULL is landed straight - * through the driver, as an older release left it. The readers this file pins - * still read it until C8's NULL-reader stages (18.x). + * [C8 stage 8b] An organization-less row is legacy residue: under `single` the + * engine stamps or refuses an organization-less platform insert (ADR-0131 D9), + * so a row with `organization_id` NULL is landed straight through the driver, + * as an older release left it. The grant readers no longer honour one. */ function write(e: any, object: string, row: Record) { return row.organization_id === null @@ -145,24 +149,25 @@ async function boot(): Promise { const e = engine as any; await e.insert('sys_organization', { id: ORG_OTHER, name: ORG_OTHER, slug: 'other' }, SYS); await e.insert('sys_organization', { id: ORG_HOME, name: ORG_HOME, slug: 'home' }, SYS); - for (const id of [USER_HOME, USER_GLOBAL, USER_OTHER]) { + for (const id of [USER_HOME, USER_GLOBAL, USER_LEGACY, USER_OTHER]) { await e.insert('sys_user', { id, name: id, email: `${id}@example.test` }, SYS); } await e.insert('sys_member', { id: 'mem_home', user_id: USER_HOME, organization_id: ORG_HOME, role: 'member' }, SYS); await e.insert('sys_member', { id: 'mem_global', user_id: USER_GLOBAL, organization_id: ORG_HOME, role: 'member' }, SYS); + await e.insert('sys_member', { id: 'mem_legacy', user_id: USER_LEGACY, organization_id: ORG_HOME, role: 'member' }, SYS); await e.insert('sys_member', { id: 'mem_other', user_id: USER_OTHER, organization_id: ORG_OTHER, role: 'member' }, SYS); // ORG_OTHER authors sets named after what every member holds as a position. await insertSet(e, 'ps_other_org_member', 'org_member', ORG_OTHER, ['manage_metadata', 'probe.other_org_member']); await insertSet(e, 'ps_other_everyone', 'everyone', ORG_OTHER, ['manage_metadata', 'probe.other_everyone']); - // USER_GLOBAL's GLOBAL grants — what an organization-less principal really holds: - // (1) a global position assignment, folded onto a global same-named set; + // USER_GLOBAL's grants, owned by ORG_HOME: + // (1) a position assignment, folded onto a global same-named set; await write(e, 'sys_user_position', { - id: 'up_global_ops_lead', user_id: USER_GLOBAL, position: 'ops_lead', organization_id: null, + id: 'up_global_ops_lead', user_id: USER_GLOBAL, position: 'ops_lead', organization_id: ORG_HOME, }); await insertSet(e, 'ps_global_ops_lead', 'ops_lead', null, ['probe.global_ops_lead']); - // (2) a global user grant to a global set, beside ORG_OTHER's same-named + // (2) a user grant to a global set, beside ORG_OTHER's same-named // copy. The copy's id SORTS FIRST (`ps_0_…` before `ps_1_…`) and the // SQL driver's read comes back ordered by id, so an unscoped read meets // the copy first. With the ids the other way round this pin stays green @@ -172,6 +177,15 @@ async function boot(): Promise { await insertSet(e, 'ps_1_global_platform_ops', 'platform_ops', null, ['probe.global_platform_ops']); await write(e, 'sys_user_permission_set', { id: 'ups_global_platform_ops', user_id: USER_GLOBAL, + permission_set: 'platform_ops', organization_id: ORG_HOME, + }); + // USER_LEGACY holds the same two as organization-less rows — the shape an + // older release left, which confers nothing anywhere. + await write(e, 'sys_user_position', { + id: 'up_legacy_ops_lead', user_id: USER_LEGACY, position: 'ops_lead', organization_id: null, + }); + await write(e, 'sys_user_permission_set', { + id: 'ups_legacy_platform_ops', user_id: USER_LEGACY, permission_set: 'platform_ops', organization_id: null, }); // [ADR-0131 D3/D4] The two GLOBAL sets are declared in the catalog, which is @@ -295,29 +309,42 @@ describe('[#20555] with no active organization, another organization\'s set name expect(orgless.ledger).toBeNull(); }, 120_000); - it('a GLOBAL grant resolves the global row it names, not another organization\'s same-named copy', async () => { + it('an owned grant confers nothing with no organization active — neither the set it names nor another organization\'s copy', async () => { const r = await rig(); const orgless = await resolve(r, USER_GLOBAL); - expect(orgless.permissions).toContain('platform_ops'); - expect(orgless.byName.get('platform_ops')?.systemPermissions).toEqual(['probe.global_platform_ops']); + expect(orgless.permissions).not.toContain('platform_ops'); + expect(orgless.byName.get('platform_ops')).toBeUndefined(); + // Control: with its owner active the same grant resolves the global set it + // names, not ORG_OTHER's same-named copy (whose id sorts first). + const home = await resolve(r, USER_GLOBAL, ORG_HOME); + expect(home.permissions).toContain('platform_ops'); + expect(home.byName.get('platform_ops')?.systemPermissions).toEqual(['probe.global_platform_ops']); }, 120_000); it('…and the same-named copy\'s systemPermissions do not reach that principal', async () => { const r = await rig(); const orgless = await resolve(r, USER_GLOBAL); expect([...orgless.capabilities].filter((c) => c.startsWith('probe.other_'))).toEqual([]); + // Nor with the grant's owner active, where the grant does confer (the + // control that keeps the leg above from passing for want of a grant). + const home = await resolve(r, USER_GLOBAL, ORG_HOME); + expect(home.capabilities.has('probe.global_platform_ops')).toBe(true); + expect([...home.capabilities].filter((c) => c.startsWith('probe.other_'))).toEqual([]); }, 120_000); }); describe('[#20555] what the rule keeps', () => { - it('a global position folded onto a global same-named set still resolves with no active organization', async () => { + it('a position folded onto a global same-named set resolves while its owner is active, and not with none active', async () => { const r = await rig(); + const home = await resolve(r, USER_GLOBAL, ORG_HOME); + expect(home.positions).toContain('ops_lead'); + expect(home.byName.get('ops_lead')?.systemPermissions).toEqual(['probe.global_ops_lead']); const orgless = await resolve(r, USER_GLOBAL); - expect(orgless.positions).toContain('ops_lead'); - expect(orgless.byName.get('ops_lead')?.systemPermissions).toEqual(['probe.global_ops_lead']); + expect(orgless.positions).not.toContain('ops_lead'); + expect(orgless.byName.get('ops_lead')).toBeUndefined(); }, 120_000); - it('the global grants resolve identically with the principal\'s own organization active', async () => { + it('the owned grants resolve with the principal\'s own organization active', async () => { const r = await rig(); const home = await resolve(r, USER_GLOBAL, ORG_HOME); expect(home.byName.get('ops_lead')?.systemPermissions).toEqual(['probe.global_ops_lead']); @@ -325,6 +352,18 @@ describe('[#20555] what the rule keeps', () => { expect(home.setNames).not.toContain('org_member'); }, 120_000); + it('an organization-less grant row confers nothing — with no organization active, or with the holder\'s own', async () => { + const r = await rig(); + for (const organizationId of [undefined, ORG_HOME]) { + const legacy = await resolve(r, USER_LEGACY, organizationId); + expect(legacy.positions).not.toContain('ops_lead'); + expect(legacy.permissions).not.toContain('platform_ops'); + expect(legacy.byName.get('ops_lead')).toBeUndefined(); + expect(legacy.byName.get('platform_ops')).toBeUndefined(); + expect([...legacy.capabilities].filter((c) => c.startsWith('probe.'))).toEqual([]); + } + }, 120_000); + // [ADR-0131 D3/D4, #15196 Q3 = A] REVERSED: the plugin no longer reads // `sys_permission_set` rows at all, so a set an organization authored as a // row with no catalog definition resolves for nobody — its own members, diff --git a/packages/plugins/plugin-security/src/position-catalog-refusal.test.ts b/packages/plugins/plugin-security/src/position-catalog-refusal.test.ts index cef87436f6a..06bf5c2f0dd 100644 --- a/packages/plugins/plugin-security/src/position-catalog-refusal.test.ts +++ b/packages/plugins/plugin-security/src/position-catalog-refusal.test.ts @@ -91,6 +91,14 @@ const SYS = { isSystem: true } as const; const ADMIN = { userId: 'u_admin', positions: [], permissions: ['qa_admin'] }; const ORG_ADMIN_LIKE = { userId: 'u_org_admin', positions: [], permissions: ['qa_org_admin_like'] }; const PLAIN_MEMBER = { userId: 'u_member', positions: [], permissions: [] }; +/** + * The organization the grant-resolving legs below run in. A grant row takes + * effect only in the organization that owns it (`grantAppliesInTenant`), so a + * leg that reads what an assignment grants writes it from inside `org_a` and + * resolves it there. + */ +const GRANT_ORG = 'org_a'; +const ADMIN_IN_GRANT_ORG = { ...ADMIN, tenantId: GRANT_ORG }; /** A position record id — the spelling the measured authors wrote where the position's NAME belongs. */ const AUDITOR_POSITION_ID = 'position_mtur8hz5jp7ptm3r'; @@ -201,9 +209,9 @@ async function addMember(h: Harness, userId: string, organizationId: string): Pr { context: { isSystem: true, tenantId: organizationId } } as any); } -/** What an account reads of `qa_inquiry`, through the real resolver and the real middleware. */ +/** What an account reads of `qa_inquiry` in {@link GRANT_ORG}, through the real resolver and the real middleware. */ async function rowsReadBy(h: Harness, userId: string): Promise { - const g = await resolveUserAuthzGrants(h.engine, userId); + const g = await resolveUserAuthzGrants(h.engine, userId, { tenantId: GRANT_ORG }); try { const rows = await h.engine.find('qa_inquiry', { context: { userId, positions: g.positions, permissions: g.permissions }, @@ -244,8 +252,9 @@ async function assignmentsOf(h: Harness, userId: string): Promise { describe('a position spelled as a record id is refused, and the account is provably fine', () => { it('refuses 400 VALIDATION_FAILED, reference_not_found at position, naming the value and the remedy', async () => { const h = await boot(); + await addMember(h, 'u_holder', GRANT_ORG); const err = await refusalOf(() => h.engine.insert( - 'sys_user_position', { user_id: 'u_holder', position: AUDITOR_POSITION_ID }, { context: ADMIN } as any, + 'sys_user_position', { user_id: 'u_holder', position: AUDITOR_POSITION_ID }, { context: ADMIN_IN_GRANT_ORG } as any, )); const env = envelopeOf(err); expect(env.code).toBe('VALIDATION_FAILED'); @@ -268,7 +277,7 @@ describe('a position spelled as a record id is refused, and the account is prova // distributes reads rows at once, so the account, the set and the object // are fine — the refused edge was the whole difference. await h.engine.insert( - 'sys_user_permission_set', { user_id: 'u_holder', permission_set: 'qa_auditor_set' }, { context: ADMIN } as any, + 'sys_user_permission_set', { user_id: 'u_holder', permission_set: 'qa_auditor_set' }, { context: ADMIN_IN_GRANT_ORG } as any, ); expect(await rowsReadBy(h, 'u_holder')).toBe(3); }); @@ -301,19 +310,21 @@ describe('the accepted half — a catalog NAME, active or deactivated', () => { it('a catalog name is accepted and resolves: the holder reads rows', async () => { const h = await boot(); + await addMember(h, 'u_named', GRANT_ORG); const created = await h.engine.insert( - 'sys_user_position', { user_id: 'u_named', position: 'qa_auditor' }, { context: ADMIN } as any, + 'sys_user_position', { user_id: 'u_named', position: 'qa_auditor' }, { context: ADMIN_IN_GRANT_ORG } as any, ); - expect(created).toMatchObject({ user_id: 'u_named', position: 'qa_auditor' }); + expect(created).toMatchObject({ user_id: 'u_named', position: 'qa_auditor', organization_id: GRANT_ORG }); expect(await rowsReadBy(h, 'u_named')).toBe(3); }); it('ADR-0049 shape E: a DEACTIVATED position is still a position — accepted, stored, and grants nothing', async () => { const h = await boot(); + await addMember(h, 'u_retired', GRANT_ORG); const created = await h.engine.insert( - 'sys_user_position', { user_id: 'u_retired', position: 'qa_retired' }, { context: ADMIN } as any, + 'sys_user_position', { user_id: 'u_retired', position: 'qa_retired' }, { context: ADMIN_IN_GRANT_ORG } as any, ); - expect(created).toMatchObject({ position: 'qa_retired' }); + expect(created).toMatchObject({ position: 'qa_retired', organization_id: GRANT_ORG }); expect(await assignmentsOf(h, 'u_retired')).toHaveLength(1); // It stops granting (the resolver drops the deactivated name) — it is not refused. expect(await rowsReadBy(h, 'u_retired')).toBe(0); diff --git a/packages/plugins/plugin-security/src/security-plugin.test.ts b/packages/plugins/plugin-security/src/security-plugin.test.ts index 6476c519dbc..2ab67120d06 100644 --- a/packages/plugins/plugin-security/src/security-plugin.test.ts +++ b/packages/plugins/plugin-security/src/security-plugin.test.ts @@ -3481,6 +3481,13 @@ describe('explainAccessForCaller (ADR-0090 D6/D12)', () => { describe('SecurityPlugin — ADR-0090 D10 agent intersection', () => { const AGENT = 'user_agent'; const DELEGATOR = 'user_delegator'; + /** + * The organization every request below runs in, and the owner of every + * delegator holding: a holding takes effect only in its own organization + * (`grantAppliesInTenant`), so an organization-less one would leave the + * delegator holding nothing and every deny below passing for want of a grant. + */ + const D10_ORG = 'org-1'; // task carries every field the RLS/FLS policies below target, so no policy is // dropped as a fail-closed "missing field" deny. @@ -3510,7 +3517,7 @@ describe('SecurityPlugin — ADR-0090 D10 agent intersection', () => { if (opts.delegatorPositions !== null) { tables.sys_user.push({ id: DELEGATOR, email: 'delegator@x' }); for (const p of opts.delegatorPositions) { - tables.sys_user_position.push({ user_id: DELEGATOR, position: p }); + tables.sys_user_position.push({ user_id: DELEGATOR, position: p, organization_id: D10_ORG }); } } const matches = (row: any, where: any): boolean => @@ -3576,7 +3583,7 @@ describe('SecurityPlugin — ADR-0090 D10 agent intersection', () => { }; const agentCtx = (extra?: Record) => ({ - userId: AGENT, tenantId: 'org-1', positions: ['agent_set'], permissions: [], + userId: AGENT, tenantId: D10_ORG, positions: ['agent_set'], permissions: [], onBehalfOf: { userId: DELEGATOR, principalKind: 'human' }, principalKind: 'agent', ...(extra ?? {}), }); @@ -3643,7 +3650,7 @@ describe('SecurityPlugin — ADR-0090 D10 agent intersection', () => { agentPositions: ['agent_set'], delegatorPositions: ['del_set'], }); // Same context, but WITHOUT onBehalfOf. - const opCtx: any = { object: 'task', operation: 'find', ast: { where: undefined }, context: { userId: AGENT, tenantId: 'org-1', positions: ['agent_set'], permissions: [] } }; + const opCtx: any = { object: 'task', operation: 'find', ast: { where: undefined }, context: { userId: AGENT, tenantId: D10_ORG, positions: ['agent_set'], permissions: [] } }; await h.run(opCtx); expect(opCtx.ast.where).toEqual({ owner_id: AGENT }); }); @@ -3854,7 +3861,7 @@ describe('SecurityPlugin — ADR-0090 D10 agent intersection', () => { sets: [silentCeiling('agent_set'), viewAllDelegator('del_set')], agentPositions: ['agent_set'], delegatorPositions: ['del_set'], }); - const humanCtx = { userId: DELEGATOR, tenantId: 'org-1', positions: ['del_set'], permissions: [] }; + const humanCtx = { userId: DELEGATOR, tenantId: D10_ORG, positions: ['del_set'], permissions: [] }; expect(await h.security().describeDelegationNarrowing('task', humanCtx)).toEqual({ narrowed: false }); }); diff --git a/packages/plugins/plugin-security/src/tenant-wall-organization-removal.test.ts b/packages/plugins/plugin-security/src/tenant-wall-organization-removal.test.ts index 6fe5ddba4e1..50fa25612ff 100644 --- a/packages/plugins/plugin-security/src/tenant-wall-organization-removal.test.ts +++ b/packages/plugins/plugin-security/src/tenant-wall-organization-removal.test.ts @@ -15,8 +15,9 @@ * judged at all — neither as the payload was sent nor on the stored row. On an * ordinary tenant object such a row drops out of every reader's scope; on the * organization-scoped grant tables (`sys_user_position`, - * `sys_user_permission_set`) an organization-less row is read as a grant that - * applies in every organization (`@objectstack/core`, `grantAppliesInTenant`). + * `sys_user_permission_set`) an organization-less row is invalid state — a + * grant that applies nowhere (`@objectstack/core`, `grantAppliesInTenant`), so + * emptying the column silently revokes it from its own organization. * * The wall now judges the stored organization on every non-system update * path: an empty organization, sent or written by a hook, is refused with the 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 176364bce3c..1c1094925d6 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 @@ -16,16 +16,17 @@ * built-in name. `resolveUserAuthzGrants` §4 pushes the row's `position` * straight into `grants.positions`, and `resolveAuthzContext` copies that array * onto `ExecutionContext.positions` verbatim. The capability rung - * (`grants.posture`, §6d) is derived from the unscoped `admin_full_access` - * grant and nothing else, so it stays `MEMBER` — the two answers genuinely + * (`grants.posture`, §6d) is derived from the anchoring `admin_full_access` + * grant (the Default Organization's) and nothing else, so it stays `MEMBER` — the two answers genuinely * disagree, and the name-read took the wrong one. * * ⭐ WHAT THE ESCALATION BUYS, driven rather than argued (see the arms below): * `manage_sharing` is an ORG-scoped capability (ADR-0111 D6) that an ordinary - * tenant admin may grant. (Since #20515 a grant scoped to an organization no - * longer applies to a resolution with no organization at all, so the arms below - * hold it through a GLOBAL grant — the one way an org-less caller still holds - * it.) Holding it with no organization resolved is refused + * tenant admin may grant. (A grant applies only in its own organization, and + * an organization-less grant row confers nothing, so the arms below resolve + * the caller's grants IN `HOME_ORG` and then present them on a context that + * carries no organization — the org-less shape the gate under test exists to + * refuse.) Holding it with no organization on the context is refused * by `assertResolvableAdminScope` precisely because an unscoped answer "would * expose every tenant's rules". The D4 name-read was the bypass: it satisfied * that gate, `adminOrgScope` then returned the UNFILTERED `where`, and @@ -35,10 +36,12 @@ * * ## POPULATION OF THIS PIN — stated because a pin proves only what it covers * - * Covers: an ORG-LESS caller holding `manage_sharing`, in two shapes — a D4 + * Covers: an ORG-LESS caller holding `manage_sharing`, in three shapes — a D4 * row spelling the built-in name with no capability grant behind it - * (`name-only`), and a genuine unscoped `admin_full_access` grant - * (`genuine`, the control that proves the fix did not simply deny everyone). + * (`name-only`), a genuine Default Organization `admin_full_access` grant + * (`genuine`, the control that proves the fix did not simply deny everyone), + * and that same grant carrying NO organization (`orgless`, which anchors + * nothing). * Both shapes are built by inserting rows and resolving them through the REAL * `resolveUserAuthzGrants`, so the `positions` / `posture` disagreement under * test is produced by the shipping resolver rather than hand-asserted. @@ -97,29 +100,43 @@ const sharingAdminSet = { active: true, }; -function authzTables(shape: 'name-only' | 'genuine') { +type Shape = 'name-only' | 'genuine' | 'orgless'; + +/** + * Every grant row is owned by `HOME_ORG`, which is also the Default + * Organization: a grant applies only in its own organization, and platform + * standing is anchored only by the Default Organization's `admin_full_access` + * grant. + */ +function authzTables(shape: Shape) { const userPositions = shape === 'name-only' ? [ // Exactly what a tenant admin can write through the `apiEnabled` // `sys_user_position` surface: a row whose NAME is the built-in. - { user_id: USER, position: BUILTIN_IDENTITY_PLATFORM_ADMIN, organization_id: null }, + { user_id: USER, position: BUILTIN_IDENTITY_PLATFORM_ADMIN, organization_id: HOME_ORG }, ] : []; const userSets: Array> = [ - // The `manage_sharing` capability both shapes hold — the precondition, not - // the axis under test. GLOBAL (no organization): this caller resolves with - // NO organization, and an organization-less resolution applies only global - // grants (#20515), so an org-scoped grant here would simply not be held and - // 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, permission_set: 'sharing_admin', organization_id: null }, + // The `manage_sharing` capability every shape holds — the precondition, not + // the axis under test. Owned by `HOME_ORG`, the organization the grants + // are resolved in ({@link resolve}): an organization-less row would confer + // nothing, and 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, permission_set: 'sharing_admin', organization_id: HOME_ORG }, ]; - if (shape === 'genuine') { - userSets.push({ user_id: USER, permission_set_id: PS_ADMIN, permission_set: ADMIN_FULL_ACCESS, organization_id: null }); + if (shape === 'genuine' || shape === 'orgless') { + userSets.push({ + user_id: USER, permission_set_id: PS_ADMIN, permission_set: ADMIN_FULL_ACCESS, + organization_id: shape === 'genuine' ? HOME_ORG : null, + }); } return { + sys_organization: [ + { id: HOME_ORG, slug: 'default' }, + { id: OTHER_ORG, slug: 'victim' }, + ], sys_user: [{ id: USER, email: 'subject@example.com', email_verified: true }], sys_member: [{ organization_id: HOME_ORG, user_id: USER, role: 'member' }], sys_user_position: userPositions, @@ -143,12 +160,14 @@ function authzTables(shape: 'name-only' | 'genuine') { * context the transports would build and the rung, so every assertion below is * about resolver output rather than a hand-written array. * - * The caller is deliberately ORG-LESS (`tenantId` omitted): that is the shape - * `assertResolvableAdminScope` exists to refuse. + * The grants are resolved IN `HOME_ORG` (the only organization a grant row + * applies in), and the context handed to the service is deliberately ORG-LESS + * (`tenantId` omitted): that is the shape `assertResolvableAdminScope` exists + * to refuse. */ -async function resolve(shape: 'name-only' | 'genuine') { +async function resolve(shape: Shape) { const ql = makeAuthzQl(authzTables(shape)); - const grants = await resolveUserAuthzGrants(ql as any, USER); + const grants = await resolveUserAuthzGrants(ql as any, USER, { tenantId: HOME_ORG }); const context = { userId: USER, positions: grants.positions, @@ -255,7 +274,7 @@ describe('[#15981] a D4 `sys_user_position` row spelling `platform_admin` confer }); }); -describe('[#15981] CONTROL — a genuine unscoped admin_full_access grant still admits', () => { +describe('[#15981] CONTROL — a genuine Default Organization admin_full_access grant still admits', () => { it('all three answers are TRUE and agree, and the cross-tenant read is served', async () => { const { context, rung } = await resolve('genuine'); const engine = makeSharingEngine(); @@ -280,6 +299,20 @@ describe('[#15981] CONTROL — a genuine unscoped admin_full_access grant still expect(escalation.rung).toBe(false); expect(genuine.rung).toBe(true); }); + + it('the same admin_full_access grant with NO organization anchors nothing, and is refused', async () => { + const { context, grants, rung } = await resolve('orgless'); + const engine = makeSharingEngine(); + const service = makeService(engine); + + expect(context.positions).not.toContain(BUILTIN_IDENTITY_PLATFORM_ADMIN); + expect(grants.posture).not.toBe('PLATFORM_ADMIN'); + expect(rung).toBe(false); + // The capability is still held, so the refusal is about platform authority. + expect(context.systemPermissions).toContain('manage_sharing'); + await expect(service.listRules({}, context)).rejects.toThrow(/PERMISSION_DENIED/); + expect(engine.finds).toEqual([]); + }); }); describe('[#15981] the OTHER spelling of platform authority is untouched', () => { diff --git a/packages/qa/dogfood/test/parent-derived-write-refusal-not-visible.dogfood.test.ts b/packages/qa/dogfood/test/parent-derived-write-refusal-not-visible.dogfood.test.ts index d900b833f2e..14c5bfc7b2f 100644 --- a/packages/qa/dogfood/test/parent-derived-write-refusal-not-visible.dogfood.test.ts +++ b/packages/qa/dogfood/test/parent-derived-write-refusal-not-visible.dogfood.test.ts @@ -131,19 +131,33 @@ async function boot(inside: boolean, email: string): Promise { const ql = await stack.kernel.getServiceAsync('objectql'); const adminId = (await ql.findOne('sys_user', { where: { email: 'admin@objectos.ai' }, context: SYS }))?.id; const userId = (await ql.findOne('sys_user', { where: { email }, context: SYS }))?.id; + // The grants are owned by the Default Organization, the organization both + // principals' sessions are active in: a grant applies only in its own + // organization, so an organization-less row would confer nothing on either. + const defaultOrg = await ql.findOne('sys_organization', { where: { slug: 'default' }, context: SYS }); + expect(defaultOrg?.id, 'the `single` boot names a Default Organization').toBeTruthy(); + const orgId = String(defaultOrg.id); for (const name of GRANTS) { const set = await catalogPermissionSet(ql, name); expect(set, `fixture permission set ${name} served by the security catalog`).toBeTruthy(); - // [C8 stage 8b] A GLOBAL grant, which the outside principal (no membership, no - // active organization) must still hold: under `single` the engine now stamps an - // organization-less system insert with the Default Organization (ADR-0131 D9), - // so the global row is landed beneath the engine, as the residue an older - // release left. The readers still apply it until C8's NULL-reader stages. - await ql.getDriver('sys_user_permission_set').create('sys_user_permission_set', { - id: `ups_${userId}_${set.name}`, user_id: userId, permission_set: set.name, organization_id: null, - }); + await ql.insert('sys_user_permission_set', { + id: `ups_${userId}_${set.name}`, user_id: userId, permission_set: set.name, organization_id: orgId, + }, { context: SYS }); + } + if (!inside) { + token = await leaveOrganization(stack, email); + // The OUTSIDE principal holds no `sys_member` row — so no `org_member` — + // while its session keeps the Default Organization as its active one: under + // `single` a session's organization claim is kept without a membership row + // (`vetOrganizationClaim`), which is the state a session that selected the + // organization before its user was removed is left in. That claim is what + // lets the grants above apply to a principal outside the floor's domain; + // with no active organization no stored grant applies at all. + const sessions = await ql.find('sys_session', { where: { user_id: userId }, limit: 20, context: SYS }); + for (const s of Array.isArray(sessions) ? sessions : sessions?.records ?? []) { + await ql.update('sys_session', { id: s.id, active_organization_id: orgId }, { context: SYS }); + } } - if (!inside) token = await leaveOrganization(stack, email); return { stack, rootDir, ql, adminId, token }; } diff --git a/packages/qa/dogfood/test/predicate-write-unreadable-not-matched.dogfood.test.ts b/packages/qa/dogfood/test/predicate-write-unreadable-not-matched.dogfood.test.ts index 1c30c648be9..dd615c94370 100644 --- a/packages/qa/dogfood/test/predicate-write-unreadable-not-matched.dogfood.test.ts +++ b/packages/qa/dogfood/test/predicate-write-unreadable-not-matched.dogfood.test.ts @@ -154,19 +154,33 @@ async function boot(inside: boolean, email: string): Promise { const ql = await stack.kernel.getServiceAsync('objectql'); const adminId = (await ql.findOne('sys_user', { where: { email: 'admin@objectos.ai' }, context: SYS }))?.id; const userId = (await ql.findOne('sys_user', { where: { email }, context: SYS }))?.id; + // The grants are owned by the Default Organization, the organization both + // principals' sessions are active in: a grant applies only in its own + // organization, so an organization-less row would confer nothing on either. + const defaultOrg = await ql.findOne('sys_organization', { where: { slug: 'default' }, context: SYS }); + expect(defaultOrg?.id, 'the `single` boot names a Default Organization').toBeTruthy(); + const orgId = String(defaultOrg.id); for (const name of GRANTS) { const set = await catalogPermissionSet(ql, name); expect(set, `fixture permission set ${name} served by the security catalog`).toBeTruthy(); - // [C8 stage 8b] A GLOBAL grant, which the outside principal (no membership, no - // active organization) must still hold: under `single` the engine now stamps an - // organization-less system insert with the Default Organization (ADR-0131 D9), - // so the global row is landed beneath the engine, as the residue an older - // release left. The readers still apply it until C8's NULL-reader stages. - await ql.getDriver('sys_user_permission_set').create('sys_user_permission_set', { - id: `ups_${userId}_${set.name}`, user_id: userId, permission_set: set.name, organization_id: null, - }); + await ql.insert('sys_user_permission_set', { + id: `ups_${userId}_${set.name}`, user_id: userId, permission_set: set.name, organization_id: orgId, + }, { context: SYS }); + } + if (!inside) { + token = await leaveOrganization(stack, email); + // The OUTSIDE principal holds no `sys_member` row — so no `org_member` — + // while its session keeps the Default Organization as its active one: under + // `single` a session's organization claim is kept without a membership row + // (`vetOrganizationClaim`), which is the state a session that selected the + // organization before its user was removed is left in. That claim is what + // lets the grants above apply to a principal outside the floor's domain; + // with no active organization no stored grant applies at all. + const sessions = await ql.find('sys_session', { where: { user_id: userId }, limit: 20, context: SYS }); + for (const s of Array.isArray(sessions) ? sessions : sessions?.records ?? []) { + await ql.update('sys_session', { id: s.id, active_organization_id: orgId }, { context: SYS }); + } } - if (!inside) token = await leaveOrganization(stack, email); return { stack, rootDir, ql, adminId, userId, token }; } diff --git a/packages/qa/dogfood/test/sharing-rule-org-less-caller.dogfood.test.ts b/packages/qa/dogfood/test/sharing-rule-org-less-caller.dogfood.test.ts index 1b8d463cd9c..8c404eca10a 100644 --- a/packages/qa/dogfood/test/sharing-rule-org-less-caller.dogfood.test.ts +++ b/packages/qa/dogfood/test/sharing-rule-org-less-caller.dogfood.test.ts @@ -42,13 +42,20 @@ // was filed, `resolveUserAuthzGrants` kept an ORGANIZATION-scoped grant for a // caller with no active organization (`!(org && tenantId && org !== tenantId)` // read "no tenant" as "every organization"), so an org-scoped `manage_sharing` -// reached `adminOrgScope`. It no longer does: with no active organization only -// GLOBAL grants apply. So the two faces are pinned separately below — -// - the EXPOSED persona holds `manage_sharing` through a GLOBAL grant, the one -// way an org-less caller still holds it, and keeps pinning `adminOrgScope` -// (#8158's defence in depth: capability held, no organization to use it in); +// reached `adminOrgScope`. It no longer does: a grant applies only in its own +// organization, so with no active organization no stored grant applies — and a +// grant that carries NO organization applies nowhere. So the two faces are +// pinned separately below — +// - the EXPOSED persona holds `manage_sharing` through an ORGANIZATION-LESS +// grant, the spelling that once applied everywhere, and is now refused at +// the ADR-0111 D6 capability gate: the row confers nothing; // - the ORG-SCOPED persona holds the very grant #8158 was filed with, scoped -// to tenant A, and is now refused at the ADR-0111 D6 capability gate. +// to tenant A, and is refused at the same gate. +// Neither reaches `assertResolvableAdminScope` (#8158's defence in depth: +// capability held, no organization to use it in) any more: the one grant that +// still applies with no active organization is the Default Organization's +// `admin_full_access`, and its holder is a platform operator, which that guard +// lets through by design. // // ## Anti-vacuity // @@ -90,13 +97,13 @@ describe('#8158 — a manage_sharing holder with NO active organization cannot r // eslint-disable-next-line @typescript-eslint/no-explicit-any let ql: any; /** - * A platform operator with no active organization: `admin_full_access` held - * globally, and no membership. [ADR-0131 D3] The harness admin can no longer + * A platform operator with no active organization: the Default + * Organization's `admin_full_access` grant, and no membership. [ADR-0131 D3] The harness admin can no longer * play this part — every `single` boot makes them the Default Organization's * owner, so their session carries it. */ let platform: string; - /** The exposed persona: GLOBAL `manage_sharing`, no membership, no active org. */ + /** The exposed persona: `manage_sharing` through an organization-less grant, no membership, no active org. */ let orgLess: string; /** [#20515] The #8158 grant as filed — scoped to tenant A — with no membership and no active org. */ let orgScopedOrgLess: string; @@ -157,17 +164,16 @@ describe('#8158 — a manage_sharing holder with NO active organization cannot r orgBoundUserId = await uid(ORG_BOUND_EMAIL); orgScopedOrgLessUserId = await uid(ORG_SCOPED_ORG_LESS_EMAIL); - // [#20515] The exposed persona holds the set GLOBALLY (no organization): - // with no active organization only global grants apply, so this is the one - // spelling under which it still carries `manage_sharing` and still reaches - // `adminOrgScope`. - // [C8 stage 8b] A global grant is legacy residue in 18.0: the engine now stamps - // or refuses an organization-less system insert under `single` (ADR-0131 D9), - // so it is landed beneath the engine. The readers still apply it until C8's - // NULL-reader stages. - const globalGrant = (row: Record) => - ql.getDriver('sys_user_permission_set').create('sys_user_permission_set', { ...row, organization_id: null }); - await globalGrant({ id: 'ups_8158_org_less', user_id: orgLessUserId, permission_set: 'sharing_admin_8158' }); + // [#20515] The exposed persona holds the set with NO organization — the + // spelling an older release read as "applies everywhere", and so the one + // under which an org-less caller once carried `manage_sharing` into + // `adminOrgScope`. It is invalid state in 18.0: the engine stamps or refuses + // an organization-less system insert (ADR-0131 D9), so the row is landed + // beneath the engine, as the residue an older release left. It applies + // nowhere now. + await ql.getDriver('sys_user_permission_set').create('sys_user_permission_set', { + id: 'ups_8158_org_less', user_id: orgLessUserId, permission_set: 'sharing_admin_8158', organization_id: null, + }); // The #8158 grant as filed — scoped to tenant A — for the control (which // has tenant A active) and for the org-scoped persona (which has none). for (const userId of [orgBoundUserId, orgScopedOrgLessUserId]) { @@ -189,12 +195,19 @@ describe('#8158 — a manage_sharing holder with NO active organization cannot r id: 'mem_8158_bound', organization_id: ORG_A, user_id: orgBoundUserId, role: 'member', }, { context: SYS }); - // A platform operator outside the organization: `admin_full_access` held - // globally, the grant a platform administrator holds under `single`. + // A platform operator outside the organization: `admin_full_access` owned + // by the Default Organization, the one grant that anchors platform standing + // under `single` — and that anchors it whatever the active organization, + // none included. await stack.signUp(OPERATOR_EMAIL, PASSWORD); const adminSet = await catalogPermissionSet(ql, 'admin_full_access'); expect(adminSet, 'admin_full_access is served by the security catalog').toBeTruthy(); - await globalGrant({ id: 'ups_8158_operator', user_id: await uid(OPERATOR_EMAIL), permission_set: adminSet.name }); + const defaultOrg = await ql.findOne('sys_organization', { where: { slug: 'default' }, context: SYS }); + expect(defaultOrg?.id, 'the `single` boot names a Default Organization').toBeTruthy(); + await ql.insert('sys_user_permission_set', { + id: 'ups_8158_operator', user_id: await uid(OPERATOR_EMAIL), permission_set: adminSet.name, + organization_id: defaultOrg.id, + }, { context: SYS }); // Sign in AFTER the grants, so every session is minted by the same path // with the membership state above already in place. The org-less personas @@ -267,8 +280,9 @@ describe('#8158 — a manage_sharing holder with NO active organization cannot r // ── the measurement ────────────────────────────────────────────────── it('THE REPORTED CASE: listing is refused 403, not answered with every tenant’s rules', async () => { - // The exposed persona holds `manage_sharing` GLOBALLY (#20515 — see the - // header), so this is `adminOrgScope` refusing, as the next case proves. + // The exposed persona's `manage_sharing` grant carries no organization + // (#20515 — see the header), so it confers nothing: this is the capability + // gate refusing, as the next case proves. const res = await stack.apiAs(orgLess, 'GET', RULES); const payload = res.status === 200 ? ((await res.json()) as { data: RuleRow[] }).data.map((r) => `${r.name}@${r.organization_id}`) @@ -283,17 +297,18 @@ describe('#8158 — a manage_sharing holder with NO active organization cannot r ).toBe(403); }); - it('the refusal names the ORGANIZATION, which is also how we know the capability gate was cleared', async () => { + it('the refusal is the CAPABILITY gate’s: an organization-less grant confers no manage_sharing', async () => { // Both refusals on this surface are `PERMISSION_DENIED`; only the message // separates "you lack manage_sharing" from "you have it and no org to use - // it in". Reading the second proves the persona really did carry the - // capability — i.e. that this fixture measures the fall-open and not the - // older ADR-0111 D6 gate. + // it in". Reading the first proves the organization-less row conferred + // nothing — the persona never carried the capability as far as the + // active-organization guard. The org-BOUND control below holds the same set + // through a grant that carries an organization, and is served. const res = await stack.apiAs(orgLess, 'GET', RULES); const body = (await res.json()) as { code?: string; error?: string }; expect(body.code).toBe('PERMISSION_DENIED'); - expect(body.error ?? '').toMatch(/active organization/); - expect(body.error ?? '').not.toMatch(/requires the manage_sharing capability/); + expect(body.error ?? '').toMatch(/requires the manage_sharing capability/); + expect(body.error ?? '').not.toMatch(/active organization/); }); it('by-NAME GET of the OTHER tenant’s rule is refused', async () => { @@ -354,7 +369,7 @@ describe('#8158 — a manage_sharing holder with NO active organization cannot r }); it('an ORG-scoped manage_sharing grant with no active organization is refused 403 at the CAPABILITY gate', async () => { - // With no active organization only global grants apply, so the tenant-A + // With no active organization no stored grant applies, so the tenant-A // grant confers nothing here: the ADR-0111 D6 gate refuses before // `adminOrgScope` is ever asked — and no tenant's rule is read. const res = await stack.apiAs(orgScopedOrgLess, 'GET', RULES); diff --git a/packages/qa/dogfood/test/write-door-unreadable-is-not-found.dogfood.test.ts b/packages/qa/dogfood/test/write-door-unreadable-is-not-found.dogfood.test.ts index 69a8d1d01fe..305abcdaece 100644 --- a/packages/qa/dogfood/test/write-door-unreadable-is-not-found.dogfood.test.ts +++ b/packages/qa/dogfood/test/write-door-unreadable-is-not-found.dogfood.test.ts @@ -123,19 +123,33 @@ async function boot(inside: boolean, email: string): Promise { const ql = await stack.kernel.getServiceAsync('objectql'); const adminId = (await ql.findOne('sys_user', { where: { email: 'admin@objectos.ai' }, context: SYS }))?.id; const userId = (await ql.findOne('sys_user', { where: { email }, context: SYS }))?.id; + // The grants are owned by the Default Organization, the organization both + // principals' sessions are active in: a grant applies only in its own + // organization, so an organization-less row would confer nothing on either. + const defaultOrg = await ql.findOne('sys_organization', { where: { slug: 'default' }, context: SYS }); + expect(defaultOrg?.id, 'the `single` boot names a Default Organization').toBeTruthy(); + const orgId = String(defaultOrg.id); for (const name of GRANTS) { const set = await catalogPermissionSet(ql, name); expect(set, `fixture permission set ${name} served by the security catalog`).toBeTruthy(); - // [C8 stage 8b] A GLOBAL grant, which the outside principal (no membership, no - // active organization) must still hold: under `single` the engine now stamps an - // organization-less system insert with the Default Organization (ADR-0131 D9), - // so the global row is landed beneath the engine, as the residue an older - // release left. The readers still apply it until C8's NULL-reader stages. - await ql.getDriver('sys_user_permission_set').create('sys_user_permission_set', { - id: `ups_${userId}_${set.name}`, user_id: userId, permission_set: set.name, organization_id: null, - }); + await ql.insert('sys_user_permission_set', { + id: `ups_${userId}_${set.name}`, user_id: userId, permission_set: set.name, organization_id: orgId, + }, { context: SYS }); + } + if (!inside) { + token = await leaveOrganization(stack, email); + // The OUTSIDE principal holds no `sys_member` row — so no `org_member` — + // while its session keeps the Default Organization as its active one: under + // `single` a session's organization claim is kept without a membership row + // (`vetOrganizationClaim`), which is the state a session that selected the + // organization before its user was removed is left in. That claim is what + // lets the grants above apply to a principal outside the floor's domain; + // with no active organization no stored grant applies at all. + const sessions = await ql.find('sys_session', { where: { user_id: userId }, limit: 20, context: SYS }); + for (const s of Array.isArray(sessions) ? sessions : sessions?.records ?? []) { + await ql.update('sys_session', { id: s.id, active_organization_id: orgId }, { context: SYS }); + } } - if (!inside) token = await leaveOrganization(stack, email); return { stack, rootDir, ql, adminId, token }; } diff --git a/packages/rest/src/direct-mount-auth-gate.pin.test.ts b/packages/rest/src/direct-mount-auth-gate.pin.test.ts index be153743174..9e4c603a886 100644 --- a/packages/rest/src/direct-mount-auth-gate.pin.test.ts +++ b/packages/rest/src/direct-mount-auth-gate.pin.test.ts @@ -122,6 +122,9 @@ async function bootDirectMounts() { package: { list: async () => [], publish: vi.fn(async () => ({ success: true })) }, }; + const SET = 'ps_gate_pin'; + const ORG = 'o1'; + /** Session reads per request for the outage token; reset by `call`. */ const outage = { reads: 0 }; const auth = { @@ -133,17 +136,17 @@ async function bootDirectMounts() { // The identity read is the request's first; REST's gate re-read is its second. if (token === 'sess-outage' && ++outage.reads > 1) throw new Error('session backend unavailable'); const authGate = SESSIONS[token]; - return { user: { id: USER, ...(authGate ? { authGate } : {}) }, session: { userId: USER } }; + // The caller acts in `o1`, the organization its grant is stamped with. + return { user: { id: USER, ...(authGate ? { authGate } : {}) }, session: { userId: USER, activeOrganizationId: ORG } }; }, }, }; - const SET = 'ps_gate_pin'; const engine = { find: async (object: string, query: any) => { if (object === 'sys_user_permission_set') { return query?.where?.user_id === USER - ? [{ id: 'ups_1', user_id: USER, permission_set_id: SET, permission_set: 'gate_pin_caller', organization_id: null }] + ? [{ id: 'ups_1', user_id: USER, permission_set_id: SET, permission_set: 'gate_pin_caller', organization_id: ORG }] : []; } if (object === 'sys_permission_set') { 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 c01f5b4d1fa..1b8b7a66ab3 100644 --- a/packages/rest/src/execctx-authz-input-seam-reachability.test.ts +++ b/packages/rest/src/execctx-authz-input-seam-reachability.test.ts @@ -436,7 +436,9 @@ function matchesWhere(row: any, where: any): boolean { * `sys_api_key`, memberships in `sys_member`, capabilities through * `sys_user_permission_set` → `sys_permission_set`. Every read filters * through the one refusing matcher above, and the caller's `limit` bound is - * held BY PRESENCE (check:objectql-double-limit). + * held BY PRESENCE (check:objectql-double-limit). Every grant is owned by + * `org_A`, the organization the stamped keys act in — a grant applies only in + * its own organization. */ function qlWith(opts: { memberships: Array<{ user_id: string; organization_id: string }> }) { const tables: Record = { @@ -453,9 +455,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', 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' }, + { user_id: 'u_member', permission_set_id: 'ps_pkg', permission_set: 'pkg_admin', organization_id: 'org_A' }, + { user_id: 'u_exmember', permission_set_id: 'ps_pkg', permission_set: 'pkg_admin', organization_id: 'org_A' }, + { user_id: 'u_orgless', permission_set_id: 'ps_pkg', permission_set: 'pkg_admin', organization_id: 'org_A' }, ], 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 208d9705821..f5f120a9fa9 100644 --- a/packages/rest/src/external-datasource-routes-auth-guard.test.ts +++ b/packages/rest/src/external-datasource-routes-auth-guard.test.ts @@ -208,7 +208,7 @@ async function bootFederation( api: { getSession: async ({ headers }: { headers: Headers }) => headers?.get?.('authorization') === `Bearer ${SESSION}` - ? { user: { id: 'u_federation' }, session: { userId: 'u_federation' } } + ? { user: { id: 'u_federation' }, session: { userId: 'u_federation', activeOrganizationId: 'o1' } } : null, }, }; @@ -229,6 +229,10 @@ async function bootFederation( * `manage_platform_settings` among six other capabilities, so a gate keyed on * platform-admin posture rather than on the named capability would pass here * unnoticed — the same reason the twin-equivalence fixture builds its own. + * + * The grant is stamped with `o1`, and both credentials act in `o1` (the + * session's active organization, the key's stamped one): a grant applies + * only in its own organization. */ const grants = opts.grants ?? FULL_GRANT; const GRANT_SET_ID = 'ps_federation_caller'; @@ -236,12 +240,12 @@ async function bootFederation( find: async (object: string, query: any) => { if (object === 'sys_api_key') { return query?.where?.key === hashApiKey(API_KEY) - ? [{ id: 'key_1', key: hashApiKey(API_KEY), user_id: 'u_federation', revoked: false }] + ? [{ id: 'key_1', key: hashApiKey(API_KEY), user_id: 'u_federation', revoked: false, active_organization_id: 'o1' }] : []; } 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, permission_set: 'federation_caller', organization_id: null }] + ? [{ id: 'ups_1', user_id: 'u_federation', permission_set_id: GRANT_SET_ID, permission_set: 'federation_caller', organization_id: 'o1' }] : []; } if (object === 'sys_permission_set') { diff --git a/packages/rest/src/meta-type-read-capability.test.ts b/packages/rest/src/meta-type-read-capability.test.ts index d47059d0eac..775df59ac09 100644 --- a/packages/rest/src/meta-type-read-capability.test.ts +++ b/packages/rest/src/meta-type-read-capability.test.ts @@ -347,7 +347,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, permission_set: setId, organization_id: null, + id: `ups_${p.userId}_${setId}`, user_id: p.userId, permission_set_id: setId, permission_set: setId, organization_id: 'o1', }))), sys_permission_set: Object.entries(SETS).map(([id, caps]) => ({ id, name: id, system_permissions: JSON.stringify(caps), object_permissions: '{}', @@ -381,7 +381,8 @@ const authService = { api: { getSession: async ({ headers }: { headers: Headers }) => { const principal = PRINCIPALS[headers?.get?.('authorization') ?? '']; - return principal ? { user: { id: principal.userId } } : null; + // Every principal acts in `o1`, the organization its grants are stamped with. + return principal ? { user: { id: principal.userId }, session: { userId: principal.userId, activeOrganizationId: 'o1' } } : null; }, }, }; diff --git a/packages/rest/src/meta-type-write-capability.test.ts b/packages/rest/src/meta-type-write-capability.test.ts index fcaa11fa676..83954202d88 100644 --- a/packages/rest/src/meta-type-write-capability.test.ts +++ b/packages/rest/src/meta-type-write-capability.test.ts @@ -308,7 +308,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, permission_set: setId, organization_id: null, + id: `ups_${p.userId}_${setId}`, user_id: p.userId, permission_set_id: setId, permission_set: setId, organization_id: 'o1', }))), sys_permission_set: Object.entries(SETS).map(([id, caps]) => ({ id, name: id, system_permissions: JSON.stringify(caps), object_permissions: '{}', @@ -336,7 +336,8 @@ const authService = { api: { getSession: async ({ headers }: { headers: Headers }) => { const principal = PRINCIPALS[headers?.get?.('authorization') ?? '']; - return principal ? { user: { id: principal.userId } } : null; + // Every principal acts in `o1`, the organization its grants are stamped with. + return principal ? { user: { id: principal.userId }, session: { userId: principal.userId, activeOrganizationId: 'o1' } } : null; }, }, }; 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 881ba6839f6..3acd3bfb736 100644 --- a/packages/rest/src/package-door-execctx-fault-reachability.test.ts +++ b/packages/rest/src/package-door-execctx-fault-reachability.test.ts @@ -250,17 +250,18 @@ const settle = async (p: Promise): Promise<'fulfilled' | 'rejected'> => // Healthy production wiring — the baseline every fault is injected into. // --------------------------------------------------------------------------- -/** A better-auth-shaped service that resolves a real session. */ -const AUTH_OK = async () => ({ api: { getSession: async () => ({ user: { id: 'u_admin' } }) } }); +/** A better-auth-shaped service that resolves a real session, acting in `o1`. */ +const AUTH_OK = async () => ({ api: { getSession: async () => ({ user: { id: 'u_admin' }, session: { userId: 'u_admin', activeOrganizationId: 'o1' } }) } }); /** * A permission store that actually grants the package capabilities, through * the SHIPPED aggregation (`sys_user_permission_set` → `sys_permission_set` * → `system_permissions`) rather than by handing the door a ready-made context. + * The grant is owned by `o1`, the session's active organization. */ const qlHealthy = () => bindTestSecurityCatalog({ find: async (object: string) => { - if (object === 'sys_user_permission_set') return [{ permission_set_id: 'ps_pkg', permission_set: 'pkg_admin' }]; + if (object === 'sys_user_permission_set') return [{ permission_set_id: 'ps_pkg', permission_set: 'pkg_admin', organization_id: 'o1' }]; if (object === 'sys_permission_set') return [{ id: 'ps_pkg', name: 'pkg_admin' }]; return []; }, diff --git a/packages/rest/src/remote-tables-twin.equivalence.test.ts b/packages/rest/src/remote-tables-twin.equivalence.test.ts index 601189ca01f..7b67858c4f6 100644 --- a/packages/rest/src/remote-tables-twin.equivalence.test.ts +++ b/packages/rest/src/remote-tables-twin.equivalence.test.ts @@ -139,7 +139,8 @@ const authService = { api: { getSession: async ({ headers }: { headers: Headers }) => { const id = USERS[headers?.get?.('authorization') ?? '']; - return id ? { user: { id } } : null; + // Every caller acts in `o1`, the organization its grant is stamped with. + return id ? { user: { id }, session: { userId: id, activeOrganizationId: 'o1' } } : null; }, }, }; @@ -195,7 +196,7 @@ const makeQl = () => bindTestSecurityCatalog({ return setId ? [{ id: `ups_${where.user_id}`, user_id: where.user_id, permission_set_id: setId, - permission_set: SETS[setId].name, organization_id: null, + permission_set: SETS[setId].name, organization_id: 'o1', }] : []; } 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 cafc1489668..b97be792b1d 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 @@ -249,18 +249,19 @@ describe('[#13904] the shipped objectQLProvider, driven against a real ObjectKer // transport seam #13910 pinned, read at the package door. // --------------------------------------------------------------------------- -/** A better-auth-shaped service that resolves a real session. */ -const AUTH_OK = async () => ({ api: { getSession: async () => ({ user: { id: 'u_admin' } }) } }); +/** A better-auth-shaped service that resolves a real session, acting in `o1`. */ +const AUTH_OK = async () => ({ api: { getSession: async () => ({ user: { id: 'u_admin' }, session: { userId: 'u_admin', activeOrganizationId: 'o1' } }) } }); /** * An engine that actually grants the package capabilities through the SHIPPED * aggregation (`sys_user_permission_set` → `sys_permission_set` → * `system_permissions`) — the healthy fixture - * `package-door-execctx-fault-reachability.test.ts` established. + * `package-door-execctx-fault-reachability.test.ts` established. The grant is + * owned by `o1`, the session's active organization. */ const engineGranting = () => bindTestSecurityCatalog({ find: async (object: string) => { - if (object === 'sys_user_permission_set') return [{ permission_set_id: 'ps_pkg', permission_set: 'pkg_admin' }]; + if (object === 'sys_user_permission_set') return [{ permission_set_id: 'ps_pkg', permission_set: 'pkg_admin', organization_id: 'o1' }]; 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 f73b191a80b..8f3eea540ad 100644 --- a/packages/rest/src/rest-exec-ctx-principal-kind.test.ts +++ b/packages/rest/src/rest-exec-ctx-principal-kind.test.ts @@ -43,8 +43,9 @@ const RAW_API_KEY = 'osk_6071_rest_face'; /** * A fake data engine holding what a real deployment holds: one `sys_api_key` - * row for `member1`, a `member_default` grant for `member1` and an UNSCOPED - * `admin_full_access` grant for `admin1` (→ PLATFORM_ADMIN). Everything else + * row for `member1`, a `member_default` grant for `member1` and an + * `admin_full_access` grant for `admin1` owned by the Default Organization (the + * grant that anchors platform standing → PLATFORM_ADMIN). Everything else * resolves empty. */ const makeQl = () => bindTestSecurityCatalog({ @@ -55,9 +56,11 @@ const makeQl = () => bindTestSecurityCatalog({ return Object.entries(where).every(([k, v]) => (row as any)[k] === v) ? [row] : []; } if (object === 'sys_user') return [{ id: where.id, email: `${where.id}@example.com` }]; + // The Default Organization, by its slug. + if (object === 'sys_organization') return [{ id: 'org_default', slug: 'default' }]; if (object === 'sys_user_permission_set') { - 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 }]; + if (where.user_id === 'admin1') return [{ permission_set_id: 'ps_admin', permission_set: 'admin_full_access', organization_id: 'org_default' }]; + if (where.user_id === 'member1') return [{ permission_set_id: 'ps_member', permission_set: 'member_default', organization_id: 'org_default' }]; return []; } if (object === 'sys_permission_set') { diff --git a/packages/rest/src/rest-server-timing.test.ts b/packages/rest/src/rest-server-timing.test.ts index e5d020bfe84..d4d16568405 100644 --- a/packages/rest/src/rest-server-timing.test.ts +++ b/packages/rest/src/rest-server-timing.test.ts @@ -24,7 +24,8 @@ const makeServer = () => ({ /** * A fake data engine that returns just enough rows for `resolveAuthzContext` to - * derive a posture: an UNSCOPED `admin_full_access` grant for `admin1` (→ + * derive a posture: an `admin_full_access` grant for `admin1` owned by the + * Default Organization (the only grant that anchors platform standing → * PLATFORM_ADMIN) and a plain `member_default` grant for `member1` (→ MEMBER). * Every other object read resolves empty. */ @@ -32,9 +33,11 @@ const makeQl = () => bindTestSecurityCatalog({ find: async (object: string, opts: any) => { const where = opts?.where ?? {}; if (object === 'sys_user') return [{ id: where.id, email: `${where.id}@example.com` }]; + // The Default Organization, by its slug. + if (object === 'sys_organization') return [{ id: 'org_default', slug: 'default' }]; if (object === 'sys_user_permission_set') { - 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 }]; + if (where.user_id === 'admin1') return [{ permission_set_id: 'ps_admin', permission_set: 'admin_full_access', organization_id: 'org_default' }]; + if (where.user_id === 'member1') return [{ permission_set_id: 'ps_member', permission_set: 'member_default', organization_id: 'org_default' }]; return []; } if (object === 'sys_permission_set') { diff --git a/packages/runtime/src/default-organization-statements.golden.test.ts b/packages/runtime/src/default-organization-statements.golden.test.ts index 5709d7a1190..d83e2f33962 100644 --- a/packages/runtime/src/default-organization-statements.golden.test.ts +++ b/packages/runtime/src/default-organization-statements.golden.test.ts @@ -50,6 +50,8 @@ const DATA_READ = '/data/pin_note?$top=1'; /** * The organization-less administrator's warm counts on this composition, * measured on `origin/main` 5a871e4e41: `get-session` 8, the data read 10. + * An organization-less grant no longer anchors standing, so that administrator + * is gone; the counts are the bar the organization-owned one is held to. * The organization-owned administrator paid 9 / 11 there (slug `default`) and * 10 / 12 (the sole organization slugged otherwise). */ @@ -144,7 +146,7 @@ afterAll(async () => { else process.env.NODE_ENV = prevNodeEnv; }, 60_000); -describe('[#24523] statements per warm request: an organization-owned admin grant costs what an organization-less one does', () => { +describe('[#24523] statements per warm request: an organization-owned admin grant costs what an organization-less one did', () => { it('the fixture: the promoted administrator\'s grant is owned by the Default Organization, slugged `default`', async () => { const [organization] = await find('sys_organization'); expect(organization.slug).toBe('default'); @@ -169,20 +171,21 @@ describe('[#24523] statements per warm request: an organization-owned admin gran expect((await standing()).standing).toBe(true); }); - it('CONTROL — the organization-less grant: the same counts, on either slug', async () => { + it('CONTROL — the organization-less grant anchors nothing: on either slug, no standing, and no sys_organization statement is issued for it', async () => { const grant = await adminGrant(); const [organization] = await find('sys_organization'); await engine.update('sys_user_permission_set', { id: grant.id, organization_id: null }, { context: SYS }); - const sole = await warm(); + const sole = { reads: await warm(), standing: await standing() }; await engine.update('sys_organization', { id: organization.id, slug: 'default' }, { context: SYS }); - const slugged = await warm(); + const slugged = { reads: await warm(), standing: await standing() }; await engine.update('sys_user_permission_set', { id: grant.id, organization_id: organization.id }, { context: SYS }); - for (const counts of [sole, slugged]) { - expect(counts).toEqual({ - [GET_SESSION]: { count: WARM[GET_SESSION], organization: 0 }, - [DATA_READ]: { count: WARM[DATA_READ], organization: 0 }, - }); + for (const { reads, standing: s } of [sole, slugged]) { + expect(s).toEqual({ standing: false, organizationReads: 0 }); + expect(reads[GET_SESSION].organization).toBe(0); + expect(reads[DATA_READ].organization).toBe(0); } + // The positive leg: the same row, owned by the Default Organization again, anchors. + expect((await standing()).standing).toBe(true); }); }); 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 6ca5b702e41..a87e79bab2a 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 @@ -32,8 +32,9 @@ // automation toggle door, for a tenant org admin who already holds // `manage_metadata` — so the tier-above gate passes and THIS gate is the only // thing left. Three shapes: a D4 row spelling the built-in name with no -// capability grant (`name-only`), a genuine unscoped `admin_full_access` grant -// (`genuine`), and a plain tenant admin (`plain`, the floor). All three +// capability grant (`name-only`), a genuine `admin_full_access` grant owned by +// the Default Organization (`genuine`), and a plain tenant admin (`plain`, the +// floor). All three // contexts are produced by inserting rows and resolving them through the REAL // `resolveUserAuthzGrants`, so `positions` / `posture` come from the shipping // resolver rather than being hand-asserted. @@ -56,6 +57,8 @@ const FLOW = 'vendor_lead_router'; const DEFINITION = { name: FLOW, label: 'Vendor Lead Router', type: 'autolaunched', nodes: [], edges: [] }; const TENANT_ORG = 'org_northwind'; +/** The Default Organization: the owner of the one `admin_full_access` grant that anchors platform standing. */ +const DEFAULT_ORG = 'org_default'; const ACTOR = 'usr_tenant_admin'; const PS_ADMIN = 'ps_admin_full_access'; const PS_METADATA = 'ps_metadata_author'; @@ -116,17 +119,19 @@ function authzTables(shape: Shape) { { 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, permission_set: ADMIN_FULL_ACCESS, organization_id: null }); + userSets.push({ user_id: ACTOR, permission_set_id: PS_ADMIN, permission_set: ADMIN_FULL_ACCESS, organization_id: DEFAULT_ORG }); } return { sys_user: [{ id: ACTOR, email: 'tenant-admin@example.com', email_verified: true }], sys_member: [{ organization_id: TENANT_ORG, user_id: ACTOR, role: 'owner' }], + sys_organization: [{ id: DEFAULT_ORG, slug: 'default' }, { id: TENANT_ORG, slug: 'northwind' }], sys_user_position: shape === 'name-only' ? [ // Exactly what a tenant admin can write through the - // `apiEnabled` `sys_user_position` surface. - { user_id: ACTOR, position: BUILTIN_IDENTITY_PLATFORM_ADMIN, organization_id: null }, + // `apiEnabled` `sys_user_position` surface, in their own + // organization — the one the resolution runs in. + { user_id: ACTOR, position: BUILTIN_IDENTITY_PLATFORM_ADMIN, organization_id: TENANT_ORG }, ] : [], // An ACTIVE catalogue row, so ADR-0049's deactivated-position filter is @@ -240,7 +245,7 @@ for (const posture of ['group', 'isolated'] as const) { expect(hName.toggleFlow).not.toHaveBeenCalled(); }); - it('CONTROL — a genuine unscoped admin_full_access grant still flips the switch', async () => { + it('CONTROL — a genuine admin_full_access grant owned by the Default Organization still flips the switch', async () => { const { context, rung } = await resolve('genuine'); const h = boot(posture); const ec = (context as any).executionContext; 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 28545421823..b0dbcb57122 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 @@ -9,7 +9,7 @@ * how that standing is obtained. Which routes to it exist is decided in ONE * place, `resolveUserAuthzGrants` in `@objectstack/core`, and it answers per * REQUESTED tenancy posture: the declared administrator list - * (`OS_PLATFORM_OWNER_EMAIL`) on every posture, the unscoped + * (`OS_PLATFORM_OWNER_EMAIL`) on every posture, the Default Organization's * `admin_full_access` grant under `single` only. The sentence used to name the * grant everywhere, so on a walled deployment it sent the operator to do a * thing that then failed silently at the permission layer. @@ -128,12 +128,15 @@ const REMEDIES: readonly Remedy[] = [ }), }, { - label: `the unscoped ${ADMIN_FULL_ACCESS} grant`, + label: `the Default Organization's ${ADMIN_FULL_ACCESS} grant`, named: (message) => message.includes(ADMIN_FULL_ACCESS), arrange: () => { const tables = userOnly('usr_granted', 'legacy@corp.example'); + // The grant that anchors platform standing is the one the Default + // Organization owns; an organization-less one anchors nothing. + tables.sys_organization = [{ id: 'org_default', slug: 'default' }]; tables.sys_user_permission_set = [ - { id: 'ups_1', user_id: 'usr_granted', permission_set_id: 'pst_1', permission_set: ADMIN_FULL_ACCESS, organization_id: null }, + { id: 'ups_1', user_id: 'usr_granted', permission_set_id: 'pst_1', permission_set: ADMIN_FULL_ACCESS, organization_id: 'org_default' }, ]; 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/automation.ts b/packages/runtime/src/domains/automation.ts index 84d4f8b34e4..1898b79bd33 100644 --- a/packages/runtime/src/domains/automation.ts +++ b/packages/runtime/src/domains/automation.ts @@ -739,7 +739,7 @@ function runLifecycleStandingSentence(): string { + `that standing comes only from ${RUN_LIFECYCLE_DECLARED_ADMINISTRATORS}.`; } return `It requires platform-operator standing, which under this deployment's '${posture}' tenancy posture ` - + `comes from an unscoped \`${ADMIN_FULL_ACCESS}\` grant (ADR-0068 D2) or from ` + + `comes from the Default Organization's \`${ADMIN_FULL_ACCESS}\` grant (ADR-0068 D2, ADR-0131 D5) or from ` + `${RUN_LIFECYCLE_DECLARED_ADMINISTRATORS}.`; } @@ -761,9 +761,10 @@ function runLifecycleStandingSentence(): string { * - the declared administrator list (`OS_PLATFORM_OWNER_EMAIL`, matched * against the caller's own VERIFIED stored email) confers the rung on EVERY * posture — the config anchor is additive; - * - the unscoped `admin_full_access` grant (ADR-0068 D2) confers it under - * `single` ONLY: under `group` / `isolated` its anchor is retired, so on a - * walled deployment that grant confers nothing. + * - the Default Organization's `admin_full_access` grant (ADR-0068 D2, + * ADR-0131 D5) confers it under `single` ONLY: under `group` / `isolated` + * its anchor is retired, so on a walled deployment that grant confers + * nothing. * * One fixed sentence naming the grant therefore sent a walled operator to do a * thing that then failed silently at the permission layer. The middle part @@ -822,8 +823,8 @@ function runLifecycleDenyMessage(): string { * `resolveUserAuthzGrants` §4 pushes it onto the array. The rung is derived * from the platform-admin anchors and never from a position name: on every * posture, a declared `OS_PLATFORM_OWNER_EMAIL` address on the caller's own - * verified stored row; under `single` only, also the unscoped - * `admin_full_access` grant (ADR-0068 D2), whose anchor a walled posture + * verified stored row; under `single` only, also the Default Organization's + * `admin_full_access` grant (ADR-0068 D2, ADR-0131 D5), whose anchor a walled posture * retires. So it is what the ruling MEANT, and it is byte-for-byte what * `hasPlatformAdminStanding` returns. * diff --git a/packages/runtime/src/domains/catalog-activation-door.test.ts b/packages/runtime/src/domains/catalog-activation-door.test.ts index f04444a9eac..04bf5653bfe 100644 --- a/packages/runtime/src/domains/catalog-activation-door.test.ts +++ b/packages/runtime/src/domains/catalog-activation-door.test.ts @@ -26,6 +26,8 @@ import { bindTestSecurityCatalog, type TestSecurityCatalog } from '../security/s const LEDGER = 'sys_metadata_activation'; const USER = 'u_holder'; +/** The organization the holder's grants belong to, and the one the EFFECT is resolved in. */ +const HOLDER_ORG = 'org_holder'; const POSITION = 'sales_rep'; const SET = 'sales_access'; const DIRECT_SET = 'report_reader'; @@ -67,11 +69,11 @@ function makeEngine(opts: EngineOptions = {}) { sys_user: [{ id: USER }], sys_member: [], sys_user_position: [ - { user_id: USER, position: POSITION, organization_id: null }, - { user_id: USER, position: ENV_POSITION, organization_id: null }, + { user_id: USER, position: POSITION, organization_id: HOLDER_ORG }, + { user_id: USER, position: ENV_POSITION, organization_id: HOLDER_ORG }, ], sys_user_permission_set: [ - { user_id: USER, permission_set_id: 'ps_direct', permission_set: DIRECT_SET, organization_id: null }, + { user_id: USER, permission_set_id: 'ps_direct', permission_set: DIRECT_SET, organization_id: HOLDER_ORG }, ], [LEDGER]: [], }; @@ -185,7 +187,8 @@ const messageOf = (r: any): string => String(r?.response?.body?.error?.message ? const flip = (h: Harness, ctx: HttpProtocolContext, path: string, body: unknown = { enabled: false }) => h.dispatcher.handleSecurity(path, 'POST', body, {}, ctx); -const grantsOf = (h: Harness) => resolveUserAuthzGrants(h.engine, USER, {}); +// A grant applies only in its own organization, so the effect is read there. +const grantsOf = (h: Harness) => resolveUserAuthzGrants(h.engine, USER, { tenantId: HOLDER_ORG }); describe('the catalog activation door — authority', () => { for (const [type, name] of [['position', POSITION], ['permission', SET]] as const) { 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 ec6d5c731cd..9a16db15522 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 @@ -137,8 +137,8 @@ function protocolDouble() { * `u_exmember`'s session is stamped `org_alpha` while its only current * `sys_member` row is `org_beta` — the session outlived the membership that * backed it. `u_member` is a current `org_alpha` member. Both hold ONE shared - * permission set, so RBAC cannot separate any two arms: only the organization - * claim can. + * permission set (bound to the built-in `everyone` position), so RBAC cannot + * separate any two arms: only the organization claim can. */ function makeQl() { const tables: Record = { @@ -153,13 +153,21 @@ function makeQl() { { id: 'u_exmember', email: 'u_exmember@example.com' }, { id: 'u_admin', email: 'u_admin@example.com' }, ], + // The Default Organization: it owns the operator's `admin_full_access` + // grant, the one grant that anchors platform standing. + sys_organization: [{ id: 'org_default', slug: 'default' }], sys_user_permission_set: [ - { 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' }, // [ADR-0131 D12 items 7–8] The platform operator: under the wall // the one caller the `/meta` write doors admit. - { user_id: 'u_admin', permission_set_id: 'ps_admin', permission_set: 'admin_full_access', organization_id: null }, + { user_id: 'u_admin', permission_set_id: 'ps_admin', permission_set: 'admin_full_access', organization_id: 'org_default' }, ], + // The ONE shared set reaches every caller through the built-in + // `everyone` position, so it holds in whichever organization a caller + // acts in and with none — the ex-member's dropped claim included. A + // user grant applies only in its own organization, so it could not be + // shared across the arms. + sys_position: [{ id: 'pos_everyone', name: 'everyone' }], + sys_position_permission_set: [{ id: 'pps_everyone_shared', position_id: 'pos_everyone', permission_set_id: 'ps_shared' }], sys_permission_set: [ { id: 'ps_shared', name: 'shared_access', system_permissions: ['manage_metadata', 'studio.access'] }, { id: 'ps_admin', name: 'admin_full_access', system_permissions: ['manage_metadata', 'studio.access', 'setup.access'] }, 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 a1830e86a35..b34827231d0 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 @@ -22,9 +22,11 @@ * organization's copy of `org_member` fed its bindings to anyone still an * `org_member` somewhere. * - * The rule now: with no active organization only the global grants apply, and - * a grant scoped to an organization applies only while that organization is the - * active tenant (`resolveUserAuthzGrants`, `@objectstack/core`). + * The rule now: a grant applies only while its organization is the active + * tenant, so with no active organization no stored grant applies, and a grant + * that names no organization applies nowhere (`resolveUserAuthzGrants`, + * `@objectstack/core`). Platform standing is anchored only by the + * `admin_full_access` grant the Default Organization owns. * * ## The rig * @@ -50,6 +52,8 @@ import { bindCatalogFromTables } from '../security/security-catalog.testkit.js'; const PKG = 'com.acme.crm'; const ALPHA = 'org_alpha'; const BETA = 'org_beta'; +/** The Default Organization: its `admin_full_access` grant anchors platform standing. */ +const DEFAULT_ORG = 'org_default'; const SESSION_HEADER = 'x-test-session'; /** Equality plus `$in`, and a loud refusal of every other shape. */ @@ -74,9 +78,12 @@ function matchesWhere(row: any, where: any): boolean { * `org_beta`; `u_gone` has no membership left anywhere. Both still hold the * `org_alpha`-scoped `manage_metadata` set. `u_member` is the control: a * current `org_alpha` member holding the same grant. `u_global` was removed the - * same way but holds the set GLOBALLY. `u_admin` holds `admin_full_access` - * unscoped — the row the platform-admin bootstrap mints. `u_orgless` signed in, - * never selected an organization and holds no grant at all. + * same way but holds the set with NO organization — a row the ownership + * ceremony never leaves behind, so it confers nothing. `u_admin` holds + * `admin_full_access` owned by the Default Organization — the row the + * platform-admin bootstrap mints. `u_oldadmin` holds `admin_full_access` with no + * organization, the row an earlier release's bootstrap wrote. `u_orgless` + * signed in, never selected an organization and holds no grant at all. */ const TABLES: Record = { sys_api_key: [], @@ -87,16 +94,20 @@ const TABLES: Record = { { user_id: 'u_admin', organization_id: ALPHA, role: 'owner' }, { user_id: 'u_posex', organization_id: BETA, role: 'member' }, { user_id: 'u_posmember', organization_id: ALPHA, role: 'member' }, + { user_id: 'u_oldadmin', organization_id: ALPHA, role: 'member' }, ], - sys_user: ['u_member', 'u_exmember', 'u_gone', 'u_global', 'u_admin', 'u_posex', 'u_posmember', 'u_orgless'] + sys_user: ['u_member', 'u_exmember', 'u_gone', 'u_global', 'u_admin', 'u_posex', 'u_posmember', 'u_orgless', 'u_oldadmin'] .map((id) => ({ id, email: `${id}@example.com` })), sys_user_permission_set: [ { 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 }, + { user_id: 'u_admin', permission_set_id: 'ps_admin', permission_set: 'admin_full_access', organization_id: DEFAULT_ORG }, + { user_id: 'u_oldadmin', permission_set_id: 'ps_admin', permission_set: 'admin_full_access', organization_id: null }, ], + // The Default Organization, which owns the platform-admin bootstrap's grant. + sys_organization: [{ id: DEFAULT_ORG, slug: 'default' }], sys_permission_set: [ { id: 'ps_meta', name: 'alpha_metadata_editors', system_permissions: ['manage_metadata', 'studio.access'] }, { id: 'ps_admin', name: 'admin_full_access', system_permissions: ['manage_metadata', 'studio.access', 'setup.access'] }, @@ -115,16 +126,24 @@ const TABLES: Record = { ], }; -type Who = 'member' | 'exmember' | 'gone' | 'global' | 'admin' | 'admin_orgless' | 'posex' | 'posmember' | 'orgless'; +type Who = 'member' | 'exmember' | 'gone' | 'global' | 'global_beta' | 'admin' | 'admin_orgless' + | 'oldadmin' | 'oldadmin_orgless' | 'posex' | 'posmember' | 'orgless'; -/** Every session names `org_alpha` except `admin_orgless` and `orgless`, which name no organization. */ +/** + * Every session names `org_alpha` except `global_beta` (`org_beta`, where its + * holder IS a member), and `admin_orgless`, `oldadmin_orgless` and `orgless`, + * which name no organization. + */ const SESSIONS: Record = { member: { id: 'ses_member', token: 'tok_member', userId: 'u_member', activeOrganizationId: ALPHA }, exmember: { id: 'ses_exmember', token: 'tok_exmember', userId: 'u_exmember', activeOrganizationId: ALPHA }, gone: { id: 'ses_gone', token: 'tok_gone', userId: 'u_gone', activeOrganizationId: ALPHA }, global: { id: 'ses_global', token: 'tok_global', userId: 'u_global', activeOrganizationId: ALPHA }, + global_beta: { id: 'ses_global_beta', token: 'tok_global_beta', userId: 'u_global', activeOrganizationId: BETA }, admin: { id: 'ses_admin', token: 'tok_admin', userId: 'u_admin', activeOrganizationId: ALPHA }, admin_orgless: { id: 'ses_admin2', token: 'tok_admin2', userId: 'u_admin' }, + oldadmin: { id: 'ses_oldadmin', token: 'tok_oldadmin', userId: 'u_oldadmin', activeOrganizationId: ALPHA }, + oldadmin_orgless: { id: 'ses_oldadmin2', token: 'tok_oldadmin2', userId: 'u_oldadmin' }, posex: { id: 'ses_posex', token: 'tok_posex', userId: 'u_posex', activeOrganizationId: ALPHA }, posmember: { id: 'ses_posmember', token: 'tok_posmember', userId: 'u_posmember', activeOrganizationId: ALPHA }, orgless: { id: 'ses_orgless', token: 'tok_orgless', userId: 'u_orgless' }, @@ -135,6 +154,10 @@ function rig() { (registry as any).logLevel = 'silent'; registry.installPackage({ id: PKG, namespace: 'crm', name: 'CRM', version: '1.0.0', type: 'app', scope: 'project' } as any, 'code'); registry.registerObject({ name: 'crm_lead', fields: { title: { type: 'text' } } } as any, PKG, 'crm', 'own'); + // The organization object the platform registers: the resolver reads the + // Default Organization (the owner of the anchoring admin grant) only from a + // registry that holds it. + registry.registerObject({ name: 'sys_organization', fields: { slug: { type: 'text' } } } as any, 'com.objectstack.platform', 'sys', 'own'); const deleteRequests: any[] = []; const protocol = { @@ -261,6 +284,15 @@ async function refusedByTheWallNotTheCapabilityGate(answer: { status: number; co expect(message).not.toContain('requires the `manage_metadata` capability'); } +/** The capability gate's own refusal: it names the capability, and the walled refusal is never reached. */ +function refusedByTheCapabilityGate(answer: { status: number; code: unknown; httpStatus?: unknown; body: any }): void { + expect({ status: answer.status, code: answer.code, httpStatus: answer.httpStatus }) + .toEqual({ status: 403, code: 'PERMISSION_DENIED', httpStatus: 403 }); + const message = String(answer.body?.error?.message ?? ''); + expect(message).toContain('requires the `manage_metadata` capability'); + expect(message).not.toContain("'isolated' tenancy posture"); +} + describe('[#20515] what the rule leaves unchanged', () => { it('CONTROL · a current member with the same grant and org_alpha active passes the capability gate: DELETE /packages/:id reaches the walled refusal, and nothing is deleted', async () => { const r = rig(); @@ -286,11 +318,21 @@ describe('[#20515] what the rule leaves unchanged', () => { expect(r.deleteRequests).toEqual([]); }); - it('a GLOBAL grant still passes the capability gate for the removed member — the walled refusal answers next, below the operator rung', async () => { + // The CONTROL above holds the same set scoped to org_alpha and passes the + // capability gate, so this refusal is the organization-less row's, not the set's. + it('an ORGANIZATION-LESS grant confers nothing: the removed member holding it is refused by the capability gate on both doors', async () => { + const r = rig(); + refusedByTheCapabilityGate(await r.call('DELETE', 'global', `/packages/${PKG}`)); + expect(r.deleteRequests).toEqual([]); + refusedByTheCapabilityGate(await r.call('PATCH', 'global', `/packages/${PKG}/disable`)); + expect(switchedOff(r)).toBe(false); + }); + + it('an organization-less grant confers nothing in an organization its holder IS a member of, either: org_beta active, both doors refused by the capability gate', async () => { const r = rig(); - await refusedByTheWallNotTheCapabilityGate(await r.call('DELETE', 'global', `/packages/${PKG}`)); + refusedByTheCapabilityGate(await r.call('DELETE', 'global_beta', `/packages/${PKG}`)); expect(r.deleteRequests).toEqual([]); - await refusedByTheWallNotTheCapabilityGate(await r.call('PATCH', 'global', `/packages/${PKG}/disable`)); + refusedByTheCapabilityGate(await r.call('PATCH', 'global_beta', `/packages/${PKG}/disable`)); expect(switchedOff(r)).toBe(false); }); @@ -303,13 +345,25 @@ describe('[#20515] what the rule leaves unchanged', () => { expect(switchedOff(d)).toBe(true); }); - it('platform-admin standing (unscoped admin_full_access) passes with org_alpha active, and with no organization at all', async () => { + it('platform-admin standing (the Default Organization\'s admin_full_access) passes with org_alpha active, and with no organization at all', async () => { expect((await rig().call('DELETE', 'admin', `/packages/${PKG}`)).status).toBe(200); expect((await rig().call('PATCH', 'admin', `/packages/${PKG}/disable`)).status).toBe(200); expect((await rig().call('DELETE', 'admin_orgless', `/packages/${PKG}`)).status).toBe(200); expect((await rig().call('PATCH', 'admin_orgless', `/packages/${PKG}/disable`)).status).toBe(200); }); + // The operator test above is the positive control: the same set, owned by the + // Default Organization, passes both doors with and without an organization. + it('an ORGANIZATION-LESS admin_full_access grant anchors nothing: its holder is refused by the capability gate with org_alpha active, and with no organization at all', async () => { + for (const who of ['oldadmin', 'oldadmin_orgless'] as const) { + const r = rig(); + refusedByTheCapabilityGate(await r.call('DELETE', who, `/packages/${PKG}`)); + refusedByTheCapabilityGate(await r.call('PATCH', who, `/packages/${PKG}/disable`)); + expect(r.deleteRequests).toEqual([]); + expect(switchedOff(r)).toBe(false); + } + }); + it('a caller who never selected an organization and holds no grant is refused 403 on both doors, as before', async () => { const r = rig(); const del = await r.call('DELETE', 'orgless', `/packages/${PKG}`); 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 49f8c0415ae..015da15ef82 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 @@ -123,7 +123,8 @@ 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', permission_set: 'pkg_admin' }], + // Owned by the organization the session names: a grant applies only there. + sys_user_permission_set: [{ user_id: 'u_admin', permission_set_id: 'ps_pkg', permission_set: 'pkg_admin', organization_id: ORG }], sys_permission_set: [ { id: 'ps_pkg', name: 'pkg_admin', system_permissions: ['manage_metadata', 'studio.access'] }, ], diff --git a/packages/runtime/src/domains/packages-single-door.test.ts b/packages/runtime/src/domains/packages-single-door.test.ts index 59e16958635..b03af40a423 100644 --- a/packages/runtime/src/domains/packages-single-door.test.ts +++ b/packages/runtime/src/domains/packages-single-door.test.ts @@ -106,10 +106,17 @@ function matchesWhere(row: any, where: any): boolean { return true; } -/** The permission store the shared authz resolver reads, in its shipped shapes. */ +/** The organization the caller acts in, and the one that owns their grant. */ +const ORG = 'org_1'; + +/** + * The permission store the shared authz resolver reads, in its shipped shapes. + * A grant applies only in its own organization, so the caller's session names + * the organization that owns it (kept under `single` without a membership row). + */ 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', permission_set: 'pkg_admin' }], + sys_user_permission_set: [{ user_id: 'u_admin', permission_set_id: 'ps_pkg', permission_set: 'pkg_admin', organization_id: ORG }], sys_permission_set: [ { id: 'ps_pkg', name: 'pkg_admin', system_permissions: ['manage_metadata', 'studio.access'] }, ], @@ -138,7 +145,7 @@ function kernelWith(registry: SchemaRegistry): any { return typeof q?.limit === 'number' ? rows.slice(0, q.limit) : rows; }, }, TABLES); - const auth = { api: { getSession: async () => ({ user: { id: 'u_admin' } }) } }; + const auth = { api: { getSession: async () => ({ user: { id: 'u_admin' }, session: { activeOrganizationId: ORG } }) } }; const services: Record = { objectql: ql, auth }; return { getState: () => 'running', 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 ca9170f4958..2a0a480548e 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 @@ -41,9 +41,9 @@ * Three populations below the operator rung: a current member, a member * removed from the organization whose session still names it (the resolver * drops the claim), and a caller who never selected an organization. All three - * are refused the same way. The platform operator (an unscoped - * `admin_full_access` grant) uninstalls, with or without an active - * organization, naming none. + * are refused the same way. The platform operator (an `admin_full_access` + * grant owned by the Default Organization) uninstalls, with or without an + * active organization, naming none. */ import { mkdtempSync, rmSync } from 'node:fs'; @@ -57,6 +57,8 @@ import { bindCatalogFromTables } from '../security/security-catalog.testkit.js'; const PKG = 'com.acme.crm'; const ALPHA = 'org_alpha'; const BETA = 'org_beta'; +/** The Default Organization, which owns the operator's grant. */ +const DEFAULT_ORG = 'org_default'; const SESSION_HEADER = 'x-test-session'; // ── Identity: sessions and the permission store, in the shipped shapes ──────── @@ -96,12 +98,18 @@ const TABLES: Record = { { id: 'u_orgless', email: 'u_orgless@example.com' }, { id: 'u_admin', email: 'u_admin@example.com' }, ], + // The Default Organization: it owns the operator's `admin_full_access` + // grant, the one grant that anchors platform standing. + sys_organization: [{ id: DEFAULT_ORG, slug: 'default' }], sys_user_permission_set: [ - { 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' }, - { user_id: 'u_admin', permission_set_id: 'ps_admin', permission_set: 'admin_full_access', organization_id: null }, + { user_id: 'u_admin', permission_set_id: 'ps_admin', permission_set: 'admin_full_access', organization_id: DEFAULT_ORG }, ], + // The ONE shared set reaches every caller through the built-in `everyone` + // position, so it holds in whichever organization a caller acts in and with + // none. A user grant applies only in its own organization, so it could not + // be shared by a caller whose claim is dropped or who never selected one. + sys_position: [{ id: 'pos_everyone', name: 'everyone' }], + sys_position_permission_set: [{ id: 'pps_everyone_shared', position_id: 'pos_everyone', permission_set_id: 'ps_shared' }], sys_permission_set: [ { id: 'ps_shared', name: 'shared_access', system_permissions: ['manage_metadata', 'studio.access'] }, { id: 'ps_admin', name: 'admin_full_access', system_permissions: ['manage_metadata', 'studio.access', 'setup.access'] }, @@ -152,6 +160,10 @@ function rig(opts: { persistedHalf?: boolean } = {}): Rig { (registry as any).logLevel = 'silent'; registry.installPackage({ id: PKG, namespace: 'crm', name: 'CRM', version: '1.0.0', type: 'app', scope: 'project' } as any, 'code'); registry.registerObject({ name: 'crm_lead', fields: { title: { type: 'text' } } } as any, PKG, 'crm', 'own'); + // The organization object the platform registers: the resolver reads the + // Default Organization (the owner of the anchoring admin grant) only from a + // registry that holds it. + registry.registerObject({ name: 'sys_organization', fields: { slug: { type: 'text' } } } as any, 'com.objectstack.platform', 'sys', 'own'); const objectName = registry.getAllObjects().map((o: any) => o.name).find((n: string) => n.endsWith('crm_lead'))!; const rows = ['object:crm_lead', 'view:crm_lead_list']; 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 f4233ba31ad..2de803904b9 100644 --- a/packages/runtime/src/domains/packages-vetted-org-source.test.ts +++ b/packages/runtime/src/domains/packages-vetted-org-source.test.ts @@ -57,8 +57,9 @@ * [ADR-0131 D12 items 7–8] Under this rig's `isolated` posture every package * WRITE is the platform operator's, so the three members are refused at the * write doors before any protocol call, and reach no partition at all; the - * read doors serve them the env-wide partition. The operator (an unscoped - * `admin_full_access` grant, its session naming `org_alpha`) is the admitted + * read doors serve them the env-wide partition. The operator (an + * `admin_full_access` grant owned by the Default Organization, its session + * naming `org_alpha`) is the admitted * writer, and it reaches the env-wide partition too. */ @@ -75,6 +76,8 @@ const PREFIX = '/api/v1'; const PKG = 'com.acme.crm'; const ALPHA = 'org_alpha'; const BETA = 'org_beta'; +/** The Default Organization, which owns the operator's grant. */ +const DEFAULT_ORG = 'org_default'; /** The env-wide partition's key: what a request carrying no organization reaches. */ const ENV_WIDE = 'env_wide'; const SESSION_HEADER = 'x-test-session'; @@ -178,7 +181,8 @@ function protocolDouble() { /** * `u_exmember`'s stale session names `org_alpha` while their only current * `sys_member` row is `org_beta`. `u_member` is a current `org_alpha` member. - * Both hold ONE shared permission set carrying both package capabilities. + * Both hold ONE shared permission set carrying both package capabilities, + * bound to the built-in `everyone` position. */ function makeQl() { const tables: Record = { @@ -193,11 +197,19 @@ function makeQl() { { id: 'u_exmember', email: 'u_exmember@example.com' }, { id: 'u_admin', email: 'u_admin@example.com' }, ], + // The Default Organization: it owns the operator's `admin_full_access` + // grant, the one grant that anchors platform standing. + sys_organization: [{ id: DEFAULT_ORG, slug: 'default' }], sys_user_permission_set: [ - { 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_admin', permission_set_id: 'ps_admin', permission_set: 'admin_full_access', organization_id: null }, + { user_id: 'u_admin', permission_set_id: 'ps_admin', permission_set: 'admin_full_access', organization_id: DEFAULT_ORG }, ], + // The ONE shared set reaches every caller through the built-in + // `everyone` position, so it holds in whichever organization a caller + // acts in and with none — the ex-member's dropped claim included. A + // user grant applies only in its own organization, so it could not be + // shared across the arms. + sys_position: [{ id: 'pos_everyone', name: 'everyone' }], + sys_position_permission_set: [{ id: 'pps_everyone_shared', position_id: 'pos_everyone', permission_set_id: 'ps_shared' }], sys_permission_set: [ { id: 'ps_shared', name: 'shared_access', system_permissions: ['manage_metadata', 'studio.access'] }, { id: 'ps_admin', name: 'admin_full_access', system_permissions: ['manage_metadata', 'studio.access', 'setup.access'] }, diff --git a/packages/runtime/src/security/resolve-execution-context.oauth-organization.test.ts b/packages/runtime/src/security/resolve-execution-context.oauth-organization.test.ts index 3ccce48aa9d..9f77b5472a7 100644 --- a/packages/runtime/src/security/resolve-execution-context.oauth-organization.test.ts +++ b/packages/runtime/src/security/resolve-execution-context.oauth-organization.test.ts @@ -23,8 +23,8 @@ * session's `activeOrganizationId`, so the session arm of * `resolveAuthzContext` vets it on EVERY request with the rule it applies to a * browser session (`vetOrganizationClaim`): kept while a current membership - * backs it under a walled posture, dropped (no organization, global grants - * only) once none does. ⛔ No union of the user's organizations, no widened + * backs it under a walled posture, dropped (no organization, so no stored + * grant applies) once none does. ⛔ No union of the user's organizations, no widened * global grant, no third branch in core. * * ## The rig @@ -51,6 +51,8 @@ const ALPHA = 'org_alpha'; const BETA = 'org_beta'; /** An organization no fixture user belongs to. */ const GAMMA = 'org_gamma'; +/** The Default Organization, which owns the platform-admin row. */ +const DEFAULT_ORG = 'org_default'; const CLIENT = 'agent-client-1'; const LEAD = 'crm_lead'; const CONTACT = 'crm_contact'; @@ -68,11 +70,14 @@ const SETS: PermissionSet[] = [ * `crm_contact_reader` in org_beta; a member of both — the "another * organization" control. * - `u_pat` is an org_alpha member holding no grant. - * - `u_admin` holds `admin_full_access` GLOBALLY (the platform-admin row). + * - `u_admin` holds `admin_full_access` owned by the Default Organization (the + * platform-admin row), which applies whatever organization is active. * - `u_ex` held `sales_rep` in org_alpha and was removed from it (no * org_alpha `sys_member` row; still a member of org_beta). They also hold - * `crm_lead_global` globally, so what is left after the claim is dropped is - * visible: a read the wall empties and a write it refuses. + * `crm_lead_global` in org_beta, so what is left after the claim is dropped + * is visible: nothing, since no organization is active. And they hold + * `crm_contact_reader` with NO organization — a row that applies nowhere, + * not even in org_beta. */ function tables(): Record { return { @@ -91,9 +96,12 @@ function tables(): Record { ], sys_user_permission_set: [ { user_id: 'u_mia', permission_set: 'crm_contact_reader', organization_id: BETA }, - { user_id: 'u_admin', permission_set: 'admin_full_access', organization_id: null }, - { user_id: 'u_ex', permission_set: 'crm_lead_global', organization_id: null }, + { user_id: 'u_admin', permission_set: 'admin_full_access', organization_id: DEFAULT_ORG }, + { user_id: 'u_ex', permission_set: 'crm_lead_global', organization_id: BETA }, + { user_id: 'u_ex', permission_set: 'crm_contact_reader', organization_id: null }, ], + // The Default Organization: its `admin_full_access` grant anchors platform standing. + sys_organization: [{ id: DEFAULT_ORG, slug: 'default' }], sys_permission_set: SETS.map((s) => ({ id: `ps_${s.name}`, name: s.name, object_permissions: JSON.stringify(s.objects ?? {}) })), sys_position: [{ id: 'pos_sales_rep', name: 'sales_rep', organization_id: null }], sys_position_permission_set: [{ position_id: 'pos_sales_rep', permission_set_id: 'ps_crm_sales_user' }], @@ -273,26 +281,35 @@ describe('an OAuth token acts in the organization its consent bound — isolated await expect(r.run(ctx, 'find', LEAD)).rejects.toThrow(D10_DENIAL); }); - it('CONTROL — a revoked membership: the claim is dropped, so only global grants apply — the read comes back empty and the tenant write is refused loudly', async () => { + it('CONTROL — a revoked membership: the claim is dropped, so no stored grant applies — the read and the write are both refused', async () => { const ctx = await r.resolve(READ_TOKEN('u_ex', ALPHA)); expect(ctx.tenantId).toBeUndefined(); expect(droppedClaimLines()).toHaveLength(1); expect(droppedClaimLines()[0]).toContain(`principal=u_ex organization=${ALPHA}`); - // The global grant admits the read; the wall has no organization to read in. - const read = await r.run(ctx, 'find', LEAD); - expect(conjuncts(read.ast.where)).toEqual([RLS_DENY_FILTER, RLS_DENY_FILTER]); - await expect(r.run(ctx, 'insert', LEAD)).rejects.toThrow(/has no active organization/); + // The org_beta grant counts only with org_beta active, and none is. + await expect(r.run(ctx, 'find', LEAD)).rejects.toThrow(D10_DENIAL); + await expect(r.run(ctx, 'insert', LEAD)).rejects.toThrow(D10_DENIAL); + }); + + it('CONTROL — the same former member bound to org_beta: the org_beta grant admits the read; the ORGANIZATION-LESS grant admits nothing', async () => { + const ctx = await r.resolve(READ_TOKEN('u_ex', BETA)); + expect(ctx.tenantId).toBe(BETA); + expect(droppedClaimLines()).toEqual([]); + // The positive leg: the organization-scoped grant counts in its organization. + expect(conjuncts((await r.run(ctx, 'find', LEAD)).ast.where)).toEqual([{ organization_id: BETA }, { organization_id: BETA }]); + // An organization-less row applies nowhere, even where its holder is a member. + await expect(r.run(ctx, 'find', CONTACT)).rejects.toThrow(D10_DENIAL); }); - it('CONTROL — a platform admin\'s global grant is unchanged: it admits the delegated read bound or unbound; only the organization the wall reads in moves', async () => { + it('CONTROL — a platform admin\'s Default Organization grant is unchanged: it admits the delegated read bound or unbound; only the organization the wall reads in moves', async () => { const bound = await r.resolve(READ_TOKEN('u_admin', ALPHA)); expect(bound.tenantId).toBe(ALPHA); expect(bound.posture).toBe('PLATFORM_ADMIN'); expect(conjuncts((await r.run(bound, 'find', LEAD)).ast.where)).toEqual([{ organization_id: ALPHA }, { organization_id: ALPHA }]); // Unbound is what every token resolved before this change: still admitted - // by the global grant, and — a business object under `isolated` — walled + // by the anchoring grant, and — a business object under `isolated` — walled // to nothing with no organization to read in (ADR-0095 W2), as before. const unbound = await r.resolve(READ_TOKEN('u_admin')); expect(unbound.tenantId).toBeUndefined(); @@ -300,7 +317,7 @@ describe('an OAuth token acts in the organization its consent bound — isolated expect(conjuncts((await r.run(unbound, 'find', LEAD)).ast.where)).toEqual([RLS_DENY_FILTER, RLS_DENY_FILTER]); }); - it('CONTROL — a token minted before consents bound an organization (no claim) keeps today\'s behaviour: global grants only', async () => { + it('CONTROL — a token minted before consents bound an organization (no claim) acts in no organization: no stored grant applies', async () => { const ctx = await r.resolve(READ_TOKEN('u_mia')); expect(ctx.tenantId).toBeUndefined(); await expect(r.run(ctx, 'find', LEAD)).rejects.toThrow(D10_DENIAL); diff --git a/packages/runtime/src/security/resolve-execution-context.test.ts b/packages/runtime/src/security/resolve-execution-context.test.ts index a5d64d8aa4c..ec2e8ce80d0 100644 --- a/packages/runtime/src/security/resolve-execution-context.test.ts +++ b/packages/runtime/src/security/resolve-execution-context.test.ts @@ -304,15 +304,19 @@ describe('resolveExecutionContext — localization (timezone + locale)', () => { // --------------------------------------------------------------------------- -// ADR-0066 — platform-scoped (null-org) permission-set grants are GLOBAL and -// must resolve even when the caller has an active org. Regression for the gap -// where `organization_id = tenantId` dropped a platform admin's null-org -// admin_full_access grant (and its systemPermissions) the moment they owned an -// org — which then locked them out of a requiredPermissions-gated object. +// ADR-0066 — the platform-scoped permission-set grant must resolve even when the +// caller has an active org. Regression for the gap where `organization_id = +// tenantId` dropped a platform admin's admin_full_access grant (and its +// systemPermissions) the moment they owned an org — which then locked them out +// of a requiredPermissions-gated object. The platform-scoped grant is the one +// the Default Organization owns: it anchors platform standing whatever the +// active org. An organization-less grant is not platform-scoped — it applies +// nowhere and anchors nothing. // --------------------------------------------------------------------------- -describe('resolveExecutionContext — platform-scoped (null-org) grants (ADR-0066)', () => { +describe('resolveExecutionContext — the platform-scoped (Default Organization) grant (ADR-0066)', () => { const RAW = 'osk_admin'; - function makeAuthQl(extraGrants = []) { + /** @param adminGrantOrg the organization that owns u1's admin_full_access grant. */ + function makeAuthQl(extraGrants = [], adminGrantOrg: string | null = 'org_default') { const tables = { // [#8287] Re-spelled from `organization_id`: these fixtures use the key as a // VEHICLE to authenticate u1 into orgA, and `active_organization_id` is the @@ -321,8 +325,9 @@ describe('resolveExecutionContext — platform-scoped (null-org) grants (ADR-006 // out), so this keeps them meaningful rather than merely green. 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_organization: [{ id: 'org_default', slug: 'default' }, { id: 'orgA', slug: 'org-a' }], sys_user_permission_set: [ - { id: 'ups_global', user_id: 'u1', permission_set_id: 'ps_admin', permission_set: 'admin_full_access', organization_id: null }, + { id: 'ups_global', user_id: 'u1', permission_set_id: 'ps_admin', permission_set: 'admin_full_access', organization_id: adminGrantOrg }, ...extraGrants, ], sys_permission_set: [ @@ -352,20 +357,32 @@ describe('resolveExecutionContext — platform-scoped (null-org) grants (ADR-006 } const opts = (ql) => ({ getService: async () => undefined, getQl: async () => ql, request: { headers: { 'x-api-key': RAW } } }); - it('resolves a null-org admin grant + its systemPermissions even with an active org', async () => { + it('resolves the Default Organization\'s admin grant + its systemPermissions even with another org active', async () => { const ctx = await resolveExecutionContext(opts(makeAuthQl())); expect(ctx.tenantId).toBe('orgA'); expect(ctx.permissions).toContain('admin_full_access'); expect(ctx.systemPermissions).toContain('manage_platform_settings'); // [#2947] the derived posture rung is now CARRIED on the ctx (previously - // dropped at this entry). An unscoped admin_full_access grant → PLATFORM_ADMIN. + // dropped at this entry). The Default Organization's admin_full_access + // grant → PLATFORM_ADMIN. expect(ctx.posture).toBe('PLATFORM_ADMIN'); }); + // The test above is the CONTROL: the same grant, owned by the Default + // Organization, resolves in full with orgA active. + it('an ORGANIZATION-LESS admin grant resolves nothing: no set, no capability, no platform standing', async () => { + const ctx = await resolveExecutionContext(opts(makeAuthQl([], null))); + expect(ctx.tenantId).toBe('orgA'); + expect(ctx.permissions).not.toContain('admin_full_access'); + expect(ctx.systemPermissions).not.toContain('manage_platform_settings'); + expect(ctx.systemPermissions).not.toContain('manage_users'); + expect(ctx.posture).not.toBe('PLATFORM_ADMIN'); + }); + 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', 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).toContain('admin_full_access'); // the Default Organization's grant kept expect(ctx.permissions).not.toContain('other_org_set'); // foreign-org grant dropped expect(ctx.systemPermissions).not.toContain('should_not_appear'); }); @@ -501,11 +518,14 @@ describe('resolveExecutionContext — ADR-0090 D10 agent principal (OAuth on /mc // ── [ADR-0090 D10 #2] action-capability delegation ─────────────────────── // A ql that resolves user `u1` to a set carrying system capabilities, so we - // can see whether the agent inherits the user's ACTION authority. + // can see whether the agent inherits the user's ACTION authority. The grant is + // the Default Organization's, so it applies whatever organization (or none) + // the agent resolves into. 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', permission_set: 'admin_full_access', organization_id: null }], + sys_organization: [{ id: 'org_default', slug: 'default' }], + sys_user_permission_set: [{ id: 'ups1', user_id: 'u1', permission_set_id: 'ps_admin', permission_set: 'admin_full_access', organization_id: 'org_default' }], 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/runtime/test-typecheck-debt.json b/packages/runtime/test-typecheck-debt.json index b819768d54a..8b471b8b1cf 100644 --- a/packages/runtime/test-typecheck-debt.json +++ b/packages/runtime/test-typecheck-debt.json @@ -116,7 +116,7 @@ "TS7006: Parameter 'opts' implicitly has an 'any' type.": 1, "TS7006: Parameter 'ql' implicitly has an 'any' type.": 1, "TS7006: Parameter 'row' implicitly has an 'any' type.": 1, - "TS7053: Element implicitly has an 'any' type because expression of type 'any' can't be used to index type '{ sys_api_key: { id: string; key: string; revoked: boolean; user_id: string; active_organization_id: string; expires_at: string; }[]; sys_member: { user_id: string; organization_id: string; role: string; }[]; ... 4 more ...; sys_user_position: never[]; }'.": 1 + "TS7053: Element implicitly has an 'any' type because expression of type 'any' can't be used to index type '{ sys_api_key: { id: string; key: string; revoked: boolean; user_id: string; active_organization_id: string; expires_at: string; }[]; sys_member: { user_id: string; organization_id: string; role: string; }[]; ... 5 more ...; sys_user_position: never[]; }'.": 1 }, "src/seed-loader.test.ts": { "TS2322: Type '…' is not assignable to type '…'.": 4, 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 6104851a557..72470785420 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 @@ -62,12 +62,19 @@ function updateFlow(name: string, runAs: 'system' | 'user') { }; } -/** Authz tables granting user 'u1' the unscoped `ehr_all` set + the `approver` position. */ +/** + * The organization the run acts in, and the one u1's grants are owned by: a + * grant row applies only in its own organization (an organization-less row + * confers nothing), so the run names it. + */ +const ORG = 'org_1'; + +/** Authz tables granting user 'u1' the `ehr_all` set + the `approver` position, both in {@link ORG}. */ 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', permission_set: 'ehr_all', organization_id: null }], + sys_member: [{ user_id: 'u1', organization_id: ORG, role: 'member' }], + sys_user_position: [{ user_id: 'u1', position: 'approver', organization_id: ORG }], + sys_user_permission_set: [{ user_id: 'u1', permission_set_id: 'psE', permission_set: 'ehr_all', organization_id: ORG }], sys_permission_set: [{ id: 'psE', name: 'ehr_all', system_permissions: ['cap_ehr'] }], sys_position: [], sys_position_permission_set: [], @@ -96,8 +103,9 @@ describe("AutomationServicePlugin bridges the runAs:'user' grant resolver (#3356 const automation = kernel.getService('automation'); automation.registerFlow('usr', updateFlow('usr', 'user') as never); - // The record-change hook shape: ONLY a userId — no positions/permissions. - const res = await automation.execute('usr', { userId: 'u1', params: { noteId: 'n1' } }); + // The record-change hook shape: ONLY a userId (and the organization the + // write ran in) — no positions/permissions. + const res = await automation.execute('usr', { userId: 'u1', tenantId: ORG, params: { noteId: 'n1' } } as never); expect(res.success, `run failed: ${JSON.stringify(res)}`).toBe(true); const update = crud.find((c) => c.op === 'update' && c.obj === 'runas_thing'); @@ -111,8 +119,8 @@ describe("AutomationServicePlugin bridges the runAs:'user' grant resolver (#3356 update!.ctx.permissions, "the triggering user's permission set was not propagated — runAs:'user' ran with the bare member fallback (#3356)", ).toContain('ehr_all'); - // The peer list rides the same bridge. No organization here, so it is the - // user alone — what an organization-less request resolves to as well. + // The peer list rides the same bridge: the members of the organization the + // run acts in, u1 alone here. expect(update!.ctx.org_user_ids, 'the bridge dropped the resolver\'s peer list').toEqual(['u1']); await kernel.shutdown(); diff --git a/packages/services/service-automation/src/sys-user-default-row-scope.integration.test.ts b/packages/services/service-automation/src/sys-user-default-row-scope.integration.test.ts index 9b54bb93f69..d839953aa83 100644 --- a/packages/services/service-automation/src/sys-user-default-row-scope.integration.test.ts +++ b/packages/services/service-automation/src/sys-user-default-row-scope.integration.test.ts @@ -20,8 +20,11 @@ * The default applies only where the caller's sets carry no row policy for * `sys_user`. Every human holds the platform baseline (`member_default`), * which carries both literals, so this deployment composes no baseline and - * grants one authored `'*'` read set with no row policy of its own — the - * ruling's no-baseline principal. Explain names the object default, so the + * binds one authored `'*'` read set with no row policy of its own to the + * `everyone` anchor — the ruling's no-baseline principal. The anchor is the + * one channel that holds in every resolution, an organisation or none: a + * stored grant row applies only in its own organisation, and an + * organisation-less one applies nowhere. Explain names the object default, so the * rows read are the declaration's, not a set's. * * The REAL stack, as its two twins beside it compose it: ObjectKernel, @@ -148,12 +151,13 @@ describe("sys_user's one `||` default keeps the reader's own row on every user-c for (const [id, userId, organizationId] of memberships) { await ql.insert('sys_member', { id, user_id: userId, organization_id: organizationId, role: 'member' }, { context: SYS }); } - // The one grant: a global (organisation-less) assignment of the `'*'` reader, - // written straight into the table — a system write here must name one of - // the two organisations, and a global grant names none. - await (driver as unknown as { knex: (t: string) => { insert(r: object): Promise } }) - .knex('sys_user_permission_set') - .insert({ id: 'ups_me', user_id: 'usr_me', permission_set: STAR_READER.name, organization_id: null }); + // The one grant: the `'*'` reader bound to the `everyone` anchor, declared + // in the registry's bare slot (where an environment definition answers + // first). A stored `sys_user_permission_set` row would apply only in its own + // organisation, so it could not reach the "acting in no organisation" arms + // below; the anchor reaches every authenticated resolution. + (ql as unknown as { registry: { registerItem(type: string, item: Record, key: string): void } }) + .registry.registerItem('position', { name: 'everyone', label: 'Everyone', permissionSets: [STAR_READER.name] }, 'name'); hookSeen = []; readContexts = []; diff --git a/packages/services/service-datasource/src/__tests__/admin-routes-api-key-account-posture.pin.test.ts b/packages/services/service-datasource/src/__tests__/admin-routes-api-key-account-posture.pin.test.ts index d29dde83ac1..ed24baccc96 100644 --- a/packages/services/service-datasource/src/__tests__/admin-routes-api-key-account-posture.pin.test.ts +++ b/packages/services/service-datasource/src/__tests__/admin-routes-api-key-account-posture.pin.test.ts @@ -25,7 +25,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { hashApiKey } from '@objectstack/core'; import { HonoHttpServer } from '@objectstack/plugin-hono-server'; import { registerDatasourceAdminRoutes } from '../admin-routes.js'; -import { ENTITLED_USER, createSessionAuthService, createGrantsEngine } from './entitled-caller.fixture.js'; +import { ENTITLED_ORGANIZATION, ENTITLED_USER, createSessionAuthService, createGrantsEngine } from './entitled-caller.fixture.js'; const KEY = 'osk_datasource_account_posture_pin'; @@ -54,13 +54,15 @@ function mountFamily() { ...createSessionAuthService(), resolveUserAuthGates: async (userId: string) => postures[userId] ?? [], }; - // The fixture's grants engine, taught the one key the entitled operator owns. + // The fixture's grants engine, taught the one key the entitled operator owns — + // stamped with the organization the operator's grant belongs to, since a key + // resolves its owner's grants in the key's own organization. const engine: { find: (object: string, opts: any) => Promise } = createGrantsEngine(); const grantsFind = engine.find; const keyHash = hashApiKey(KEY); engine.find = async (object: string, opts: any) => ( object === 'sys_api_key' - ? (opts?.where?.key === keyHash ? [{ id: 'key_1', key: keyHash, user_id: ENTITLED_USER, revoked: false, scopes: '[]' }] : []) + ? (opts?.where?.key === keyHash ? [{ id: 'key_1', key: keyHash, user_id: ENTITLED_USER, active_organization_id: ENTITLED_ORGANIZATION, revoked: false, scopes: '[]' }] : []) : grantsFind(object, opts) ); const ctx = { diff --git a/packages/services/service-datasource/src/__tests__/admin-routes-auth-gate.pin.test.ts b/packages/services/service-datasource/src/__tests__/admin-routes-auth-gate.pin.test.ts index f4dfdd5b64a..d1dced50be0 100644 --- a/packages/services/service-datasource/src/__tests__/admin-routes-auth-gate.pin.test.ts +++ b/packages/services/service-datasource/src/__tests__/admin-routes-auth-gate.pin.test.ts @@ -46,6 +46,7 @@ import { UNENTITLED_USER, createSessionAuthService, createGrantsEngine, + sessionFor, } from './entitled-caller.fixture.js'; /** The gate postures plugin-auth's enrichment writes, one per policy. */ @@ -74,7 +75,7 @@ function createAuthService() { api: { getSession: async (input: { headers: Headers }) => { const user = held[input.headers?.get?.('authorization') ?? '']; - return user ? { user } : base.api.getSession(input); + return user ? sessionFor(user) : base.api.getSession(input); }, }, }; 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 1b71dda3190..44fc49fe4c9 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 @@ -34,9 +34,15 @@ * get the capability refusal — all on the same wiring. * 2. **The RBAC grant is SYMMETRIC across all three key principals.** One * shared permission set carries `manage_platform_settings` for the member, - * the ex-member and the organization-less caller alike. If they held - * different capabilities, RBAC could be what separates the arms; with one - * shared grant, only the tenancy posture can be. + * the ex-member and the organization-less caller alike, each row owned by + * `org_alpha`. If they held different capabilities, RBAC could be what + * separates the arms; with one shared grant, only the tenancy posture can + * be. A stored grant applies only in its own organization, so the + * organization-less key — which resolves with NO active organization — + * holds nothing wherever the posture lets it through: it is ADMITTED + * (authenticated, no refusal line) and then answered the capability `403`, + * never the posture `401`. That `401`-versus-`403` split is how every arm + * below reads "did the posture refuse it". * 3. **A REAL `ObjectKernel` carries the wiring fact.** The classification * under measurement is the REGISTRY's — the branded "never registered" * rejection versus the unbranded "registered and failed to construct" one @@ -124,13 +130,15 @@ function makeQl() { { id: 'u_orgless', email: 'u_orgless@example.com' }, { id: 'u_plain', email: 'u_plain@example.com' }, ], - // The grant, SYMMETRIC across the three subject principals (note 2). - // `u_plain` is deliberately absent: it is the capability control, the one - // caller separated by RBAC rather than by posture. + // The grant, SYMMETRIC across the three subject principals (note 2), and + // owned by the organization the stamped keys name: an organization-less + // grant row applies in no organization, so it would confer nothing to + // anyone. `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, 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 }, + { user_id: 'u_member', permission_set_id: SHARED_SET, permission_set: 'datasource_operator', organization_id: ORG_ALPHA }, + { user_id: 'u_exmember', permission_set_id: SHARED_SET, permission_set: 'datasource_operator', organization_id: ORG_ALPHA }, + { user_id: 'u_orgless', permission_set_id: SHARED_SET, permission_set: 'datasource_operator', organization_id: ORG_ALPHA }, ], sys_permission_set: [ { @@ -487,10 +495,16 @@ describe('[#15350] §4 — ablation: with no kernel to read the posture from, th expect(h.store().filter((r) => r.name === 'pg')).toHaveLength(0); }); - it('the organization-less key is admitted again too', async () => { + it('the organization-less key is admitted again too — the capability 403, not the posture 401', async () => { + // Authenticated again (no `organization_required`), and then served what a + // principal with NO active organization holds: no stored grant applies + // outside its own organization, so the shared `org_alpha` grant confers + // nothing and the capability gate answers. The member control in this + // block proves the same grant does serve inside its organization. const h = mount(ABLATED); const res = await listOf(h, RAW_ORGLESS_KEY); - expect(res.status).toBe(200); + expect(res.status).toBe(403); + expect(res.body?.error?.code).toBe('PERMISSION_DENIED'); }); it('and NOTHING is said about any of it — no refusal line, because no refusal was decided', async () => { @@ -523,7 +537,9 @@ describe('[#15350] §5 — a `tenancy` service that was NEVER REGISTERED resolve it('and it runs NO posture-conditional refusal — behaviour is exactly what it was', async () => { const h = mount(UNREGISTERED); expect((await listOf(h, RAW_EXMEMBER_KEY)).status).toBe(200); - expect((await listOf(h, RAW_ORGLESS_KEY)).status).toBe(200); + // Admitted (the capability 403, not the posture 401): with no active + // organization its owner's organization-owned grant applies nowhere. + expect((await listOf(h, RAW_ORGLESS_KEY)).status).toBe(403); expect(h.warnings().filter((l) => l.includes('API key refused'))).toHaveLength(0); }); }); @@ -593,11 +609,21 @@ describe('[#15350] §6 — the `group` posture, measured on the READ and the WRI // reads the union of its owner\'s organizations, so refusing it would break // working deployments for no security gain. Pinned so the asymmetry with // §3 is a recorded decision rather than an accident. + // + // Admitted is not entitled: the key resolves with no active organization, + // where no stored grant applies, so the shared `org_alpha` grant confers + // nothing and the CAPABILITY gate answers — `403`, never §3's posture + // `401`, and no refusal line. The member control below is the same grant + // serving inside its organization. const h = mount(GROUP); - expect((await listOf(h, RAW_ORGLESS_KEY)).status).toBe(200); + const list = await listOf(h, RAW_ORGLESS_KEY); + expect(list.status).toBe(403); + expect(list.body?.error?.code).toBe('PERMISSION_DENIED'); const post = await createOf(h, RAW_ORGLESS_KEY, 'ds_group_orgless'); - expect(post.status).toBe(201); - expect(h.store().filter((r) => r.name === 'ds_group_orgless')).toHaveLength(1); + expect(post.status).toBe(403); + expect(h.store().filter((r) => r.name === 'ds_group_orgless')).toHaveLength(0); + expect(h.service.createDatasource).not.toHaveBeenCalled(); + expect(h.warnings().filter((l) => l.includes('API key refused'))).toHaveLength(0); }); it('the member control is unchanged under `group`', async () => { @@ -613,11 +639,13 @@ describe('[#15350] §6 — the `group` posture, measured on the READ and the WRI describe('[#15350] §7 — under `single` nothing changes', () => { const SINGLE: Wiring = { kind: 'posture', posture: 'single' }; - it('every entitled key is admitted, stamped or not', async () => { + it('every key is admitted, stamped or not', async () => { const h = mount(SINGLE); expect((await listOf(h, RAW_MEMBER_KEY)).status).toBe(200); expect((await listOf(h, RAW_EXMEMBER_KEY)).status).toBe(200); - expect((await listOf(h, RAW_ORGLESS_KEY)).status).toBe(200); + // Admitted (the capability 403, not a posture 401): with no active + // organization the `org_alpha` grant applies nowhere. + expect((await listOf(h, RAW_ORGLESS_KEY)).status).toBe(403); expect(h.warnings().filter((l) => l.includes('API key refused'))).toHaveLength(0); }); 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 2be8019e0bc..a42458b6d39 100644 --- a/packages/services/service-datasource/src/__tests__/entitled-caller.fixture.ts +++ b/packages/services/service-datasource/src/__tests__/entitled-caller.fixture.ts @@ -47,6 +47,24 @@ export const UNENTITLED_CREDENTIAL = 'Bearer unentitled-session'; export const ENTITLED_USER = 'u_entitled'; export const UNENTITLED_USER = 'u_plain'; +/** + * The organization the grant belongs to, and the one both sessions are active + * in. A stored grant applies only in its own organization — a row with no + * organization applies nowhere — so the grant names one and the resolution runs + * in it. Under the default `single` posture the session's claim is kept + * without a membership row, so no `sys_member` row is needed for it. + */ +export const ENTITLED_ORGANIZATION = 'o1'; + +/** + * The session a credential resolves to: the user, active in + * {@link ENTITLED_ORGANIZATION}. Exported so a suite that adds sessions of its + * own (a held one, say) builds them in the same organization. + */ +export function sessionFor(user: U) { + return { user, session: { userId: user.id, activeOrganizationId: ENTITLED_ORGANIZATION } }; +} + /** The permission set that carries the grant. */ const GRANT_SET_ID = 'ps_datasource_operator'; @@ -64,7 +82,8 @@ const GRANT_SET_NAME = 'datasource_operator'; /** * The `auth` service double: a `getSession` over the two credentials above, - * spelled on the legacy `api` member the contract still declares. + * spelled on the legacy `api` member the contract still declares. Each session + * is active in {@link ENTITLED_ORGANIZATION}. */ export function createSessionAuthService() { const sessions: Record = { @@ -75,7 +94,7 @@ export function createSessionAuthService() { api: { getSession: async ({ headers }: { headers: Headers }) => { const id = sessions[headers?.get?.('authorization') ?? '']; - return id ? { user: { id } } : null; + return id ? sessionFor({ id }) : null; }, }, }; @@ -86,8 +105,8 @@ export function createSessionAuthService() { * * Only the two objects carrying the grant answer rows; every other read — the * memberships, positions and identity tables the resolver also consults — - * returns empty, which is what a deployment with no orgs and no custom roles - * really looks like. The rows carry no `active` flag and no validity window on + * returns empty, which is what a `single`-posture deployment with one + * organization and no custom roles really looks like. The rows carry no `active` flag and no validity window on * purpose: absent means active/unbounded by the resolver's own predicates * (`isRowActive` / `isGrantActive`), so these fixtures pin the CAPABILITY path * rather than a validity edge case that belongs to `@objectstack/core`'s suite. @@ -101,9 +120,10 @@ export function createGrantsEngine() { id: 'ups_datasource_operator', user_id: ENTITLED_USER, 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, + // Owned by the sessions' organization: an organization-less + // grant row applies in no organization, so it would confer + // nothing (`grantAppliesInTenant`). + organization_id: ENTITLED_ORGANIZATION, }] : []; } 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 a7555e3c24e..cf7a53382d5 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 @@ -164,8 +164,10 @@ function matchesWhere(row: any, where: any): boolean { /** * The permission store in the SHIPPED aggregation shapes. All four principals - * hold `setup.access` + `setup.write`, so a refusal below can only be the - * tenancy wall — never a missing capability. + * hold `setup.access` + `setup.write` in `org_A` — the organization every + * stamped key and session names (a grant row with no organization applies in + * none) — so a refusal below can only be the tenancy wall — never a missing + * capability. * * `u_exmember`'s key still carries `active_organization_id: 'org_A'`; the row * that made that stamp true is simply gone from `sys_member`. That is the whole @@ -190,10 +192,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', 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 }, + { id: 'ups_member', user_id: 'u_member', permission_set_id: 'ps_settings', permission_set: 'settings_admin', organization_id: 'org_A' }, + { id: 'ups_exmember', user_id: 'u_exmember', permission_set_id: 'ps_settings', permission_set: 'settings_admin', organization_id: 'org_A' }, + { id: 'ups_orgless', user_id: 'u_orgless', permission_set_id: 'ps_settings', permission_set: 'settings_admin', organization_id: 'org_A' }, + { id: 'ups_session', user_id: 'u_session_exmember', permission_set_id: 'ps_settings', permission_set: 'settings_admin', organization_id: 'org_A' }, ], sys_permission_set: [ { id: 'ps_settings', name: 'settings_admin', system_permissions: ['setup.access', 'setup.write'] },