From 7e3b858fea42e5055dd2a9df87963a0428e49990 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 01:45:45 +0000 Subject: [PATCH 1/5] feat(plugin-security): organization administrators assign inside the D12 organization boundary The delegated-administration gate gains its organization-level branch (ADR-0090 D12 as amended 2026-10-17): a holder of organization_admin / organization_admin_no_bypass who is not tenant-level creates and removes sys_user_position / sys_user_permission_set rows of its own organization when every distributed set has no superuser wildcard bit, no adminScope and only scope 'org' capabilities, and is not one of the sets the ADR keeps outside by name. The overrides open create and delete on both assignment tables; explain and the delegable-scope report learn the organization administrator. Claude-Session: https://claude.ai/code/session_013WG7E4zFnUTehQ9UXwyQtT Co-authored-by: Claude --- .../src/__tests__/security-catalog.testkit.ts | 3 +- .../src/delegated-admin-gate.ts | 379 ++++++++++++++++-- .../src/objects/default-permission-sets.ts | 28 +- .../src/objects/rbac-objects.test.ts | 103 ++++- .../plugin-security/src/security-plugin.ts | 26 +- 5 files changed, 476 insertions(+), 63 deletions(-) diff --git a/packages/plugins/plugin-security/src/__tests__/security-catalog.testkit.ts b/packages/plugins/plugin-security/src/__tests__/security-catalog.testkit.ts index da4eea9f4e6..1d4df23d79a 100644 --- a/packages/plugins/plugin-security/src/__tests__/security-catalog.testkit.ts +++ b/packages/plugins/plugin-security/src/__tests__/security-catalog.testkit.ts @@ -23,13 +23,14 @@ type Definition = Record; export interface TestSecurityCatalog { positions?: readonly Definition[]; permissions?: readonly Definition[]; + capabilities?: readonly Definition[]; } /** Bind a catalog over these definitions (read afresh per lookup when given a function); returns `engine`. */ export function bindTestSecurityCatalog(engine: T, catalog: TestSecurityCatalog | (() => TestSecurityCatalog)): T { const items = (type: string): readonly Definition[] => { const c = typeof catalog === 'function' ? catalog() : catalog; - return (type === 'position' ? c.positions : type === 'permission' ? c.permissions : undefined) ?? []; + return (type === 'position' ? c.positions : type === 'permission' ? c.permissions : type === 'capability' ? c.capabilities : undefined) ?? []; }; bindSecurityCatalogReader(engine, createSecurityCatalogReader({ registry: { getItem: (type, name) => items(type).find((d) => d.name === name), listItems: items, isPackageDisabled: () => false }, diff --git a/packages/plugins/plugin-security/src/delegated-admin-gate.ts b/packages/plugins/plugin-security/src/delegated-admin-gate.ts index 0ee08f5bdf8..c3fe00f6334 100644 --- a/packages/plugins/plugin-security/src/delegated-admin-gate.ts +++ b/packages/plugins/plugin-security/src/delegated-admin-gate.ts @@ -7,11 +7,20 @@ * write the RBAC link tables can manage ALL permissions; this gate turns * those writes into a governed operation: * - * - TENANT-LEVEL ADMINS (a resolved set carries the ADR-0066 superuser - * wildcard `objects['*'].modifyAllRecords`) keep the status quo — the gate - * passes and the ordinary CRUD/RLS checks decide (note the built-in - * `organization_admin` also passes here but is still denied downstream by - * its explicit per-table `allowEdit:false` overrides — unchanged). + * - TENANT-LEVEL ADMINS (a resolved set OTHER than the two organization- + * administrator sets carries the ADR-0066 superuser wildcard + * `objects['*'].modifyAllRecords` — the platform administrator's + * `admin_full_access`) keep the status quo — the gate passes and the + * ordinary CRUD/RLS checks decide. + * - ORGANIZATION-LEVEL ADMINS (the D12 amendment of 2026-10-17, ruling of + * record 6228728551: a holder of `organization_admin` / + * `organization_admin_no_bypass` for the active organization who is not + * tenant-level) create and remove assignment rows of THEIR organization + * when every permission set the row distributes is inside the organization + * boundary ({@link organizationBoundaryFailure}): no wildcard superuser bit, + * no `adminScope`, only `scope: 'org'` capabilities, and not one of the sets + * the ADR keeps outside by name. Nothing else — no edit in place, no + * organization-less or foreign-organization row, no membership row. * - DELEGATES (non-tenant-admins holding ≥1 `adminScope`) may write ONLY * what one of their scopes fully approves: the right kind of action * (manageAssignments), inside their BU subtree, handing out only @@ -30,8 +39,9 @@ * * `sys_member` (organization membership) is likewise tenant-level only: no * `adminScope` axis expresses membership, and an admin-grade membership row is - * auto-elevated to `organization_admin` — writing one mints a tenant admin - * (#3697 follow-up). The delegable way to add people is an INVITATION, whose + * auto-elevated to `organization_admin` — writing one mints an administrator + * (#3697 follow-up), which is why an organization administrator may not write + * one either: administrator status comes from the membership grade alone. The delegable way to add people is an INVITATION, whose * placement this gate authorizes and whose role the framework caps at the * issuer's own grade. * @@ -42,6 +52,16 @@ import { isGrantActive, securityCatalogReaderOf, type SecurityCatalogReader } from '@objectstack/core'; import type { AdminScope, AdminScopeParsed, PermissionSet } from '@objectstack/spec/security'; +import { + ADMIN_FULL_ACCESS, + ORGANIZATION_ADMIN, + ORGANIZATION_ADMIN_NO_BYPASS, +} from '@objectstack/spec'; +import { + MCP_AGENT_PERMISSION_SET_READ, + MCP_AGENT_PERMISSION_SET_RESTRICTED, + MCP_AGENT_PERMISSION_SET_WRITE, +} from '@objectstack/spec/ai'; import { PermissionDeniedError } from './errors.js'; import { resolveOwnOrganizationRow, @@ -262,6 +282,53 @@ export function isTenantAdmin(sets: PermissionSet[]): boolean { return false; } +/** + * [ADR-0090 D12, amended 2026-10-17] The two sets that make their holder an + * organization-level administrator — granted by `auto-org-admin-grant.ts` from + * an owner or admin membership, for the organization of that membership. The + * caller's sets are resolved for its active organization, so holding one here + * means administering THAT organization. + */ +const ORGANIZATION_ADMIN_SETS: ReadonlySet = new Set([ORGANIZATION_ADMIN, ORGANIZATION_ADMIN_NO_BYPASS]); + +/** + * [ADR-0090 D12, amended 2026-10-17] The sets outside the organization boundary + * BY NAME, whatever the three conditions say of them: `admin_full_access` and + * both organization-administrator sets (administrator status is conferred by + * membership grade only, never copied by an assignment), and the ADR's + * denylist — the three D10 agent ceilings, whose bound is the delegating + * principal's grants and not the organization wall. + */ +const ORGANIZATION_BOUNDARY_DENYLIST: ReadonlySet = new Set([ + ADMIN_FULL_ACCESS, + ORGANIZATION_ADMIN, + ORGANIZATION_ADMIN_NO_BYPASS, + MCP_AGENT_PERMISSION_SET_READ, + MCP_AGENT_PERMISSION_SET_WRITE, + MCP_AGENT_PERMISSION_SET_RESTRICTED, +]); + +/** Does the caller hold an organization-administrator set for its active organization? */ +function holdsOrganizationAdmin(sets: PermissionSet[]): boolean { + return sets.some((ps) => ORGANIZATION_ADMIN_SETS.has(String((ps as any)?.name ?? ''))); +} + +/** + * D12's tenant-level administrator: a superuser wildcard from any set but the + * two organization-administrator sets. `organization_admin` carries the + * wildcard too, bounded by the organization wall — under the 2026-10-17 + * amendment its holder is organization-level, judged by the boundary below. + */ +function isTenantLevelAdmin(sets: PermissionSet[]): boolean { + return isTenantAdmin(sets.filter((ps) => !ORGANIZATION_ADMIN_SETS.has(String((ps as any)?.name ?? '')))); +} + +/** A refusal reason from the organization-level branch, and the subject it names. */ +interface OrganizationFailure { + reason: string; + meta: Record; +} + /** Single scalar id from an update/delete opCtx (mirrors the engine's single-id rule). */ function extractSingleId(opCtx: any): string | number | null { const isScalar = (v: unknown): v is string | number => @@ -329,7 +396,7 @@ export class DelegatedAdminGate { sets = []; // resolution failure → treated as no authority (fail closed) } - if (isTenantAdmin(sets)) return; // status quo — downstream CRUD/RLS decide + if (isTenantLevelAdmin(sets)) return; // status quo — downstream CRUD/RLS decide // ── [#3697 follow-up] Organization membership is NOT delegable ──────── // Every other governed object has an adminScope axis that can approve part @@ -367,7 +434,25 @@ export class DelegatedAdminGate { return this.assertSelfDelegation(opCtx, ctx, organizationId); } + // ── [ADR-0090 D12, amended 2026-10-17] Organization-level administration ── + // An organization administrator creates and removes its own organization's + // assignment rows inside the organization boundary. A caller who is also a + // delegate and fails the boundary is still judged by its adminScope below; + // one who is not is refused with the boundary's own reason. + let organizationFailure: OrganizationFailure | null = null; + if (holdsOrganizationAdmin(sets)) { + organizationFailure = await this.organizationAdminFailure(opCtx, organizationId); + if (organizationFailure === null) return; + } + const held = await this.resolveHeldScopes(sets, organizationId); + if (held.length === 0 && organizationFailure) { + throw new PermissionDeniedError( + `[Security] Access denied: organization administrator '${opCtx.operation}' on '${opCtx.object}' ` + + `rejected — ${organizationFailure.reason} (ADR-0090 D12, the organization boundary).`, + { operation: opCtx.operation, object: opCtx.object, ...organizationFailure.meta }, + ); + } if (held.length === 0) { throw new PermissionDeniedError( `[Security] Access denied: '${opCtx.operation}' on '${opCtx.object}' requires tenant-level ` + @@ -456,13 +541,16 @@ export class DelegatedAdminGate { * * A tenant-level admin (ADR-0066 superuser wildcard) is unconstrained; * the report says so AND enumerates everything, so a consumer can render - * one uniform picker instead of special-casing. + * one uniform picker instead of special-casing. An organization + * administrator (D12 as amended 2026-10-17) is not: `isTenantAdmin` is + * false, every unit of its organization is placeable and the positions + * inside the organization boundary are assignable — on the same report + * shape, beside any delegated scopes it also holds. */ async describeDelegableScope( sets: PermissionSet[], callerContext?: any, ): Promise { - const ql = this.deps.ql; const organizationId = callerOrganizationId(callerContext); // [ADR-0131 D3/D4] Every position the security catalog holds — the // environment registry, one namespace per deployment — never the @@ -481,29 +569,14 @@ export class DelegatedAdminGate { } }; - if (isTenantAdmin(sets)) { - let businessUnitIds: string[] = []; - if (ql?.find) { - try { - const rows = await ql.find('sys_business_unit', { - limit: 5000, - context: organizationScopedCtx(organizationId), - }); - businessUnitIds = (Array.isArray(rows) ? rows : []) - // A tenant admin is unconstrained INSIDE their organization, never - // across organizations: the picker this feeds must not name a unit - // the caller could not place anyone in. - .filter((r: any) => !organizationId || rowOrganizationId(r) === organizationId) - .map((r: any) => String(r?.id ?? '')) - .filter(Boolean); - } catch { - businessUnitIds = []; - } - } + if (isTenantLevelAdmin(sets)) { return { isTenantAdmin: true, scopes: [], - placeableBusinessUnitIds: businessUnitIds, + // A tenant admin is unconstrained INSIDE their organization, never + // across organizations: the picker this feeds must not name a unit + // the caller could not place anyone in. + placeableBusinessUnitIds: await this.organizationBusinessUnitIds(organizationId), assignablePositions: await allPositions(), }; } @@ -546,6 +619,26 @@ export class DelegatedAdminGate { } } + // [ADR-0090 D12, amended 2026-10-17] An organization administrator places + // into every unit of its organization and assigns every position inside + // the organization boundary — `organizationAdminFailure`'s own test, so an + // option offered here is one the write path admits. + if (holdsOrganizationAdmin(sets) && organizationId) { + for (const id of await this.organizationBusinessUnitIds(organizationId)) { + if (!placeableBusinessUnitIds.includes(id)) placeableBusinessUnitIds.push(id); + } + for (const positionName of await allPositions()) { + if (assignablePositions.includes(positionName)) continue; + let failure: unknown; + try { + failure = await this.positionBoundaryFailure(positionName); + } catch { + continue; // a position whose sets cannot be read is not offered + } + if (failure === null) assignablePositions.push(positionName); + } + } + return { isTenantAdmin: false, scopes, @@ -554,6 +647,232 @@ export class DelegatedAdminGate { }; } + /** + * [ADR-0090 D6 × D12, amended 2026-10-17] Is the caller an organization + * administrator of its active organization, and `targetUserId` a member of + * it? Used by the explain API: an administrator who may assign a member's + * positions and sets may also read why that member's access resolves as it + * does. Fail-closed: no active organization, or an unreadable membership, + * covers nobody. + */ + async organizationAdminCoversUser( + sets: PermissionSet[], + targetUserId: string, + callerContext?: any, + ): Promise { + if (!holdsOrganizationAdmin(sets)) return false; + const organizationId = callerOrganizationId(callerContext); + const ql = this.deps.ql; + if (!organizationId || !targetUserId || !ql?.find) return false; + try { + const rows = await ql.find('sys_member', { + where: { user_id: targetUserId, organization_id: organizationId }, + limit: 1, + context: SYSTEM_CTX, + }); + return (Array.isArray(rows) ? rows : []).some((r: any) => rowOrganizationId(r) === organizationId); + } catch { + return false; + } + } + + // ── [ADR-0090 D12, amended 2026-10-17] Organization-level administration ── + + /** + * Why the organization administrator of `organizationId` may NOT perform this + * write — or `null` when the organization boundary admits it. + * + * The right is to create and remove `sys_user_position` / + * `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 is judged for every writer by the grant-holder membership + * hooks (`grant-holder-membership-refusal.ts`), not again here. + * + * Every permission set the row distributes must be inside the boundary + * ({@link organizationBoundaryFailure}); for a position, every set it carries + * ({@link setsCarriedByPosition}). One set outside puts the row outside. + */ + private async organizationAdminFailure( + opCtx: any, + organizationId?: string, + ): Promise { + const op = String(opCtx.operation); + if (!organizationId) { + return { reason: 'the request carries no active organization to administer', meta: {} }; + } + if (op !== 'insert' && op !== 'delete') { + return { + reason: `'${op}' is not part of the right — an assignment row is created and removed, never edited in place`, + meta: {}, + }; + } + if (op === 'delete' && extractSingleId(opCtx) == null) { + return { + reason: 'a removal must target one assignment row by id — a filter delete cannot be checked against the organization boundary', + meta: {}, + }; + } + + for (const t of await this.materializeTargets(opCtx, opCtx.object)) { + const row = t.next ?? t.prev; + if (!row) return { reason: 'the assignment row to remove could not be read', meta: {} }; + + const rowOrg = rowOrganizationId(row); + if (t.next ? rowOrg !== null && rowOrg !== organizationId : rowOrg !== organizationId) { + 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', + meta: { organizationId: rowOrg }, + }; + } + + if (opCtx.object === 'sys_user_position') { + const bu = t.next?.business_unit_id; + if (bu != null && bu !== '' && !(await this.businessUnitInOrganization(String(bu), organizationId))) { + return { + reason: `business unit '${bu}' is not a unit of the organization you administer`, + meta: { businessUnitId: bu }, + }; + } + const positionName = String(row.position ?? ''); + const failure = await this.positionBoundaryFailure(positionName); + if (failure) return { reason: failure.reason, meta: { position: positionName, permissionSet: failure.permissionSet } }; + } else { + const setName = grantSetNameOf(row) ?? ''; + const reason = setName + ? await this.organizationBoundaryFailure(setName) + : 'the grant names no permission set'; + if (reason) return { reason, meta: { permissionSet: setName } }; + } + } + + // Admitted: an insert that named no organization lands in the caller's. + if (op === 'insert') { + for (const row of rowsOf(opCtx)) { + if (row && typeof row === 'object' && rowOrganizationId(row) === null) row.organization_id = organizationId; + } + } + return null; + } + + /** + * Is position `positionName` inside the organization boundary? `null` when + * every set it carries is; otherwise the first failure, naming the set. + * A position the catalog does not hold cannot be shown inside, and is not. + */ + private async positionBoundaryFailure( + positionName: string, + ): Promise<{ reason: string; permissionSet?: string } | null> { + if (!positionName) return { reason: 'the assignment names no position' }; + const carried = await this.setsCarriedByPosition(positionName); + if (carried === null) { + return { reason: `position '${positionName}' is not in the security catalog, so it cannot be shown to be inside the organization boundary` }; + } + for (const setName of carried) { + const reason = await this.organizationBoundaryFailure(setName); + if (reason) return { reason: `position '${positionName}' carries ${reason}`, permissionSet: setName }; + } + return null; + } + + /** + * [ADR-0090 D12, amended 2026-10-17] Every permission set an assignment of + * `positionName` grants: the names its definition's `permissionSets` lists, + * and — while the resolver folds held position names into the requested set + * names (`security-plugin.ts`, `[...positions, ...explicitPermissionSets]`) — + * the set of the position's own name, when the catalog holds one. `null` + * when the catalog does not hold the position. An unreadable catalog throws + * (`AuthzStoreUnavailableError`) and the write is refused with it. + */ + private async setsCarriedByPosition(positionName: string): Promise { + const catalog = this.catalog(); + if (!catalog) return null; + const position = await catalog.resolve('position', positionName); + if (!position) return null; + const listed = Array.isArray(position.definition.permissionSets) + ? (position.definition.permissionSets as unknown[]).filter((n): n is string => typeof n === 'string' && n !== '') + : []; + const ownName = (await catalog.resolve('permission', positionName)) ? [positionName] : []; + return [...new Set([...listed, ...ownName])]; + } + + /** + * [ADR-0090 D12, amended 2026-10-17] Is permission set `setName` inside the + * organization boundary? `null` when it is; otherwise the reason, naming the + * set and the condition it fails: + * + * - by name — {@link ORGANIZATION_BOUNDARY_DENYLIST}; + * - (a) its `objects['*']` entry carries `viewAllRecords` or `modifyAllRecords`; + * - (b) it carries an `adminScope`; + * - (c) a name in its `systemPermissions` is not a capability declared + * `scope: 'org'` in the security catalog (a name no declaration carries is + * not an organization capability). + * + * A set the catalog does not hold cannot be shown inside, and is not. + */ + private async organizationBoundaryFailure(setName: string): Promise { + if (ORGANIZATION_BOUNDARY_DENYLIST.has(setName)) { + return `permission set '${setName}', which is outside the organization boundary by name (platform and organization administration and the agent ceilings are never assigned by an organization administrator)`; + } + const catalog = this.catalog(); + const entry = catalog ? await catalog.resolve('permission', setName) : undefined; + if (!catalog || !entry) { + return `permission set '${setName}', which is not in the security catalog, so it cannot be shown to be inside the organization boundary`; + } + const definition: any = entry.definition; + const wildcard = (parseMaybeJson(definition.objects) ?? {})?.['*']; + for (const bit of ['viewAllRecords', 'modifyAllRecords'] as const) { + if (wildcard?.[bit] === true) { + return `permission set '${setName}', which fails condition (a) of the organization boundary: its objects['*'] entry carries the superuser bit '${bit}'`; + } + } + if (parseMaybeJson(definition.adminScope ?? definition.admin_scope)) { + return `permission set '${setName}', which fails condition (b) of the organization boundary: it carries an adminScope`; + } + const capabilities = parseMaybeJson(definition.systemPermissions); + for (const capability of Array.isArray(capabilities) ? capabilities : []) { + const declared = await catalog.resolve('capability', String(capability)); + if (declared?.definition.scope !== 'org') { + return `permission set '${setName}', which fails condition (c) of the organization boundary: its capability '${String(capability)}' is ${declared ? "not declared scope 'org'" : "declared nowhere, so it is not an organization capability"}`; + } + } + return null; + } + + /** Is business unit `id` a unit of `organizationId`? Fail-closed on an unreadable unit. */ + private async businessUnitInOrganization(id: string, organizationId: string): Promise { + return (await this.organizationBusinessUnitIds(organizationId, id)).includes(id); + } + + /** + * The ids of `organizationId`'s business units (narrowed to `onlyId` when + * given) — never another organization's, whatever a driver returns. An + * organization-less caller (`single` posture without an organization) is not + * filtered, as before. Fail-closed: an unreadable table yields none. + */ + private async organizationBusinessUnitIds(organizationId: string | undefined, onlyId?: string): Promise { + const ql = this.deps.ql; + if (!ql?.find) return []; + try { + const rows = await ql.find('sys_business_unit', { + ...(onlyId !== undefined ? { where: { id: onlyId } } : {}), + limit: 5000, + context: organizationScopedCtx(organizationId), + }); + return (Array.isArray(rows) ? rows : []) + .filter((r: any) => !organizationId || rowOrganizationId(r) === organizationId) + .map((r: any) => String(r?.id ?? '')) + .filter(Boolean); + } catch { + return []; + } + } + // ── [ADR-0091 D3] Self-service delegation of duty ──────────────────── /** A delegation write = a `sys_user_position` INSERT whose rows stamp diff --git a/packages/plugins/plugin-security/src/objects/default-permission-sets.ts b/packages/plugins/plugin-security/src/objects/default-permission-sets.ts index 4c5dd9a3037..2a704cc96e0 100644 --- a/packages/plugins/plugin-security/src/objects/default-permission-sets.ts +++ b/packages/plugins/plugin-security/src/objects/default-permission-sets.ts @@ -423,12 +423,18 @@ const baseDefaultPermissionSets: PermissionSet[] = [ // (`branding`, `feature_flags`) keep using `setup.access` so org // admins CAN configure their own org's branding. // - // **Anti-escalation**: writes to the global RBAC tables - // (`sys_position`, `sys_permission_set`, `sys_position_permission_set`, - // `sys_user_permission_set`, `sys_user_position`) are denied. Allowing - // them would let an org admin bind `admin_full_access` (which has no - // RLS) to themselves and break out of tenant isolation. Reads are - // permitted so the Roles / Permission Sets nav entries still render. + // **Anti-escalation — the organization boundary** (ADR-0090 D12, amended + // 2026-10-17, ruling of record 6228728551): the two assignment tables + // (`sys_user_position`, `sys_user_permission_set`) are create-and-remove, + // never edit, and what an org admin may create or remove there is bounded + // by the delegated-administration gate (`delegated-admin-gate.ts`), not by + // these overrides: a row of its own organization, for its own members, whose + // every distributed set has no `'*'` superuser bit, no `adminScope` and only + // `scope: 'org'` capabilities. `admin_full_access` (which has no RLS), the + // platform sets, both org-admin variants and the agent ceilings stay outside, + // so an org admin can never bind itself or anyone out of tenant isolation. + // The catalog itself (positions, sets) is environment metadata, written + // through the metadata door by `manage_metadata` alone (ADR-0131 D3). // // Auto-granted to every `sys_member` whose role contains `owner` or // `admin` by `plugin-security/src/auto-org-admin-grant.ts` — but ONLY under a @@ -471,9 +477,11 @@ const baseDefaultPermissionSets: PermissionSet[] = [ // Identity tables — go through better-auth endpoints (invite, // accept, remove-member, transfer, …) rather than raw CRUD. ...denyWritesOnManagedObjects(), - // RBAC tables — read-only to prevent privilege escalation. - sys_user_permission_set: { allowRead: true, allowCreate: false, allowEdit: false, allowDelete: false }, - sys_user_position: { allowRead: true, allowCreate: false, allowEdit: false, allowDelete: false }, + // Assignment tables — created and removed inside the organization + // boundary the delegated-administration gate enforces (see above); + // never edited in place. + sys_user_permission_set: { allowRead: true, allowCreate: true, allowEdit: false, allowDelete: true }, + sys_user_position: { allowRead: true, allowCreate: true, allowEdit: false, allowDelete: true }, // [ADR-0131 D7] The compliance ledger, WITHOUT the superuser bits: the // ledger has no tenant column, so the wildcard's `viewAllRecords` would // skip its organization row scope (`sys_audit_log_org`, see @@ -1463,7 +1471,7 @@ const baseDefaultPermissionSets: PermissionSet[] = [ * [ADR-0105 D4] Derive the wall-less org-admin variant * ({@link ORGANIZATION_ADMIN_NO_BYPASS}) from `organization_admin` by dropping * the wildcard `viewAllRecords`/`modifyAllRecords` bits — everything else - * (object grants, managed-write denies, anti-escalation RBAC read-only rules, + * (object grants, managed-write denies, the assignment-table overrides, * system permissions, the identity RLS carve-outs) is carried over verbatim. * * DERIVED, never a second literal: two hand-maintained copies of a diff --git a/packages/plugins/plugin-security/src/objects/rbac-objects.test.ts b/packages/plugins/plugin-security/src/objects/rbac-objects.test.ts index 10ab00ec7c4..d396389cb17 100644 --- a/packages/plugins/plugin-security/src/objects/rbac-objects.test.ts +++ b/packages/plugins/plugin-security/src/objects/rbac-objects.test.ts @@ -1,7 +1,11 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. import { describe, it, expect } from 'vitest'; +import { ADMIN_FULL_ACCESS } from '@objectstack/spec'; +import { PLATFORM_CAPABILITIES } from '@objectstack/spec/security'; import { SysUserPermissionSet, SysUserPosition, defaultPermissionSets } from './index.js'; +import { DelegatedAdminGate } from '../delegated-admin-gate.js'; +import { bindTestSecurityCatalog } from '../__tests__/security-catalog.testkit.js'; /** * RBAC object + default-permission-set assertions. Moved here with the objects @@ -60,20 +64,91 @@ describe('default permission sets', () => { expect(sys).not.toContain('manage_platform_settings'); }); - it('organization_admin is read-only on global RBAC tables to prevent privilege escalation', () => { - const orgAdmin = defaultPermissionSets.find((p) => p.name === 'organization_admin')!; - // [C3 stage 8] The catalog row objects are retired (ADR-0131 D3); the - // assignment and grant tables are what is left to protect. - for (const obj of [ - 'sys_user_permission_set', - 'sys_user_position', - ]) { - const perms = (orgAdmin.objects as any)[obj]; - expect(perms, `${obj} explicit perms missing`).toBeDefined(); - expect(perms.allowRead).toBe(true); - expect(perms.allowCreate).toBe(false); - expect(perms.allowEdit).toBe(false); - expect(perms.allowDelete).toBe(false); + // [ADR-0090 D12, amended 2026-10-17] The boundary pin. The overrides open + // create and remove on both assignment tables and keep edit closed; what an + // org admin may create there is decided by the delegated-administration + // gate, judged here against the sets this package actually ships. + it('organization_admin creates and removes assignment rows, never edits them — both variants', () => { + for (const name of ['organization_admin', 'organization_admin_no_bypass']) { + const set = defaultPermissionSets.find((p) => p.name === name)!; + for (const obj of ['sys_user_permission_set', 'sys_user_position']) { + const perms = (set.objects as any)[obj]; + expect(perms, `${name}.${obj} explicit perms missing`).toBeDefined(); + expect([perms.allowRead, perms.allowCreate, perms.allowEdit, perms.allowDelete], `${name}.${obj}`) + .toEqual([true, true, false, true]); + } + } + }); + + describe('the organization boundary over the shipped sets', () => { + const ADMIN_SCOPED = { + name: 'qa_delegated_admin', + objects: {}, + adminScope: { businessUnit: 'hq', includeSubtree: true, manageAssignments: true, assignablePermissionSets: [] }, + }; + const PLATFORM_CAPABLE = { name: 'qa_platform_capable', objects: {}, systemPermissions: ['manage_users'] }; + const ORG_CAPABLE = { name: 'qa_org_capable', objects: {}, systemPermissions: ['manage_org_users'] }; + + function harness(orgAdminSet: string) { + const ql: any = { async find() { return []; }, async findOne() { return null; } }; + bindTestSecurityCatalog(ql, { + permissions: [...defaultPermissionSets, ADMIN_SCOPED, PLATFORM_CAPABLE, ORG_CAPABLE] as any, + positions: [ + { name: 'qa_platform_seat', permissionSets: [ADMIN_FULL_ACCESS] }, + { name: 'qa_member_seat', permissionSets: ['member_default'] }, + ], + capabilities: PLATFORM_CAPABILITIES as any, + }); + const orgAdmin = defaultPermissionSets.find((p) => p.name === orgAdminSet)!; + const gate = new DelegatedAdminGate({ ql, resolveSets: async () => [orgAdmin] }); + const ctx = { userId: 'u_org_admin', tenantId: 'org_a', permissions: [orgAdminSet] }; + const grant = (setName: string, userId: string) => gate.assert({ + object: 'sys_user_permission_set', operation: 'insert', + data: { user_id: userId, permission_set: setName }, context: ctx, + }); + const assign = (position: string, userId: string) => gate.assert({ + object: 'sys_user_position', operation: 'insert', + data: { user_id: userId, position }, context: ctx, + }); + return { grant, assign }; + } + + async function refusal(run: () => Promise): Promise { + try { await run(); } catch (e) { return e; } + throw new Error('expected the write to be refused, but it was admitted'); + } + + for (const orgAdminSet of ['organization_admin', 'organization_admin_no_bypass']) { + it(`${orgAdminSet}: admin_full_access, an adminScope set and a platform-capability set are refused — to anyone, itself included`, async () => { + const h = harness(orgAdminSet); + const cases: Array<[string, RegExp]> = [ + [ADMIN_FULL_ACCESS, /outside the organization boundary by name/], + ['qa_delegated_admin', /condition \(b\)/], + ['qa_platform_capable', /condition \(c\).*'manage_users'/], + ['organization_admin', /outside the organization boundary by name/], + ['organization_admin_no_bypass', /outside the organization boundary by name/], + ]; + for (const [setName, why] of cases) { + for (const userId of ['u_org_admin', 'u_member']) { + const err = await refusal(() => h.grant(setName, userId)); + expect([err.code, err.status], `${setName} → ${userId}`).toEqual(['PERMISSION_DENIED', 403]); + expect(err.message, `${setName} → ${userId}`).toContain(`'${setName}'`); + expect(err.message, `${setName} → ${userId}`).toMatch(why); + } + } + // …and a position that carries admin_full_access, likewise. + const err = await refusal(() => h.assign('qa_platform_seat', 'u_org_admin')); + expect([err.code, err.status]).toEqual(['PERMISSION_DENIED', 403]); + expect(err.message).toMatch(/position 'qa_platform_seat' carries permission set 'admin_full_access'/); + }); + + it(`${orgAdminSet}: CONTROL — member_default, viewer_readonly and an org-capability set are inside`, async () => { + const h = harness(orgAdminSet); + for (const setName of ['member_default', 'viewer_readonly', 'qa_org_capable']) { + await expect(h.grant(setName, 'u_member'), setName).resolves.toBeUndefined(); + } + await expect(h.assign('qa_member_seat', 'u_member')).resolves.toBeUndefined(); + }); } }); diff --git a/packages/plugins/plugin-security/src/security-plugin.ts b/packages/plugins/plugin-security/src/security-plugin.ts index d31b3ba9c4d..64f8170eb29 100644 --- a/packages/plugins/plugin-security/src/security-plugin.ts +++ b/packages/plugins/plugin-security/src/security-plugin.ts @@ -4860,7 +4860,9 @@ export class SecurityPlugin implements Plugin { */ /** * [ADR-0090 D6] Explain access for a caller. `request.userId` (explaining - * someone else) requires the caller to hold `manage_users`, be system, or — + * someone else) requires the caller to hold `manage_users`, be system, be + * the organization administrator of an organization the target is a member + * of (D12 as amended 2026-10-17), or — * [D12] — hold a delegated `adminScope` whose BU subtree covers the target * user (an access report is itself sensitive data, but an admin who can * already rewire a user's grants may read why they resolve as they do). @@ -4891,19 +4893,27 @@ export class SecurityPlugin implements Plugin { const callerSets = await this.resolvePermissionSetsForContext(callerContext).catch(() => []); const held = this.permissionEvaluator.getSystemPermissions(callerSets); if (!held.has('manage_users')) { - // [ADR-0090 D12] Delegated administrators may explain principals - // inside their delegation boundary (fail-closed on any error). - const delegated = this.delegatedAdminGate + // [ADR-0090 D12, amended 2026-10-17] An organization administrator + // may explain a member of the organization it administers — the + // members whose assignments it creates and removes. Then [D12] + // delegated administrators may explain principals inside their + // delegation boundary. Both fail closed on any error, and both read + // the organization the caller's sets were resolved in, never another. + const organizationLevel = this.delegatedAdminGate ? await this.delegatedAdminGate - // Same organization the caller's sets were resolved in — a - // delegation boundary is never read across organizations. - .scopesCoverUser(callerSets, request.userId, callerContext) + .organizationAdminCoversUser(callerSets, request.userId, callerContext) .catch(() => false) : false; + const delegated = organizationLevel || (this.delegatedAdminGate + ? await this.delegatedAdminGate + .scopesCoverUser(callerSets, request.userId, callerContext) + .catch(() => false) + : false); if (!delegated) { throw new PermissionDeniedError( `[Security] Access denied: explaining another user's access requires the 'manage_users' ` + - `capability or a delegated adminScope covering that user (ADR-0090 D6/D12).`, + `capability, organization administration of an organization that user is a member of, ` + + `or a delegated adminScope covering that user (ADR-0090 D6/D12).`, { object, operation, targetUserId: request.userId }, ); } From 78b7deefb982c34cceb621f627fb86534e5c29e1 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 01:52:18 +0000 Subject: [PATCH 2/5] =?UTF-8?q?test(plugin-security):=20organization=20bou?= =?UTF-8?q?ndary=20=E2=80=94=20gate=20branch,=20invitation,=20and=20three-?= =?UTF-8?q?posture=20real-stack=20acceptance?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_013WG7E4zFnUTehQ9UXwyQtT Co-authored-by: Claude --- .../src/auto-org-admin-grant.ts | 17 +- .../src/delegated-admin-gate.test.ts | 283 ++++++++++++++++++ .../src/delegated-admin-gate.ts | 8 +- .../src/invitation-placement.test.ts | 62 ++++ .../src/invitation-placement.ts | 22 +- ...-admin-assignment-boundary.posture.test.ts | 272 +++++++++++++++++ 6 files changed, 647 insertions(+), 17 deletions(-) create mode 100644 packages/plugins/plugin-security/src/org-admin-assignment-boundary.posture.test.ts diff --git a/packages/plugins/plugin-security/src/auto-org-admin-grant.ts b/packages/plugins/plugin-security/src/auto-org-admin-grant.ts index 01db4f91c52..8b955e51e4b 100644 --- a/packages/plugins/plugin-security/src/auto-org-admin-grant.ts +++ b/packages/plugins/plugin-security/src/auto-org-admin-grant.ts @@ -34,12 +34,17 @@ * `bootstrapPlatformAdmin` (which does the analogous thing for * platform admins). * - * **Anti-escalation:** `organization_admin` itself (declared in - * `platform-objects/src/security/default-permission-sets.ts`) is - * deliberately read-only on the global RBAC tables - * (`sys_permission_set`, `sys_user_permission_set`, `sys_position`, …), - * so a freshly-granted org admin cannot rebind themselves to - * `admin_full_access`. + * **Anti-escalation — the organization boundary** (ADR-0090 D12, amended + * 2026-10-17): the set granted here (declared in + * `objects/default-permission-sets.ts`) creates and removes its organization's + * `sys_user_position` / `sys_user_permission_set` rows only inside the + * boundary the delegated-administration gate enforces — every distributed set + * free of the `'*'` superuser bits and of `adminScope`, with only + * `scope: 'org'` capabilities — and `admin_full_access`, the platform sets, + * both org-admin variants and the agent ceilings stay outside it. So a + * freshly-granted org admin cannot rebind itself (or anyone) to + * `admin_full_access`, and cannot copy its own administrator status: that is + * conferred here, by membership grade, and nowhere else. */ import { ORGANIZATION_ADMIN, ORGANIZATION_ADMIN_NO_BYPASS } from '@objectstack/spec'; 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 47eafc885c9..4aec11b6bec 100644 --- a/packages/plugins/plugin-security/src/delegated-admin-gate.test.ts +++ b/packages/plugins/plugin-security/src/delegated-admin-gate.test.ts @@ -1065,3 +1065,286 @@ describe('DelegatedAdminGate — granted_by is the writer on every admitted inse expect('granted_by' in member).toBe(false); }); }); + +// --------------------------------------------------------------------------- +// [ADR-0090 D12, amended 2026-10-17] Organization-level administration +// --------------------------------------------------------------------------- + +/** + * Organization org_a holds units bu_a1 (and org_b holds bu_b1). The catalog: + * `in_set` (inside), `org_cap_set` (an org capability, inside), `wild_set` + * (superuser bit — condition a), `scoped_set` (adminScope — condition b), + * `plat_cap_set` (a platform capability — condition c), `undeclared_cap_set` + * (a capability nobody declares — condition c), and the shipped names the ADR + * keeps outside. Positions: `in_pos` → [in_set], `mixed_pos` → [in_set, + * wild_set], `ghost_pos` → [missing_set], `self_named` → [in_set] beside a + * permission set of the position's own name that carries a platform capability. + */ +function makeOrganizationHarness() { + const tables: Record = { + sys_business_unit: [ + { id: 'bu_a1', name: 'a_ops', parent_business_unit_id: null, organization_id: 'org_a' }, + { id: 'bu_b1', name: 'b_ops', parent_business_unit_id: null, organization_id: 'org_b' }, + ], + sys_user_position: [ + { id: 'up_a', user_id: 'u_member', position: 'in_pos', organization_id: 'org_a' }, + { id: 'up_a_mixed', user_id: 'u_member', position: 'mixed_pos', organization_id: 'org_a' }, + { id: 'up_b', user_id: 'u_member_b', position: 'in_pos', organization_id: 'org_b' }, + { id: 'up_orgless', user_id: 'u_member', position: 'in_pos', organization_id: null }, + ], + sys_user_permission_set: [ + { id: 'ups_a', user_id: 'u_member', permission_set: 'in_set', organization_id: 'org_a' }, + { id: 'ups_a_wild', user_id: 'u_member', permission_set: 'wild_set', organization_id: 'org_a' }, + ], + sys_member: [ + { id: 'm_a', user_id: 'u_member', organization_id: 'org_a', role: 'member' }, + { id: 'm_b', user_id: 'u_member_b', organization_id: 'org_b', role: 'member' }, + ], + }; + const matches = (row: any, where: any): boolean => + Object.entries(where ?? {}).every(([k, v]) => { + if (v && typeof v === 'object' && Array.isArray((v as any).$in)) return (v as any).$in.includes(row[k]); + return row[k] === v; + }); + const ql: any = { + tables, + async find(object: string, opts: any) { + const rows = (tables[object] ?? []).filter((r) => matches(r, opts?.where)); + return typeof opts?.limit === 'number' ? rows.slice(0, opts.limit) : rows; + }, + async findOne(object: string, opts: any) { + return (tables[object] ?? []).find((r) => matches(r, opts?.where)) ?? null; + }, + }; + bindTestSecurityCatalog(ql, { + permissions: [ + { name: 'in_set', objects: { crm_account: { allowRead: true, allowCreate: true } } }, + { name: 'org_cap_set', objects: {}, systemPermissions: ['manage_org_users'] }, + { name: 'wild_set', objects: { '*': { allowRead: true, viewAllRecords: true } } }, + { name: 'scoped_set', objects: {}, adminScope: { businessUnit: 'a_ops', manageAssignments: true, assignablePermissionSets: ['in_set'] } }, + { name: 'plat_cap_set', objects: {}, systemPermissions: ['manage_metadata'] }, + { name: 'undeclared_cap_set', objects: {}, systemPermissions: ['qa_nobody_declares_this'] }, + { name: 'self_named', objects: {}, systemPermissions: ['manage_users'] }, + { name: 'admin_full_access', objects: { '*': { allowRead: true, modifyAllRecords: true } } }, + { name: 'organization_admin', objects: { '*': { allowRead: true, modifyAllRecords: true } } }, + { name: 'organization_admin_no_bypass', objects: { '*': { allowRead: true } }, systemPermissions: ['manage_org_users'] }, + { name: 'mcp_agent_data_read', objects: { '*': { allowRead: true } } }, + { name: 'mcp_agent_data_write', objects: { '*': { allowRead: true, allowCreate: true } } }, + { name: 'mcp_agent_restricted', objects: {} }, + ], + positions: [ + { name: 'in_pos', permissionSets: ['in_set'] }, + { name: 'mixed_pos', permissionSets: ['in_set', 'wild_set'] }, + { name: 'ghost_pos', permissionSets: ['missing_set'] }, + { name: 'self_named', permissionSets: ['in_set'] }, + { name: 'org_owner_like', permissionSets: ['organization_admin'] }, + ], + capabilities: [ + { name: 'manage_org_users', scope: 'org' }, + { name: 'manage_metadata', scope: 'platform' }, + { name: 'manage_users', scope: 'platform' }, + ], + }); + + const SETS: Record = { + // The walled variant carries the superuser wildcard; the wall-less one does not. + org_admin: [{ name: 'organization_admin', objects: { '*': { allowRead: true, modifyAllRecords: true } } }], + org_admin_nb: [{ name: 'organization_admin_no_bypass', objects: { '*': { allowRead: true } } }], + platform_admin: [ + { name: 'admin_full_access', objects: { '*': { allowRead: true, modifyAllRecords: true } } }, + { name: 'organization_admin', objects: { '*': { allowRead: true, modifyAllRecords: true } } }, + ], + member: [{ name: 'member_default', objects: {} }], + org_admin_and_delegate: [ + { name: 'organization_admin_no_bypass', objects: { '*': { allowRead: true } } }, + { name: 'scoped_set', objects: {}, adminScope: { businessUnit: 'a_ops', includeSubtree: true, manageAssignments: true, assignablePermissionSets: ['wild_set'] } }, + ], + }; + const gate = new DelegatedAdminGate({ ql, resolveSets: async (c: any) => SETS[c?.principal ?? ''] ?? [] }); + const ctxOf = (principal: string, tenantId: string | null = 'org_a') => + ({ principal, userId: `u_${principal}`, ...(tenantId ? { tenantId } : {}) }); + return { gate, ql, tables, ctxOf }; +} + +/** The refusal an HTTP door answers, asserted by its ADR-0112 identity, not by a bare throw. */ +async function deniedOf(run: () => Promise): Promise { + try { + await run(); + } catch (e) { + expect([(e as any).code, (e as any).status]).toEqual(['PERMISSION_DENIED', 403]); + return e; + } + throw new Error('expected the write to be refused, but it was admitted'); +} + +describe('DelegatedAdminGate — organization administrators (ADR-0090 D12 as amended 2026-10-17)', () => { + let o: ReturnType; + beforeEach(() => { o = makeOrganizationHarness(); }); + + const write = (ctx: any, object: string, operation: string, data: any) => + o.gate.assert({ object, operation, data, context: ctx }); + + for (const principal of ['org_admin', 'org_admin_nb']) { + describe(principal === 'org_admin' ? 'organization_admin (walled)' : 'organization_admin_no_bypass (wall-less)', () => { + it('inserts an in-boundary position and an in-boundary set for a member; the rows land in its organization', async () => { + const pos: any = { user_id: 'u_member', position: 'in_pos', business_unit_id: 'bu_a1' }; + await expect(write(o.ctxOf(principal), 'sys_user_position', 'insert', pos)).resolves.toBeUndefined(); + expect(pos).toMatchObject({ organization_id: 'org_a', granted_by: `u_${principal}` }); + for (const setName of ['in_set', 'org_cap_set']) { + const grant: any = { user_id: 'u_member', permission_set: setName }; + await expect(write(o.ctxOf(principal), 'sys_user_permission_set', 'insert', grant), setName).resolves.toBeUndefined(); + expect(grant.organization_id).toBe('org_a'); + } + }); + + it('self-assignment of an in-boundary set is admitted (the boundary, not the target, decides)', async () => { + await expect(write(o.ctxOf(principal), 'sys_user_permission_set', 'insert', + { user_id: `u_${principal}`, permission_set: 'in_set' })).resolves.toBeUndefined(); + }); + + it('deletes an in-boundary row of its organization by id', async () => { + await expect(write(o.ctxOf(principal), 'sys_user_position', 'delete', { id: 'up_a' })).resolves.toBeUndefined(); + await expect(write(o.ctxOf(principal), 'sys_user_permission_set', 'delete', { id: 'ups_a' })).resolves.toBeUndefined(); + }); + + it('refuses every out-of-boundary set, naming the set and the condition — to a member and to itself', async () => { + const cases: Array<[string, RegExp]> = [ + ['wild_set', /'wild_set'.*condition \(a\).*'viewAllRecords'/], + ['scoped_set', /'scoped_set'.*condition \(b\)/], + ['plat_cap_set', /'plat_cap_set'.*condition \(c\).*'manage_metadata'/], + ['undeclared_cap_set', /'undeclared_cap_set'.*condition \(c\).*declared nowhere/], + ['missing_set', /'missing_set'.*not in the security catalog/], + ['admin_full_access', /'admin_full_access'.*by name/], + ['organization_admin', /'organization_admin'.*by name/], + ['organization_admin_no_bypass', /'organization_admin_no_bypass'.*by name/], + ['mcp_agent_data_read', /'mcp_agent_data_read'.*by name/], + ['mcp_agent_data_write', /'mcp_agent_data_write'.*by name/], + ['mcp_agent_restricted', /'mcp_agent_restricted'.*by name/], + ]; + for (const [setName, why] of cases) { + for (const userId of ['u_member', `u_${principal}`]) { + const err = await deniedOf(() => write(o.ctxOf(principal), 'sys_user_permission_set', 'insert', + { user_id: userId, permission_set: setName })); + expect(err.message, `${setName} → ${userId}`).toMatch(why); + expect(err.details?.permissionSet, setName).toBe(setName); + } + } + }); + + it('a position carrying a mixed bundle is outside whole — one set outside puts the position outside', async () => { + const err = await deniedOf(() => write(o.ctxOf(principal), 'sys_user_position', 'insert', + { user_id: 'u_member', position: 'mixed_pos' })); + expect(err.message).toMatch(/position 'mixed_pos' carries permission set 'wild_set'.*condition \(a\)/); + expect(err.details).toMatchObject({ position: 'mixed_pos', permissionSet: 'wild_set' }); + // …and so is removing it: the right covers in-boundary rows only. + await deniedOf(() => write(o.ctxOf(principal), 'sys_user_position', 'delete', { id: 'up_a_mixed' })); + await deniedOf(() => write(o.ctxOf(principal), 'sys_user_permission_set', 'delete', { id: 'ups_a_wild' })); + }); + + it("counts the permission set of the position's own name, which the resolver also grants", async () => { + const err = await deniedOf(() => write(o.ctxOf(principal), 'sys_user_position', 'insert', + { user_id: 'u_member', position: 'self_named' })); + expect(err.message).toMatch(/position 'self_named' carries permission set 'self_named'.*condition \(c\).*'manage_users'/); + }); + + it('refuses a position that carries organization_admin, and a position the catalog does not hold', async () => { + expect((await deniedOf(() => write(o.ctxOf(principal), 'sys_user_position', 'insert', + { user_id: 'u_member', position: 'org_owner_like' }))).message).toMatch(/'organization_admin'.*by name/); + expect((await deniedOf(() => write(o.ctxOf(principal), 'sys_user_position', 'insert', + { user_id: 'u_member', position: 'no_such_pos' }))).message).toMatch(/position 'no_such_pos' is not in the security catalog/); + expect((await deniedOf(() => write(o.ctxOf(principal), 'sys_user_position', 'insert', + { user_id: 'u_member', position: 'ghost_pos' }))).message).toMatch(/'missing_set'.*not in the security catalog/); + }); + + it('refuses a row of another organization, and the removal of a foreign or organization-less row', async () => { + expect((await deniedOf(() => write(o.ctxOf(principal), 'sys_user_permission_set', 'insert', + { user_id: 'u_member_b', permission_set: 'in_set', organization_id: 'org_b' }))).message) + .toMatch(/belongs to organization 'org_b'/); + expect((await deniedOf(() => write(o.ctxOf(principal), 'sys_user_position', 'delete', { id: 'up_b' }))).message) + .toMatch(/belongs to organization 'org_b'/); + expect((await deniedOf(() => write(o.ctxOf(principal), 'sys_user_position', 'delete', { id: 'up_orgless' }))).message) + .toMatch(/carries no organization/); + }); + + it("refuses a business unit of another organization as the assignment's anchor", async () => { + const err = await deniedOf(() => write(o.ctxOf(principal), 'sys_user_position', 'insert', + { user_id: 'u_member', position: 'in_pos', business_unit_id: 'bu_b1' })); + expect(err.message).toMatch(/business unit 'bu_b1' is not a unit of the organization you administer/); + }); + + it('refuses an edit in place, a filter delete, and a caller with no active organization', async () => { + expect((await deniedOf(() => write(o.ctxOf(principal), 'sys_user_position', 'update', + { id: 'up_a', reason: 'x' }))).message).toMatch(/never edited in place/); + expect((await deniedOf(() => o.gate.assert({ object: 'sys_user_position', operation: 'delete', + options: { where: { user_id: 'u_member' } }, context: o.ctxOf(principal) }))).message).toMatch(/one assignment row by id/); + expect((await deniedOf(() => write(o.ctxOf(principal, null), 'sys_user_permission_set', 'insert', + { user_id: 'u_member', permission_set: 'in_set' }))).message).toMatch(/no active organization/); + }); + + it('still may not write membership rows — administrator status comes from the membership grade alone', async () => { + await deniedOf(() => write(o.ctxOf(principal), 'sys_member', 'insert', + { user_id: 'u_member', organization_id: 'org_a', role: 'admin' })); + }); + + it('a refused insert is neither stamped nor given an organization', async () => { + const grant: any = { user_id: 'u_member', permission_set: 'wild_set' }; + await deniedOf(() => write(o.ctxOf(principal), 'sys_user_permission_set', 'insert', grant)); + expect('organization_id' in grant || 'granted_by' in grant).toBe(false); + }); + }); + } + + it('a platform administrator keeps the status quo, organization_admin beside it or not', async () => { + await expect(write(o.ctxOf('platform_admin'), 'sys_user_permission_set', 'insert', + { user_id: 'u_member', permission_set: 'admin_full_access' })).resolves.toBeUndefined(); + await expect(write(o.ctxOf('platform_admin'), 'sys_user_position', 'update', { id: 'up_a', reason: 'x' })).resolves.toBeUndefined(); + }); + + it('a member is denied as before', async () => { + expect((await deniedOf(() => write(o.ctxOf('member'), 'sys_user_permission_set', 'insert', + { user_id: 'u_member', permission_set: 'in_set' }))).message).toMatch(/delegated adminScope/); + }); + + it('an organization administrator that is also a delegate falls back to its adminScope outside the boundary', async () => { + // wild_set is outside the organization boundary but on the delegate's allowlist. + await expect(write(o.ctxOf('org_admin_and_delegate'), 'sys_user_position', 'insert', + { user_id: 'u_member', position: 'mixed_pos', business_unit_id: 'bu_a1' })).rejects.toThrow(/delegated 'insert' on sys_user_position rejected — position 'mixed_pos' distributes permission set 'in_set'/); + await expect(write(o.ctxOf('org_admin_and_delegate'), 'sys_user_permission_set', 'insert', + { user_id: 'u_member', permission_set: 'in_set' })).resolves.toBeUndefined(); + }); + + describe('the two doors', () => { + it('describeDelegableScope: every unit of its organization is placeable and only in-boundary positions are assignable', async () => { + for (const principal of ['org_admin', 'org_admin_nb']) { + const report = await o.gate.describeDelegableScope( + (await (o.gate as any).deps.resolveSets(o.ctxOf(principal))) as any, + o.ctxOf(principal), + ); + expect(report, principal).toEqual({ + isTenantAdmin: false, + scopes: [], + placeableBusinessUnitIds: ['bu_a1'], + assignablePositions: ['in_pos'], + }); + } + }); + + it('describeDelegableScope: a platform administrator is still reported tenant-level', async () => { + const report = await o.gate.describeDelegableScope( + (await (o.gate as any).deps.resolveSets(o.ctxOf('platform_admin'))) as any, + o.ctxOf('platform_admin'), + ); + expect(report.isTenantAdmin).toBe(true); + expect(report.assignablePositions).toContain('mixed_pos'); + }); + + it('organizationAdminCoversUser: a member of its own organization only, and never for a non-administrator', async () => { + const sets = async (p: string) => (o.gate as any).deps.resolveSets(o.ctxOf(p)); + expect(await o.gate.organizationAdminCoversUser(await sets('org_admin'), 'u_member', o.ctxOf('org_admin'))).toBe(true); + expect(await o.gate.organizationAdminCoversUser(await sets('org_admin_nb'), 'u_member', o.ctxOf('org_admin_nb'))).toBe(true); + expect(await o.gate.organizationAdminCoversUser(await sets('org_admin'), 'u_member_b', o.ctxOf('org_admin'))).toBe(false); + expect(await o.gate.organizationAdminCoversUser(await sets('member'), 'u_member', o.ctxOf('member'))).toBe(false); + expect(await o.gate.organizationAdminCoversUser(await sets('org_admin'), 'u_member', o.ctxOf('org_admin', null))).toBe(false); + }); + }); +}); diff --git a/packages/plugins/plugin-security/src/delegated-admin-gate.ts b/packages/plugins/plugin-security/src/delegated-admin-gate.ts index c3fe00f6334..cd73f6e5b21 100644 --- a/packages/plugins/plugin-security/src/delegated-admin-gate.ts +++ b/packages/plugins/plugin-security/src/delegated-admin-gate.ts @@ -69,6 +69,7 @@ import { seedCtx as organizationScopedCtx, } from './organization-scope.js'; import { grantSetNameOf } from './grant-permission-set-name.js'; +import { isReservedIdentityName } from './objects/reserved-identity-names.js'; const SYSTEM_CTX = { isSystem: true } as const; /** @@ -622,13 +623,16 @@ export class DelegatedAdminGate { // [ADR-0090 D12, amended 2026-10-17] An organization administrator places // into every unit of its organization and assigns every position inside // the organization boundary — `organizationAdminFailure`'s own test, so an - // option offered here is one the write path admits. + // option offered here is one the write path admits. The ADR-0068 D2 + // identity names carry no sets, so the boundary does not settle them, but + // `sys_user_position.position` refuses them from every writer: they are + // not offered either. if (holdsOrganizationAdmin(sets) && organizationId) { for (const id of await this.organizationBusinessUnitIds(organizationId)) { if (!placeableBusinessUnitIds.includes(id)) placeableBusinessUnitIds.push(id); } for (const positionName of await allPositions()) { - if (assignablePositions.includes(positionName)) continue; + if (assignablePositions.includes(positionName) || isReservedIdentityName(positionName)) continue; let failure: unknown; try { failure = await this.positionBoundaryFailure(positionName); diff --git a/packages/plugins/plugin-security/src/invitation-placement.test.ts b/packages/plugins/plugin-security/src/invitation-placement.test.ts index 5923fd52d01..b55ce5a5033 100644 --- a/packages/plugins/plugin-security/src/invitation-placement.test.ts +++ b/packages/plugins/plugin-security/src/invitation-placement.test.ts @@ -17,6 +17,8 @@ import { readPlacementIntent, } from './invitation-placement.js'; import { assertEngineFindOnePredicate, type EngineFindOneQueryInput } from '@objectstack/metadata-core'; +import { DelegatedAdminGate } from './delegated-admin-gate.js'; +import { bindTestSecurityCatalog } from './__tests__/security-catalog.testkit.js'; const INTENT = { businessUnitId: 'bu_plant_a', positions: ['qc_inspector', 'line_lead'] }; @@ -198,3 +200,63 @@ describe('apply — accept-time placement', () => { expect(result.created).toBe(2); }); }); + +// [ADR-0090 D12, amended 2026-10-17] The organization administrator's +// invitation is judged by the REAL gate, so it carries only positions inside +// the organization boundary — the property the module header now argues from. +describe("assertIssuable — an organization administrator's invitation stays inside the organization boundary", () => { + function makeOrganizationService() { + const ql = makeQl({ + sys_business_unit: [ + { id: 'bu_plant_a', name: 'plant_a', organization_id: 'org_1' }, + { id: 'bu_other', name: 'other', organization_id: 'org_2' }, + ], + }); + bindTestSecurityCatalog(ql, { + permissions: [ + { name: 'qc_set', objects: { qc_check: { allowRead: true, allowCreate: true } } }, + { name: 'admin_full_access', objects: { '*': { allowRead: true, modifyAllRecords: true } } }, + ], + positions: [ + { name: 'qc_inspector', permissionSets: ['qc_set'] }, + { name: 'plant_superuser', permissionSets: ['qc_set', 'admin_full_access'] }, + ], + }); + const gate = new DelegatedAdminGate({ + ql, + // The issuer holds the walled organization-administrator set for org_1. + resolveSets: async (c: any) => (c?.userId === 'u_org_admin' + ? [{ name: 'organization_admin', objects: { '*': { allowRead: true, modifyAllRecords: true } } } as any] + : []), + }); + return createInvitationPlacementService({ ql, gate, logger: { info: vi.fn() } }); + } + + it('in-boundary positions into a unit of its organization are issuable', async () => { + await expect(makeOrganizationService().assertIssuable({ + intent: { businessUnitId: 'bu_plant_a', positions: ['qc_inspector'] }, + actorUserId: 'u_org_admin', + organizationId: 'org_1', + })).resolves.toBeUndefined(); + }); + + it('a position carrying a set outside the boundary is refused, naming the set and the condition', async () => { + const err: any = await makeOrganizationService().assertIssuable({ + intent: { businessUnitId: 'bu_plant_a', positions: ['qc_inspector', 'plant_superuser'] }, + actorUserId: 'u_org_admin', + organizationId: 'org_1', + }).then(() => null, (e) => e); + expect([err?.code, err?.status]).toEqual(['PERMISSION_DENIED', 403]); + expect(err.message).toMatch(/position 'plant_superuser' carries permission set 'admin_full_access'.*by name/); + }); + + it("a unit of another organization is refused", async () => { + const err: any = await makeOrganizationService().assertIssuable({ + intent: { businessUnitId: 'bu_other', positions: ['qc_inspector'] }, + actorUserId: 'u_org_admin', + organizationId: 'org_1', + }).then(() => null, (e) => e); + expect([err?.code, err?.status]).toEqual(['PERMISSION_DENIED', 403]); + expect(err.message).toMatch(/business unit 'bu_other' is not a unit of the organization you administer/); + }); +}); diff --git a/packages/plugins/plugin-security/src/invitation-placement.ts b/packages/plugins/plugin-security/src/invitation-placement.ts index 18bb3a689bf..94d4b1d3f7b 100644 --- a/packages/plugins/plugin-security/src/invitation-placement.ts +++ b/packages/plugins/plugin-security/src/invitation-placement.ts @@ -7,15 +7,19 @@ * lands in and the positions they are assigned on acceptance. Two halves, * and they must ship together: * - * 1. **Issuance is authorized** against the issuer's `adminScope` - * (ADR-0090 D12) — a delegated admin may only invite into their own - * subtree and only attach positions whose permission sets are in their - * allowlist. Without this the feature would be a privilege-escalation - * hole: `organization_admin` is deliberately READ-ONLY on the RBAC - * tables (`auto-org-admin-grant.ts`) precisely so a fresh org admin - * cannot rebind themselves, and a platform that applied an unchecked - * invitation payload under system context would hand that authority - * straight back through the invitation surface. + * 1. **Issuance is authorized** against the issuer's authority as the + * delegated-administration gate judges it (ADR-0090 D12) — a delegated + * admin may only invite into their own subtree and only attach positions + * whose permission sets are in their allowlist; an organization + * administrator (D12 as amended 2026-10-17) only into a unit of its own + * organization and only with positions inside the organization boundary + * (every set they carry: no `'*'` superuser bit, no `adminScope`, only + * `scope: 'org'` capabilities, none of the sets the ADR keeps outside by + * name). That boundary is the safety premise: acceptance writes the rows + * under system context, so an unchecked invitation payload would hand + * out exactly what the boundary withholds — `admin_full_access`, a + * platform capability, administration itself — through the invitation + * surface. * 2. **Acceptance applies it** — the `sys_user_position` rows land with the * better-auth membership, so a plant admin's invitee arrives already in * the right unit and role instead of waiting on a platform admin. diff --git a/packages/plugins/plugin-security/src/org-admin-assignment-boundary.posture.test.ts b/packages/plugins/plugin-security/src/org-admin-assignment-boundary.posture.test.ts new file mode 100644 index 00000000000..d693260a09d --- /dev/null +++ b/packages/plugins/plugin-security/src/org-admin-assignment-boundary.posture.test.ts @@ -0,0 +1,272 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [ADR-0090 D12, amended 2026-10-17] The organization administrator's + * assignment boundary, measured on a REAL `ObjectQL` engine over a real SQL + * driver with the REAL `SecurityPlugin` registered on it — the delegated- + * administration gate, the shipped overrides, the CRUD check and the + * grant-holder membership hooks all in their production order. + * + * Three postures (`single`, `group`, `isolated`) × four callers (a platform + * administrator, `organization_admin`, `organization_admin_no_bypass`, a + * member) × insert and delete × both assignment tables × an in-boundary and an + * out-of-boundary set. Every refusal is identified by its ADR-0112 envelope — + * `status` and `code` through `resolveThrownHttpError`, the resolver both HTTP + * doors answer with — and an out-of-boundary refusal also names its set and + * the failed condition. Every admission reads the stored row back. + */ + +import { describe, it, expect, afterEach, vi } from 'vitest'; +import { ObjectQL } from '@objectstack/objectql'; +import { SqlDriver } from '@objectstack/driver-sql'; +import { resolveThrownHttpError } from '@objectstack/types'; +import { SysUser, SysMember } from '@objectstack/platform-objects/identity'; + +import { SecurityPlugin } from './security-plugin.js'; +import { SysUserPosition } from './objects/sys-user-position.object.js'; +import { SysUserPermissionSet } from './objects/sys-user-permission-set.object.js'; +import { defaultPermissionSets } from './objects/index.js'; + +const SYS = { isSystem: true } as const; +const ORG_A = 'org_a'; +const ORG_B = 'org_b'; + +/** An in-boundary set (no superuser bit, no adminScope, an org capability only). */ +const QA_IN_SET = { + name: 'qa_in_set', + label: 'QA in-boundary', + objects: { sys_user: { allowRead: true } }, + systemPermissions: ['manage_org_users'], +}; +const POSITIONS = [ + { name: 'qa_in_pos', label: 'In', permissionSets: ['qa_in_set'] }, + // A mixed bundle: one set outside puts the whole position outside. + { name: 'qa_out_pos', label: 'Out', permissionSets: ['qa_in_set', 'admin_full_access'] }, +]; +const PERMISSIONS = [...defaultPermissionSets, QA_IN_SET]; + +type Posture = 'single' | 'group' | 'isolated'; +const POSTURES: Posture[] = ['single', 'group', 'isolated']; + +const CALLERS = { + platform: { userId: 'u_platform', positions: [], permissions: ['admin_full_access'], posture: 'PLATFORM_ADMIN', tenantId: ORG_A, accessible_org_ids: [ORG_A] }, + orgAdmin: { userId: 'u_org_admin', positions: [], permissions: ['organization_admin'], tenantId: ORG_A, accessible_org_ids: [ORG_A] }, + orgAdminNoBypass: { userId: 'u_org_admin_nb', positions: [], permissions: ['organization_admin_no_bypass'], tenantId: ORG_A, accessible_org_ids: [ORG_A] }, + member: { userId: 'u_member', positions: [], permissions: ['member_default'], tenantId: ORG_A, accessible_org_ids: [ORG_A] }, +} as const; +type Caller = keyof typeof CALLERS; + +const MEMBERSHIPS = [ + { id: 'm_platform', user_id: 'u_platform', organization_id: ORG_A, role: 'member' }, + { id: 'm_admin', user_id: 'u_org_admin', organization_id: ORG_A, role: 'admin' }, + { id: 'm_admin_nb', user_id: 'u_org_admin_nb', organization_id: ORG_A, role: 'admin' }, + { id: 'm_member', user_id: 'u_member', organization_id: ORG_A, role: 'member' }, + { id: 'm_target', user_id: 'u_target', organization_id: ORG_A, role: 'member' }, + { id: 'm_seeded', user_id: 'u_seeded', organization_id: ORG_A, role: 'member' }, + { id: 'm_other', user_id: 'u_other_org', organization_id: ORG_B, role: 'member' }, +]; + +const engines: ObjectQL[] = []; +afterEach(async () => { + vi.restoreAllMocks(); + while (engines.length) { + try { await engines.pop()?.destroy(); } catch { /* noop */ } + } +}); + +async function boot(posture: Posture) { + const engine = new ObjectQL(); + engine.registerDriver( + new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }), + true, + ); + await engine.init(); + engine.registerApp({ + id: 'com.objectstack.qa.org-admin-assignment-boundary', + name: 'Organization administrator assignment boundary', + version: '1.0.0', + type: 'plugin', + scope: 'system', + objects: [SysUserPosition, SysUserPermissionSet, SysMember], + } as any); + await engine.syncSchemas(); + // `sys_user` registered and unprovisioned, as in the sibling suites: the + // engine's `user_id` existence probe stands down and the membership hook is + // what judges the holder. + if (!engine.registry.getObject(SysUser.name)) engine.registry.registerObject(SysUser as never, 'qa.org-admin-boundary'); + engines.push(engine); + + const services: Record = { + manifest: { register: vi.fn() }, + objectql: engine, + // [ADR-0131 D3/D4] The catalog the rows are judged against: the shipped + // sets plus one in-boundary set, and the two positions. + metadata: { + get: async (type: string, name: string) => + (type === 'position' ? POSITIONS.find((p) => p.name === name) + : type === 'permission' ? PERMISSIONS.find((p) => p.name === name) + : type === 'capability' ? undefined + : engine.getSchema(name)) ?? null, + list: async (type: string) => (type === 'position' ? POSITIONS : type === 'permission' ? PERMISSIONS : []), + }, + ...(posture === 'single' ? {} : { 'org-scoping': { name: 'com.objectstack.org-scoping' }, tenancy: { posture } }), + }; + const ctx: any = { + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, + hook: vi.fn(), + registerService: vi.fn(), + getService: (name: string) => { + if (!(name in services)) throw new Error(`service not registered: ${name}`); + return services[name]; + }, + }; + const plugin = new SecurityPlugin({ fallbackPermissionSet: 'member_default' }); + await plugin.init(ctx); + await plugin.start(ctx); + vi.spyOn((engine as any).logger, 'warn').mockImplementation(() => undefined); + + for (const m of MEMBERSHIPS) { + await engine.insert('sys_member', m, { context: { isSystem: true, tenantId: m.organization_id } } as any); + } + return { engine, plugin }; +} + +type Harness = Awaited>; + +/** The four writes of one (table, in/out) cell. */ +const TABLES = { + sys_user_position: { in: { position: 'qa_in_pos' }, out: { position: 'qa_out_pos' } }, + sys_user_permission_set: { in: { permission_set: 'qa_in_set' }, out: { permission_set: 'admin_full_access' } }, +} as const; +type Table = keyof typeof TABLES; + +async function outcome(run: () => Promise): Promise<{ status: number; code?: string; message?: string }> { + try { + await run(); + return { status: 200 }; + } catch (e) { + const r = resolveThrownHttpError(e); + return { status: r.status, code: r.code, message: (e as Error).message }; + } +} + +async function seed(h: Harness, table: Table, id: string, side: 'in' | 'out') { + await h.engine.insert(table, { id, user_id: 'u_seeded', ...TABLES[table][side], organization_id: ORG_A }, + { context: { isSystem: true, tenantId: ORG_A } } as any); +} + +async function stored(h: Harness, table: Table, where: Record): Promise { + const rows = await h.engine.find(table, { where, context: SYS }); + return Array.isArray(rows) ? rows : []; +} + +/** What each caller is answered — admitted, or refused with the envelope. */ +const EXPECTED: Record = { + platform: { in: 'admit', out: 'admit' }, + orgAdmin: { in: 'admit', out: 'deny' }, + orgAdminNoBypass: { in: 'admit', out: 'deny' }, + member: { in: 'deny', out: 'deny' }, +}; + +describe('[ADR-0090 D12, amended 2026-10-17] the organization administrator assigns inside the boundary — three postures', () => { + for (const posture of POSTURES) { + describe(`posture '${posture}'`, () => { + for (const caller of Object.keys(CALLERS) as Caller[]) { + for (const table of Object.keys(TABLES) as Table[]) { + for (const side of ['in', 'out'] as const) { + const expected = EXPECTED[caller][side]; + it(`${caller} · ${table} · ${side}-boundary: insert and delete ${expected === 'admit' ? 'admitted' : 'refused 403'}`, async () => { + const h = await boot(posture); + const context = CALLERS[caller]; + + // insert + const ins = await outcome(() => h.engine.insert(table, { user_id: 'u_target', ...TABLES[table][side] }, { context } as any)); + const insRows = await stored(h, table, { user_id: 'u_target' }); + // delete (of a row seeded by the system in org_a) + await seed(h, table, `seed_${side}`, side); + const del = await outcome(() => h.engine.delete(table, { where: { id: `seed_${side}` }, context } as any)); + const delRows = await stored(h, table, { id: `seed_${side}` }); + + if (expected === 'admit') { + expect(ins, 'insert').toEqual({ status: 200 }); + expect(insRows, 'insert stored').toHaveLength(1); + expect(insRows[0]?.organization_id, 'insert stored in the organization').toBe(ORG_A); + expect(del, 'delete').toEqual({ status: 200 }); + expect(delRows, 'delete removed the row').toHaveLength(0); + } else { + expect([ins.status, ins.code], `insert: ${ins.message}`).toEqual([403, 'PERMISSION_DENIED']); + expect(insRows, 'insert stored nothing').toHaveLength(0); + expect([del.status, del.code], `delete: ${del.message}`).toEqual([403, 'PERMISSION_DENIED']); + expect(delRows, 'delete left the row').toHaveLength(1); + if (caller !== 'member') { + // The org admin's refusal names the set and the condition that failed. + for (const m of [ins.message, del.message]) { + expect(m).toMatch(/organization boundary/); + expect(m).toContain("'admin_full_access'"); + expect(m).toMatch(/by name/); + } + } + } + }); + } + } + } + + it('an organization administrator assigning a member of ANOTHER organization is refused (the membership hook, 400)', async () => { + const h = await boot(posture); + for (const context of [CALLERS.orgAdmin, CALLERS.orgAdminNoBypass]) { + const r = await outcome(() => h.engine.insert('sys_user_permission_set', + { user_id: 'u_other_org', permission_set: 'qa_in_set' }, { context } as any)); + expect([r.status, r.code], r.message).toEqual([400, 'VALIDATION_FAILED']); + } + expect(await stored(h, 'sys_user_permission_set', { user_id: 'u_other_org' })).toHaveLength(0); + }); + + it('an organization administrator cannot assign a built-in identity position (refused for every writer, 400)', async () => { + const h = await boot(posture); + for (const context of [CALLERS.orgAdmin, CALLERS.orgAdminNoBypass]) { + for (const position of ['platform_admin', 'org_owner', 'org_admin']) { + const r = await outcome(() => h.engine.insert('sys_user_position', { user_id: 'u_target', position }, { context } as any)); + expect([r.status, r.code], `${position}: ${r.message}`).toEqual([400, 'VALIDATION_FAILED']); + } + } + expect(await stored(h, 'sys_user_position', { user_id: 'u_target' })).toHaveLength(0); + }); + + it('an organization administrator may not edit an assignment in place', async () => { + const h = await boot(posture); + await seed(h, 'sys_user_position', 'seed_edit', 'in'); + for (const context of [CALLERS.orgAdmin, CALLERS.orgAdminNoBypass]) { + const r = await outcome(() => h.engine.update('sys_user_position', { id: 'seed_edit', reason: 'edited' }, { context } as any)); + expect([r.status, r.code], r.message).toEqual([403, 'PERMISSION_DENIED']); + } + const [row] = await stored(h, 'sys_user_position', { id: 'seed_edit' }); + expect(row?.reason ?? null).toBeNull(); + }); + + it('the delegable-scope report offers the org admin its in-boundary positions only, and explain admits it for its member', async () => { + const h = await boot(posture); + const security = h.plugin as any; + for (const context of [CALLERS.orgAdmin, CALLERS.orgAdminNoBypass]) { + const sets = await security.resolvePermissionSetsForContext(context); + const report = await security.delegatedAdminGate.describeDelegableScope(sets, context); + expect(report.isTenantAdmin).toBe(false); + expect(report.assignablePositions).toContain('qa_in_pos'); + // Never a position carrying a set outside the boundary — the mixed + // bundle, nor the built-in administrator positions. + for (const outside of ['qa_out_pos', 'platform_admin', 'org_owner', 'org_admin']) { + expect(report.assignablePositions).not.toContain(outside); + } + await expect(security.explainAccessForCaller({ object: 'sys_user_position', operation: 'read', userId: 'u_target' }, context)) + .resolves.toBeDefined(); + const refused = await outcome(() => security.explainAccessForCaller( + { object: 'sys_user_position', operation: 'read', userId: 'u_other_org' }, context)); + expect([refused.status, refused.code]).toEqual([403, 'PERMISSION_DENIED']); + } + const memberRefused = await outcome(() => security.explainAccessForCaller( + { object: 'sys_user_position', operation: 'read', userId: 'u_target' }, CALLERS.member)); + expect([memberRefused.status, memberRefused.code]).toEqual([403, 'PERMISSION_DENIED']); + }); + }); + } +}); From 296a1d63c7656411684c235e024f7afa88edde97 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 01:55:06 +0000 Subject: [PATCH 3/5] docs(qa): checklist item for the organization administrator's assignment boundary; changeset Claude-Session: https://claude.ai/code/session_013WG7E4zFnUTehQ9UXwyQtT Co-authored-by: Claude --- .../24136-org-admin-assignment-boundary.md | 15 ++++ .../areas/access-security.json | 76 +++++++++++++++++++ 2 files changed, 91 insertions(+) create mode 100644 .changeset/24136-org-admin-assignment-boundary.md diff --git a/.changeset/24136-org-admin-assignment-boundary.md b/.changeset/24136-org-admin-assignment-boundary.md new file mode 100644 index 00000000000..c5dd018ad6f --- /dev/null +++ b/.changeset/24136-org-admin-assignment-boundary.md @@ -0,0 +1,15 @@ +--- +'@objectstack/plugin-security': minor +--- + +feat(plugin-security): organization administrators assign positions and permission sets to their own members, inside the organization boundary + +Clause-②: no + +ADR-0090 D12 (as amended for ADR-0131 D3) names three kinds of administrator. This release enforces the second one, the organization administrator: a holder of `organization_admin` or `organization_admin_no_bypass` for the active organization who is not a platform administrator. + +- **What an organization administrator may now do.** Create and remove `sys_user_position` and `sys_user_permission_set` rows of its own organization, for that organization's members. Every permission set the row distributes must be inside the organization boundary: the granted set, or each set the assigned position carries (including the set of the position's own name). A set is inside when its `objects['*']` entry carries neither `viewAllRecords` nor `modifyAllRecords`, it has no `adminScope`, and every name in its `systemPermissions` is a capability declared `scope: 'org'`. `admin_full_access`, both organization-administrator sets and the three MCP agent ceilings are outside by name. An assignment row is never edited in place: remove it and create the one you mean. A row inserted with no organization lands in the caller's organization. +- **What is refused, and how.** An out-of-boundary write answers `403 PERMISSION_DENIED`, and the message names the set and the condition it failed. The same refusal covers a row of another organization, removal of an organization-less row, a business unit of another organization, an edit in place and a filter delete. Assigning a user who is not a member of the organization is refused `400 VALIDATION_FAILED` as before, for every writer. So is assigning a built-in identity position (`platform_admin`, `org_owner`, `org_admin`, `org_member`). Organization membership rows stay platform-administrator-only. +- **Overrides.** `organization_admin`, and so `organization_admin_no_bypass`, now carries `allowCreate` and `allowDelete` on both assignment tables. `allowEdit` stays `false`. +- **The two doors.** `GET /security/my-delegable-scope` reports an organization administrator with `isTenantAdmin: false`, every unit of its organization as placeable, and only in-boundary positions as assignable. The walled `organization_admin` was reported as tenant-level before. Explaining another user's access now admits an organization administrator explaining a member of its own organization. +- **Unchanged.** Platform administrators keep today's behaviour. Delegated (`adminScope`) administrators keep D12's subtree, allowlist and strict containment. Everyone else is denied as before. No spec key, no endpoint and no membership-model change. diff --git a/docs/qa/platform-checklist/areas/access-security.json b/docs/qa/platform-checklist/areas/access-security.json index 9da397c523a..4806c0379de 100644 --- a/docs/qa/platform-checklist/areas/access-security.json +++ b/docs/qa/platform-checklist/areas/access-security.json @@ -3997,6 +3997,82 @@ "ref": "#24632" } ] + }, + { + "id": "access-security.org-admin-assignment-boundary", + "title": "An organization administrator creates and removes its own members' position and permission-set assignments inside the organization boundary, in all three tenancy postures; an out-of-boundary set answers 403 naming the set and the condition", + "since": "v18", + "status": "active", + "revision": 1, + "priority": "P1", + "surface": "api", + "personas": [ + "a platform administrator (admin_full_access)", + "an organization administrator under a walled posture (organization_admin)", + "an organization administrator under a wall-less posture (organization_admin_no_bypass)", + "a member (member_default)" + ], + "fixtures": { + "app": "none — the real-stack pin boots ObjectQL over SQLite with the SecurityPlugin per posture", + "requires": [ + "an in-boundary permission set (no objects['*'] superuser bit, no adminScope, only scope 'org' capabilities) and a position carrying it", + "a position carrying a mixed bundle (an in-boundary set plus admin_full_access)", + "two organizations, with the target user a member of the administrator's organization only" + ] + }, + "steps": [ + "for each posture (single, group, isolated) and each persona, insert a sys_user_position and a sys_user_permission_set row for a member, with an in-boundary and an out-of-boundary set", + "for the same cells, delete a system-seeded row of the administrator's organization by id", + "read GET /security/my-delegable-scope and explain another member's access as each organization administrator" + ], + "acceptance": [ + { + "clause": "the three-posture matrix passes at the run subject sha (RUNNER rule 6: cite the pin's output, never re-derive by hand)", + "oracle": "test", + "verify": "run each file named in automated.ref with its package's vitest (`pnpm --filter @objectstack/plugin-security exec vitest run --maxWorkers=2 FILE`, never through a bare `--`); every named file reports ≥1 test and 0 failed. A file that reports 0 tests or is not found is NOT a pass — it is a stale ref, fix the item", + "evidence": "the vitest summary line per pin file, with the package name and the subject sha" + }, + { + "clause": "both organization-administrator variants: in-boundary insert and delete on both tables succeed (the inserted row is stored in the administrator's organization); out-of-boundary insert and delete answer 403 PERMISSION_DENIED and the message names the set and the failed condition", + "oracle": "test", + "verify": "the per-cell cases of org-admin-assignment-boundary.posture.test.ts, three postures", + "evidence": "the case names and their pass lines" + }, + { + "clause": "the platform administrator is admitted in every cell (unchanged); the member is refused 403 in every cell (unchanged)", + "oracle": "test", + "verify": "the platform and member cells of the same file", + "evidence": "the case names and their pass lines" + }, + { + "clause": "the delegable-scope report gives an organization administrator isTenantAdmin false and offers only in-boundary positions; explain admits it for a member of its organization and refuses it for anyone else", + "oracle": "test", + "verify": "the report/explain case of the same file, per posture", + "evidence": "the case names and their pass lines" + } + ], + "negative": [ + "an organization administrator's assignment of a member of another organization lands — FAIL (expected 400 VALIDATION_FAILED from the grant-holder membership hooks)", + "an organization administrator edits an assignment row in place — FAIL (expected 403)", + "an organization administrator assigns admin_full_access, an adminScope set, a platform-capability set, either organization-administrator set or an agent ceiling, to anyone including itself — FAIL (expected 403 naming the set)" + ], + "automated": { + "kind": "e2e", + "ref": "packages/plugins/plugin-security/src/org-admin-assignment-boundary.posture.test.ts + packages/plugins/plugin-security/src/delegated-admin-gate.test.ts (the organization-administrator describe) + packages/plugins/plugin-security/src/objects/rbac-objects.test.ts (the boundary pin over the shipped sets)" + }, + "source": [ + "packages/plugins/plugin-security/src/delegated-admin-gate.ts#organizationAdminFailure", + "packages/plugins/plugin-security/src/objects/default-permission-sets.ts", + "docs/adr/0090-permission-model-v2-concept-convergence.md (D12, amended 2026-10-17)" + ], + "history": [ + { + "revision": 1, + "date": "2026-10-20", + "change": "initial — the organization administrator's assignment boundary (ADR-0090 D12 as amended 2026-10-17), run once on the PR head through the automated pins", + "ref": "#24136" + } + ] } ] } \ No newline at end of file From 12ff36240afd17a73988c280f72317464320be1d Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 02:01:34 +0000 Subject: [PATCH 4/5] test(plugin-security): the effective-permissions parity pin follows the organization boundary overrides Claude-Session: https://claude.ai/code/session_013WG7E4zFnUTehQ9UXwyQtT Co-authored-by: Claude --- .../src/get-effective-object-permissions.test.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/packages/plugins/plugin-security/src/get-effective-object-permissions.test.ts b/packages/plugins/plugin-security/src/get-effective-object-permissions.test.ts index 4aa18030a57..e231f4c06f5 100644 --- a/packages/plugins/plugin-security/src/get-effective-object-permissions.test.ts +++ b/packages/plugins/plugin-security/src/get-effective-object-permissions.test.ts @@ -476,16 +476,19 @@ describe('[#20083] parity: can() over the member\'s map answers what checkObject }); } - it('[#20136] the reported case, spelled out: the walled org admin may not write its RBAC rows, and can() says so', async () => { + // [ADR-0090 D12, amended 2026-10-17] Since the organization boundary the + // walled org admin creates and removes assignment rows (the gate bounds + // which), and still never edits one — the object grant and can() agree. + it('[#20136] the reported case, spelled out: the walled org admin may not EDIT its RBAC rows, and can() says so', async () => { const context = { userId: USER.id }; for (const label of ['[#20136 row a] walled org admin', '[#20136 row b] walled org admin WITHOUT member_default']) { const sets = SUBJECTS[label]; const { svc, plugin } = await locate({ schemas: REGISTERED }); vi.spyOn(plugin as any, 'resolvePermissionSetsForContext').mockResolvedValue(sets); const permissions = toEvalPermissions(await svc.getEffectiveObjectPermissions!(context)); - for (const [verb, operation] of [['edit', 'update'], ['create', 'insert'], ['delete', 'delete']] as const) { - expect(evaluator.checkObjectPermission(operation, SysUserPosition.name, sets), `${label} ${verb}`).toBe(false); - expect(can(permissions, SysUserPosition.name, verb), `${label} ${verb}`).toBe(false); + for (const [verb, operation, granted] of [['edit', 'update', false], ['create', 'insert', true], ['delete', 'delete', true]] as const) { + expect(evaluator.checkObjectPermission(operation, SysUserPosition.name, sets), `${label} ${verb}`).toBe(granted); + expect(can(permissions, SysUserPosition.name, verb), `${label} ${verb}`).toBe(granted); } // …and still reads them, on both sides. expect(evaluator.checkObjectPermission('find', SysUserPosition.name, sets)).toBe(true); From 20eb5f1f6d24c743c27ab7dcceb52707db7802bd Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 02:28:22 +0000 Subject: [PATCH 5/5] test(plugin-security): the organization-administrator double refuses WHERE combinators it does not implement Claude-Session: https://claude.ai/code/session_013WG7E4zFnUTehQ9UXwyQtT Co-authored-by: Claude --- .../plugins/plugin-security/src/delegated-admin-gate.test.ts | 1 + 1 file changed, 1 insertion(+) 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 4aec11b6bec..15377fb65f8 100644 --- a/packages/plugins/plugin-security/src/delegated-admin-gate.test.ts +++ b/packages/plugins/plugin-security/src/delegated-admin-gate.test.ts @@ -1103,6 +1103,7 @@ function makeOrganizationHarness() { }; const matches = (row: any, where: any): boolean => Object.entries(where ?? {}).every(([k, v]) => { + if (k.startsWith('$')) throw new Error(`fake driver: unsupported operator ${k}`); if (v && typeof v === 'object' && Array.isArray((v as any).$in)) return (v as any).$in.includes(row[k]); return row[k] === v; });