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 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/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..15377fb65f8 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,287 @@ 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 (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; + }); + 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 0ee08f5bdf8..cd73f6e5b21 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, @@ -49,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; /** @@ -262,6 +283,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 +397,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 +435,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 +542,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 +570,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 +620,29 @@ 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. 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) || isReservedIdentityName(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 +651,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/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); 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/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/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']); + }); + }); + } +}); 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 }, ); }