From fc4e07e1da0b3a4140ab601e4bfda55e794bcd59 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Jul 2026 13:19:39 +0000 Subject: [PATCH] =?UTF-8?q?feat(authz):=20scoped=20invitations=20=E2=80=94?= =?UTF-8?q?=20placement=20intent=20gated=20by=20the=20issuer's=20adminScop?= =?UTF-8?q?e,=20applied=20on=20acceptance=20(ADR-0105=20D8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An invitation may now carry PLACEMENT INTENT (target business unit + positions), so a delegated plant admin's invitee arrives already in the right unit and role instead of waiting on a platform admin. Closes the structural gap D8 names for single-posture deployments; the natural admission path under group. The two halves ship together, deliberately — a carrier without a gate would be an escalation hole. organization_admin is deliberately READ-ONLY on the RBAC tables (auto-org-admin-grant.ts) so a fresh org admin cannot rebind themselves; applying an unchecked invitation payload under system context would hand that authority straight back through the invitation surface. - Issuance authorized against the ISSUER's adminScope (ADR-0090 D12) by dry-running the existing DelegatedAdminGate against the very sys_user_position rows the acceptance would write. Reused verbatim: one copy of the subtree/allowlist logic, so the two can never drift. - Acceptance applies it under system context — idempotent (a replayed acceptance converges) and failure-isolated (a placement miss never undoes a valid membership; the host seam still fires). Surface: sys_invitation gains business_unit_id + positions (ADR-0092 extension fields, in the D7 collision-guarded whitelist, NOT generically editable); plugin-security registers the invitation-placement service; plugin-auth wires beforeCreateInvitation/afterAcceptInvitation to it and FAILS CLOSED when the runtime is absent. Verified: plugin-security 602, plugin-auth 528, platform-objects 215, spec 6659, core 386, runtime 654 — all green; five repo grep guards clean. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_015FebXPaaGrLhGKw1LHPbpL --- .changeset/scoped-invitation-placement.md | 45 ++++ .../src/identity/sys-invitation.object.ts | 21 ++ .../plugin-auth/src/auth-manager.test.ts | 155 ++++++++++++++ .../plugins/plugin-auth/src/auth-manager.ts | 135 +++++++++++- .../plugins/plugin-auth/src/auth-plugin.ts | 17 ++ .../plugin-auth/src/auth-schema-config.ts | 27 +++ .../src/managed-extension-fields.ts | 10 + packages/plugins/plugin-security/src/index.ts | 10 + .../src/invitation-placement.test.ts | 147 +++++++++++++ .../src/invitation-placement.ts | 193 ++++++++++++++++++ .../plugin-security/src/security-plugin.ts | 19 ++ 11 files changed, 774 insertions(+), 5 deletions(-) create mode 100644 .changeset/scoped-invitation-placement.md create mode 100644 packages/plugins/plugin-security/src/invitation-placement.test.ts create mode 100644 packages/plugins/plugin-security/src/invitation-placement.ts diff --git a/.changeset/scoped-invitation-placement.md b/.changeset/scoped-invitation-placement.md new file mode 100644 index 0000000000..a0fd178b04 --- /dev/null +++ b/.changeset/scoped-invitation-placement.md @@ -0,0 +1,45 @@ +--- +"@objectstack/platform-objects": minor +"@objectstack/plugin-auth": minor +"@objectstack/plugin-security": minor +--- + +feat(authz): scoped invitations — placement intent on an invitation, gated by +the issuer's adminScope and applied on acceptance (ADR-0105 D8) + +An invitation may now carry PLACEMENT INTENT — the business unit the invitee +lands in and the positions they are assigned — so a delegated (plant) admin's +invitee arrives already in the right unit and role instead of waiting on a +platform admin. This closes the structural gap ADR-0105 D8 names for +`single`-posture deployments and is the natural admission path under `group`. + +The two halves ship together, deliberately: + +- **Issuance is authorized** against the ISSUER's `adminScope` (ADR-0090 D12), + by dry-running the existing `DelegatedAdminGate` against the very + `sys_user_position` rows the acceptance would write. The gate is reused + verbatim — no second copy of the subtree/allowlist logic to drift — so an + invitation can never place what its issuer could not have assigned directly. + Without that gate the feature would be an escalation hole: the built-in + `organization_admin` is deliberately read-only on the RBAC tables precisely + so a fresh org admin cannot rebind themselves, and applying an unchecked + invitation payload under system context would hand that authority straight + back. +- **Acceptance applies it**, idempotently and failure-isolated: a replayed + acceptance converges instead of duplicating assignments, and a placement + miss never undoes a valid membership. + +Surface: + +- `sys_invitation` gains `business_unit_id` + `positions` (ADR-0092 extension + fields, registered in the D7 collision-guarded whitelist; NOT generically + editable — placement is set only at issuance, through the gate). +- `@objectstack/plugin-security` registers the `invitation-placement` service + (`assertIssuable` / `apply`). +- `@objectstack/plugin-auth` wires better-auth's `beforeCreateInvitation` / + `afterAcceptInvitation` to it. **Fail closed**: an invitation that requests + placement in a deployment without the delegated-administration runtime is + refused, never silently placed unchecked. + +Existing invitations are unaffected — an invitation without placement intent +never consults the gate and behaves exactly as before. diff --git a/packages/platform-objects/src/identity/sys-invitation.object.ts b/packages/platform-objects/src/identity/sys-invitation.object.ts index c08d033fa9..c9740b9ff0 100644 --- a/packages/platform-objects/src/identity/sys-invitation.object.ts +++ b/packages/platform-objects/src/identity/sys-invitation.object.ts @@ -230,6 +230,27 @@ export const SysInvitation = ObjectSchema.create({ required: false, description: 'Optional team to assign upon acceptance', }), + + // ── [ADR-0105 D8] Placement intent (ObjectStack extension fields) ── + // Carried on the invitation and applied WITH the better-auth membership + // on acceptance, so a delegated (plant) admin's invitee arrives already + // in the right unit and role instead of waiting on a platform admin. + // Issuance is authorized against the issuer's `adminScope` (ADR-0090 + // D12) by the `invitation-placement` service — an invitation can never + // place what its issuer could not have assigned directly. + business_unit_id: Field.lookup('sys_business_unit', { + label: 'Placement Business Unit', + required: false, + description: + 'Business unit the invitee is placed under on acceptance (ADR-0105 D8). Must lie inside the issuer\'s delegated subtree.', + }), + + positions: Field.json({ + label: 'Placement Positions', + required: false, + description: + 'sys_position names assigned on acceptance (ADR-0105 D8). Every position\'s permission sets must be allowlisted by the issuer\'s adminScope.', + }), }, indexes: [ diff --git a/packages/plugins/plugin-auth/src/auth-manager.test.ts b/packages/plugins/plugin-auth/src/auth-manager.test.ts index 88cd28639c..ed298b8bc0 100644 --- a/packages/plugins/plugin-auth/src/auth-manager.test.ts +++ b/packages/plugins/plugin-auth/src/auth-manager.test.ts @@ -706,6 +706,161 @@ describe('AuthManager', () => { ).resolves.toBeUndefined(); }); + // ── [ADR-0105 D8] Scoped invitations: issuance gate + accept-time apply ── + // + // The security property: authority is judged at ISSUANCE (actor = the + // inviter) and only the already-approved placement is applied at + // acceptance (actor = the invitee, who holds no RBAC-write authority). + const bootWithPlacement = async (placement?: any) => { + let capturedConfig: any; + (betterAuth as any).mockImplementation((config: any) => { + capturedConfig = config; + return { handler: vi.fn(), api: {} }; + }); + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const manager = new AuthManager({ + secret: 'test-secret-at-least-32-chars-long', + baseUrl: 'http://localhost:3000', + plugins: { organization: true }, + ...(placement !== undefined ? { invitationPlacement: async () => placement } : {}), + }); + await manager.getAuthInstance(); + const orgPlugin = capturedConfig.plugins.find((p: any) => p.id === 'organization'); + return { hooks: orgPlugin._opts.organizationHooks, warnSpy }; + }; + + const PLACEMENT_INVITE = { + id: 'inv-1', + organizationId: 'org-42', + email: 'p@x.test', + role: 'member', + businessUnitId: 'bu_plant_a', + positions: ['qc_inspector'], + }; + + it('issuance: authorizes placement intent against the ISSUER, not the invitee', async () => { + const assertIssuable = vi.fn(async () => {}); + const { hooks, warnSpy } = await bootWithPlacement({ assertIssuable, apply: vi.fn() }); + warnSpy.mockRestore(); + + await hooks.beforeCreateInvitation({ + invitation: PLACEMENT_INVITE, + inviter: { id: 'u_issuer' }, + organization: { id: 'org-42' }, + }); + + expect(assertIssuable).toHaveBeenCalledTimes(1); + expect(assertIssuable).toHaveBeenCalledWith({ + intent: { businessUnitId: 'bu_plant_a', positions: ['qc_inspector'] }, + actorContext: { userId: 'u_issuer', tenantId: 'org-42' }, + organizationId: 'org-42', + }); + }); + + it('issuance: a refused placement REJECTS the whole invitation (no row is created)', async () => { + const assertIssuable = vi.fn(async () => { + throw new Error("business unit 'bu_plant_b' is outside the delegated subtree"); + }); + const { hooks, warnSpy } = await bootWithPlacement({ assertIssuable, apply: vi.fn() }); + warnSpy.mockRestore(); + + await expect( + hooks.beforeCreateInvitation({ + invitation: PLACEMENT_INVITE, + inviter: { id: 'u_issuer' }, + organization: { id: 'org-42' }, + }), + ).rejects.toThrow(/outside the delegated subtree/); + }); + + it('issuance: NO placement runtime ⇒ a placement-carrying invitation is refused (fail closed)', async () => { + // Without plugin-security there is no gate; honouring the request would + // hand org_admin the RBAC-write authority it is deliberately denied. + const { hooks, warnSpy } = await bootWithPlacement(undefined); + warnSpy.mockRestore(); + + await expect( + hooks.beforeCreateInvitation({ + invitation: PLACEMENT_INVITE, + inviter: { id: 'u_issuer' }, + organization: { id: 'org-42' }, + }), + ).rejects.toThrow(/requires the delegated-administration runtime/); + }); + + it('issuance: an ordinary invitation (no placement) never consults the gate', async () => { + const assertIssuable = vi.fn(async () => {}); + const { hooks, warnSpy } = await bootWithPlacement({ assertIssuable, apply: vi.fn() }); + warnSpy.mockRestore(); + + await expect( + hooks.beforeCreateInvitation({ + invitation: { id: 'inv-2', organizationId: 'org-42', email: 'e@x.test', role: 'member' }, + inviter: { id: 'u_issuer' }, + organization: { id: 'org-42' }, + }), + ).resolves.toBeUndefined(); + expect(assertIssuable).not.toHaveBeenCalled(); + }); + + it('acceptance: applies the approved placement, stamping the issuer as grantor', async () => { + const apply = vi.fn(async () => ({ created: 1, skipped: 0 })); + const { hooks, warnSpy } = await bootWithPlacement({ assertIssuable: vi.fn(), apply }); + warnSpy.mockRestore(); + + await hooks.afterAcceptInvitation({ + invitation: { ...PLACEMENT_INVITE, inviterId: 'u_issuer' }, + member: { id: 'mem-9', userId: 'u-3', organizationId: 'org-42' }, + user: { id: 'u-3' }, + organization: { id: 'org-42' }, + }); + + expect(apply).toHaveBeenCalledWith({ + intent: { businessUnitId: 'bu_plant_a', positions: ['qc_inspector'] }, + userId: 'u-3', + organizationId: 'org-42', + grantedBy: 'u_issuer', + }); + }); + + it('acceptance: a placement failure keeps the membership and still fires the host seam', async () => { + const apply = vi.fn(async () => { + throw new Error('bu vanished'); + }); + const onInvitationAccepted = vi.fn(); + let capturedConfig: any; + (betterAuth as any).mockImplementation((config: any) => { + capturedConfig = config; + return { handler: vi.fn(), api: {} }; + }); + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const manager = new AuthManager({ + secret: 'test-secret-at-least-32-chars-long', + baseUrl: 'http://localhost:3000', + plugins: { organization: true }, + invitationPlacement: async () => ({ assertIssuable: vi.fn(), apply }) as never, + onInvitationAccepted, + }); + await manager.getAuthInstance(); + const orgPlugin = capturedConfig.plugins.find((p: any) => p.id === 'organization'); + + await expect( + orgPlugin._opts.organizationHooks.afterAcceptInvitation({ + invitation: PLACEMENT_INVITE, + member: { id: 'mem-9', userId: 'u-3' }, + user: { id: 'u-3' }, + organization: { id: 'org-42' }, + }), + ).resolves.toBeUndefined(); + + expect( + warnSpy.mock.calls.some((c) => String(c[0]).includes('invitation placement failed')), + ).toBe(true); + warnSpy.mockRestore(); + // The host seam is independent of placement — it still runs. + expect(onInvitationAccepted).toHaveBeenCalledTimes(1); + }); + it('should register twoFactor plugin with schema mapping when enabled', async () => { let capturedConfig: any; (betterAuth as any).mockImplementation((config: any) => { diff --git a/packages/plugins/plugin-auth/src/auth-manager.ts b/packages/plugins/plugin-auth/src/auth-manager.ts index f23200d1ed..e86dcdc26f 100644 --- a/packages/plugins/plugin-auth/src/auth-manager.ts +++ b/packages/plugins/plugin-auth/src/auth-manager.ts @@ -240,6 +240,55 @@ export function isOAuthEligibleBaseUrl(url: string): boolean { /** * Extended options for AuthManager */ +/** + * [ADR-0105 D8] The slice of `@objectstack/plugin-security`'s + * `invitation-placement` service plugin-auth depends on. Structural, so + * plugin-auth gains no dependency on plugin-security. + */ +export interface InvitationPlacementServiceLike { + assertIssuable(args: { + intent: { businessUnitId: string; positions: string[] }; + actorContext: unknown; + organizationId?: string | null; + }): Promise; + apply(args: { + intent: { businessUnitId: string; positions: string[] }; + userId: string; + organizationId?: string | null; + grantedBy?: string | null; + }): Promise<{ created: number; skipped: number }>; +} + +/** + * [ADR-0105 D8] Normalize placement intent off an invitation draft/row. + * Accepts better-auth's camelCase draft shape and the snake_case persisted + * row, and a `positions` value that survived a JSON round-trip as a string. + * Returns `null` when there is no usable intent — an invitation WITHOUT + * placement is the ordinary case, not an error. + */ +export function readInvitationPlacementIntent( + row: unknown, +): { businessUnitId: string; positions: string[] } | null { + const r = (row ?? {}) as Record; + const buRaw = r.businessUnitId ?? r.business_unit_id; + const businessUnitId = typeof buRaw === 'string' && buRaw !== '' ? buRaw : null; + + let posRaw: unknown = r.positions; + if (typeof posRaw === 'string') { + try { + posRaw = JSON.parse(posRaw); + } catch { + posRaw = [posRaw]; + } + } + const positions = Array.isArray(posRaw) + ? posRaw.filter((p): p is string => typeof p === 'string' && p !== '') + : []; + + if (!businessUnitId || positions.length === 0) return null; + return { businessUnitId, positions }; +} + export interface AuthManagerOptions extends Partial { /** * Better-Auth instance (for advanced use cases) @@ -284,6 +333,20 @@ export interface AuthManagerOptions extends Partial { * needs placement to be effectively atomic should make the callback * idempotent and reconcile on retry. */ + /** + * [ADR-0105 D8] Lazy accessor for the `invitation-placement` service + * (registered by `@objectstack/plugin-security`). Wired by `AuthPlugin`; + * lazy because plugin-security starts after plugin-auth. + * + * It carries the authority for scoped invitations: issuance authorizes the + * requested placement against the ISSUER's `adminScope`, acceptance applies + * it. Absent (an embedding without plugin-security) ⇒ an invitation that + * requests placement is REFUSED — there is no gate to check it against, and + * an unchecked placement would hand `organization_admin` the RBAC-write + * authority it is deliberately denied elsewhere. + */ + invitationPlacement?: () => Promise; + onInvitationAccepted?: (data: { invitationId?: string; organizationId?: string; @@ -1604,12 +1667,74 @@ export class AuthManager { console.warn('[auth] onOrganizationCreated callback failed:', err?.message ?? String(err)); } }, - // [ADR-0105 D8] Accept-time seam — the host applies the invitation's - // placement intent (BU membership + positions, extension fields on - // sys_invitation) as soon as the better-auth membership lands. Same - // shape and failure isolation as afterCreateOrganization above: - // acceptance never rolls back on a side-effect miss. + // [ADR-0105 D8] Issuance gate. A placement intent on an invitation + // is a REQUEST; the authority to honour it is the ISSUER's + // `adminScope` (ADR-0090 D12), judged HERE — at issuance, while the + // actor is the inviter — not at acceptance, when the actor is the + // invitee. Rejecting throws out of the endpoint, so no invitation + // row is created. + // + // Fail closed on a missing service: without plugin-security there + // is no gate, and honouring an unchecked placement would hand + // `organization_admin` the RBAC-write authority it is deliberately + // denied (auto-org-admin-grant.ts keeps it read-only on those + // tables precisely so a fresh org admin cannot rebind themselves). + beforeCreateInvitation: async ({ invitation, inviter, organization }: any) => { + const intent = readInvitationPlacementIntent(invitation); + if (!intent) return; + + const svc = await this.config.invitationPlacement?.().catch(() => undefined); + if (!svc) { + const { APIError } = await import('better-auth/api'); + throw new APIError('FORBIDDEN', { + message: + 'Invitation placement (business unit + positions) requires the delegated-administration runtime; this deployment cannot authorize it.', + }); + } + + const organizationId = organization?.id ?? invitation?.organizationId; + try { + await svc.assertIssuable({ + intent, + // The gate resolves the issuer's permission sets from this + // context, exactly as the CRUD middleware would. + actorContext: { userId: inviter?.id, tenantId: organizationId }, + organizationId, + }); + } catch (err: any) { + const { APIError } = await import('better-auth/api'); + throw new APIError('FORBIDDEN', { + message: err?.message ?? 'Invitation placement is outside your delegated administration scope.', + }); + } + }, + // [ADR-0105 D8] Accept-time application + host seam. The placement + // the issuance gate approved lands WITH the better-auth membership, + // so the invitee arrives already in the right unit and role. The + // apply is idempotent (a replayed acceptance converges) and + // failure-isolated — a placement miss must not undo a valid + // membership; the host seam still fires either way. afterAcceptInvitation: async ({ invitation, member, user, organization }: any) => { + const intent = readInvitationPlacementIntent(invitation); + if (intent) { + try { + const svc = await this.config.invitationPlacement?.(); + if (svc) { + await svc.apply({ + intent, + userId: String(user?.id ?? member?.userId), + organizationId: organization?.id ?? invitation?.organizationId ?? member?.organizationId, + grantedBy: invitation?.inviterId ?? invitation?.inviter_id ?? null, + }); + } + } catch (err: any) { + console.warn( + '[auth] invitation placement failed (membership kept):', + err?.message ?? String(err), + ); + } + } + const cb = this.config.onInvitationAccepted; if (typeof cb !== 'function') return; try { diff --git a/packages/plugins/plugin-auth/src/auth-plugin.ts b/packages/plugins/plugin-auth/src/auth-plugin.ts index e2c196f7d3..f8e3bf30ac 100644 --- a/packages/plugins/plugin-auth/src/auth-plugin.ts +++ b/packages/plugins/plugin-auth/src/auth-plugin.ts @@ -256,6 +256,23 @@ export class AuthPlugin implements Plugin { ? { ...authConfig.socialProviders } : undefined; + // [ADR-0105 D8] Lazy handle to plugin-security's `invitation-placement` + // service. LAZY on purpose: plugin-security starts after plugin-auth, so + // an eager lookup here would always miss and silently disable scoped + // invitations. Resolved per hook invocation instead; `undefined` (no + // plugin-security) makes a placement-carrying invitation fail closed. + authConfig.invitationPlacement = async () => { + try { + const svc = + (await (ctx as { getServiceAsync?: (n: string) => Promise }).getServiceAsync?.( + 'invitation-placement', + )) ?? ctx.getService?.('invitation-placement'); + return svc as never; + } catch { + return undefined; + } + }; + // Initialize auth manager with data engine this.authManager = new AuthManager(authConfig); // ADR-0092 D6 — remember the storage better-auth will actually use so the diff --git a/packages/plugins/plugin-auth/src/auth-schema-config.ts b/packages/plugins/plugin-auth/src/auth-schema-config.ts index 1bcf217c1a..ac7f92fe28 100644 --- a/packages/plugins/plugin-auth/src/auth-schema-config.ts +++ b/packages/plugins/plugin-auth/src/auth-schema-config.ts @@ -226,6 +226,33 @@ export const AUTH_INVITATION_SCHEMA = { createdAt: 'created_at', teamId: 'team_id', }, + /** + * [ADR-0105 D8] Placement intent rides the invitation better-auth already + * creates, so acceptance can apply it atomically with the membership + * (`additionalFields` is better-auth's own extension seam — no shadow + * table, no second write to keep in sync). + * + * These ARE client-suppliable (input stays on) because a placement intent + * is a REQUEST, not an authority: the `beforeCreateInvitation` hook runs + * unconditionally and authorizes the pair against the ISSUER's `adminScope` + * (ADR-0090 D12) via the `invitation-placement` service, rejecting the whole + * invitation when the unit is outside their subtree or a position + * distributes a set they may not hand out. Marking them `input: false` + * instead would not add safety — it would simply make the feature + * unreachable, since the hook has no other channel to receive the request. + */ + additionalFields: { + businessUnitId: { + type: 'string', + required: false, + fieldName: 'business_unit_id', + }, + positions: { + type: 'string[]', + required: false, + fieldName: 'positions', + }, + }, } as const; // --------------------------------------------------------------------------- diff --git a/packages/plugins/plugin-auth/src/managed-extension-fields.ts b/packages/plugins/plugin-auth/src/managed-extension-fields.ts index 32b88b115d..a11b6ffd4d 100644 --- a/packages/plugins/plugin-auth/src/managed-extension-fields.ts +++ b/packages/plugins/plugin-auth/src/managed-extension-fields.ts @@ -58,6 +58,16 @@ export const MANAGED_EXTENSION_FIELDS: Readonly {}); + const ql = overrides.ql ?? { + findOne: vi.fn(async () => null), + insert: vi.fn(async (_o: string, row: any) => ({ id: 'row', ...row })), + }; + const svc = createInvitationPlacementService({ ql, gate: { assert }, logger: { info: vi.fn() } }); + return { svc, assert, ql }; +} + +describe('readPlacementIntent', () => { + it('reads snake_case and camelCase rows alike', () => { + expect(readPlacementIntent({ business_unit_id: 'bu1', positions: ['a'] })).toEqual({ + businessUnitId: 'bu1', + positions: ['a'], + }); + expect(readPlacementIntent({ businessUnitId: 'bu1', positions: ['a'] })).toEqual({ + businessUnitId: 'bu1', + positions: ['a'], + }); + }); + + it('parses a positions value that round-tripped as JSON', () => { + expect(readPlacementIntent({ business_unit_id: 'bu1', positions: '["a","b"]' })?.positions).toEqual(['a', 'b']); + }); + + it('is null for an ordinary invitation — no BU, no positions, or neither', () => { + expect(readPlacementIntent({ business_unit_id: 'bu1' })).toBeNull(); + expect(readPlacementIntent({ positions: ['a'] })).toBeNull(); + expect(readPlacementIntent({})).toBeNull(); + expect(readPlacementIntent(null)).toBeNull(); + // Junk entries are dropped, and an all-junk list is no intent at all. + expect(readPlacementIntent({ business_unit_id: 'bu1', positions: ['', 7, null] })).toBeNull(); + }); +}); + +describe('assertIssuable — the gate decides, verbatim', () => { + it('dry-runs the gate against the sys_user_position rows the acceptance would write', async () => { + const { svc, assert } = makeService(); + const actorContext = { userId: 'u_issuer', tenantId: 'org_1' }; + + await svc.assertIssuable({ intent: INTENT, actorContext, organizationId: 'org_1' }); + + expect(assert).toHaveBeenCalledTimes(1); + const opCtx = assert.mock.calls[0][0]; + expect(opCtx.object).toBe('sys_user_position'); + expect(opCtx.operation).toBe('insert'); + expect(opCtx.context).toBe(actorContext); + // One row per requested position, each anchored to the target unit — the + // exact shape `assertAssignmentWrite` boundary-checks. + expect(opCtx.data).toEqual([ + { position: 'qc_inspector', business_unit_id: 'bu_plant_a', organization_id: 'org_1' }, + { position: 'line_lead', business_unit_id: 'bu_plant_a', organization_id: 'org_1' }, + ]); + }); + + it('propagates the gate refusal — an unauthorized placement is not swallowed', async () => { + const assert = vi.fn(async () => { + throw new Error("business unit 'bu_plant_b' is outside the delegated subtree"); + }); + const { svc } = makeService({ assert }); + await expect( + svc.assertIssuable({ intent: INTENT, actorContext: { userId: 'u' }, organizationId: 'org_1' }), + ).rejects.toThrow(/outside the delegated subtree/); + }); + + it('does not stamp organization_id when the deployment has no org context (single posture)', async () => { + const { svc, assert } = makeService(); + await svc.assertIssuable({ intent: INTENT, actorContext: { userId: 'u' }, organizationId: null }); + expect(assert.mock.calls[0][0].data[0]).toEqual({ + position: 'qc_inspector', + business_unit_id: 'bu_plant_a', + }); + }); +}); + +describe('apply — accept-time placement', () => { + it('creates one assignment per position, stamped with the issuer as granted_by', async () => { + const { svc, ql } = makeService(); + const result = await svc.apply({ + intent: INTENT, + userId: 'u_invitee', + organizationId: 'org_1', + grantedBy: 'u_issuer', + }); + + expect(result).toEqual({ created: 2, skipped: 0 }); + expect(ql.insert).toHaveBeenCalledTimes(2); + const [object, row, opts] = ql.insert.mock.calls[0]; + expect(object).toBe('sys_user_position'); + expect(row).toMatchObject({ + user_id: 'u_invitee', + position: 'qc_inspector', + business_unit_id: 'bu_plant_a', + organization_id: 'org_1', + granted_by: 'u_issuer', + }); + // Applied under system context: the acceptance actor is the INVITEE, who + // holds no RBAC-write authority. The authorization already happened at + // issuance, against the issuer. + expect(opts.context).toMatchObject({ isSystem: true }); + }); + + it('is idempotent — a replayed acceptance converges instead of duplicating', async () => { + const ql = { + findOne: vi.fn(async () => ({ id: 'existing' })), + insert: vi.fn(async () => ({ id: 'x' })), + }; + const { svc } = makeService({ ql }); + const result = await svc.apply({ intent: INTENT, userId: 'u_invitee', organizationId: 'org_1' }); + expect(result).toEqual({ created: 0, skipped: 2 }); + expect(ql.insert).not.toHaveBeenCalled(); + }); + + it('an unreadable pre-image does not block placement (treated as absent)', async () => { + const ql = { + findOne: vi.fn(async () => { + throw new Error('driver hiccup'); + }), + insert: vi.fn(async () => ({ id: 'x' })), + }; + const { svc } = makeService({ ql }); + const result = await svc.apply({ intent: INTENT, userId: 'u', organizationId: 'org_1' }); + expect(result.created).toBe(2); + }); +}); diff --git a/packages/plugins/plugin-security/src/invitation-placement.ts b/packages/plugins/plugin-security/src/invitation-placement.ts new file mode 100644 index 0000000000..8215a0f098 --- /dev/null +++ b/packages/plugins/plugin-security/src/invitation-placement.ts @@ -0,0 +1,193 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [ADR-0105 D8] Scoped-invitation placement — issuance authorization + accept-time application. + * + * An invitation may carry PLACEMENT INTENT: the business unit the invitee + * 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. + * 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. + * + * The authorization check REUSES `DelegatedAdminGate` verbatim rather than + * re-deriving subtree/allowlist logic: the issuance check is literally + * "would this actor be allowed to write these `sys_user_position` rows?", so + * it builds exactly that operation context and dry-runs the gate. Any future + * tightening of the gate tightens invitations in the same commit — the two + * can never drift, which is what ADR-0105 D8 means by "the existing + * anti-escalation gate, reused verbatim". + * + * Registered by `SecurityPlugin` as the `invitation-placement` service. + * plugin-auth's invitation hooks consume it and REFUSE placement intent when + * it is absent (an embedding without plugin-security has no gate, so it gets + * no placement — fail closed, never a silently unchecked assignment). + */ + +const SYSTEM_CTX = { isSystem: true } as const; + +/** The kernel service name plugin-auth probes. */ +export const INVITATION_PLACEMENT_SERVICE = 'invitation-placement'; + +/** Placement intent carried on a `sys_invitation` row (ADR-0092 extension fields). */ +export interface InvitationPlacementIntent { + /** Target `sys_business_unit.id` the invitee is placed under. */ + businessUnitId: string; + /** `sys_position.name` values assigned on acceptance. */ + positions: string[]; +} + +export interface InvitationPlacementService { + /** + * Authorize ISSUANCE. Throws (the gate's `PermissionDeniedError`) when the + * actor may not place into this unit / with these positions. + */ + assertIssuable(args: { + intent: InvitationPlacementIntent; + /** The ISSUER's execution context — authority is judged at issuance time. */ + actorContext: unknown; + organizationId?: string | null; + }): Promise; + + /** + * Apply placement for an accepted invitation. Idempotent: an assignment that + * already exists is left alone, so a retried/replayed acceptance converges + * instead of duplicating rows. + */ + apply(args: { + intent: InvitationPlacementIntent; + userId: string; + organizationId?: string | null; + /** Issuer id, stamped as `granted_by` for the audit trail. */ + grantedBy?: string | null; + }): Promise<{ created: number; skipped: number }>; +} + +/** + * Normalize placement intent off a raw `sys_invitation` row. + * + * `positions` round-trips as JSON through some drivers, so a string payload is + * parsed. Returns `null` when the row carries no usable intent — an invitation + * without placement is the ordinary case, not an error. + */ +export function readPlacementIntent(row: unknown): InvitationPlacementIntent | null { + const r = (row ?? {}) as Record; + const buRaw = r.business_unit_id ?? r.businessUnitId; + const businessUnitId = typeof buRaw === 'string' && buRaw !== '' ? buRaw : null; + + let posRaw: unknown = r.positions; + if (typeof posRaw === 'string') { + try { + posRaw = JSON.parse(posRaw); + } catch { + // A bare, non-JSON string is a single position name. + posRaw = [posRaw]; + } + } + const positions = Array.isArray(posRaw) + ? posRaw.filter((p): p is string => typeof p === 'string' && p !== '') + : []; + + if (!businessUnitId || positions.length === 0) return null; + return { businessUnitId, positions }; +} + +export interface InvitationPlacementDeps { + /** ObjectQL engine handle. */ + ql: any; + /** The live `DelegatedAdminGate` (ADR-0090 D12). */ + gate: { assert(opCtx: unknown): Promise }; + logger?: { info?: (msg: string, meta?: any) => void; warn?: (msg: string, meta?: any) => void }; +} + +export function createInvitationPlacementService( + deps: InvitationPlacementDeps, +): InvitationPlacementService { + const { ql, gate, logger } = deps; + + /** + * The `sys_user_position` rows this intent would create. Used BOTH as the + * gate's dry-run payload and as the apply payload, so authorization and + * effect are computed from one shape — an issuance check can never approve + * something different from what acceptance writes. + */ + const rowsFor = ( + intent: InvitationPlacementIntent, + extra: Record = {}, + ): Record[] => + intent.positions.map((position) => ({ + position, + business_unit_id: intent.businessUnitId, + ...extra, + })); + + return { + async assertIssuable({ intent, actorContext, organizationId }) { + // Dry-run the REAL gate against the REAL operation shape. No user_id is + // supplied — the invitee may not even have an account yet, and + // `assertAssignmentWrite` judges the unit + the positions' bound sets, + // never the target principal. + await gate.assert({ + object: 'sys_user_position', + operation: 'insert', + data: rowsFor(intent, organizationId ? { organization_id: organizationId } : {}), + context: actorContext, + }); + }, + + async apply({ intent, userId, organizationId, grantedBy }) { + let created = 0; + let skipped = 0; + for (const row of rowsFor(intent)) { + const where: Record = { + user_id: userId, + position: row.position, + business_unit_id: row.business_unit_id, + }; + if (organizationId) where.organization_id = organizationId; + + // Idempotence: the acceptance hook is failure-isolated and may be + // replayed, so an already-placed invitee must converge, not duplicate. + let existing: unknown; + try { + existing = await ql.findOne('sys_user_position', { where }, { context: SYSTEM_CTX }); + } catch { + existing = undefined; + } + if (existing) { + skipped++; + continue; + } + + await ql.insert( + 'sys_user_position', + { + user_id: userId, + ...row, + ...(organizationId ? { organization_id: organizationId } : {}), + ...(grantedBy ? { granted_by: grantedBy } : {}), + }, + { context: SYSTEM_CTX }, + ); + created++; + } + logger?.info?.('[security] invitation placement applied', { + userId, + businessUnitId: intent.businessUnitId, + created, + skipped, + }); + return { created, skipped }; + }, + }; +} diff --git a/packages/plugins/plugin-security/src/security-plugin.ts b/packages/plugins/plugin-security/src/security-plugin.ts index e7e997c0f1..829e46f7a7 100644 --- a/packages/plugins/plugin-security/src/security-plugin.ts +++ b/packages/plugins/plugin-security/src/security-plugin.ts @@ -6,6 +6,10 @@ import { describeHighPrivilegeBits, describeAnchorForbiddenBits, PUBLIC_FORM_SER import { MCP_AGENT_PERMISSION_SET_RESTRICTED } from '@objectstack/spec/ai'; import { PermissionEvaluator, crudBucketForOperation } from './permission-evaluator.js'; import { DelegatedAdminGate } from './delegated-admin-gate.js'; +import { + INVITATION_PLACEMENT_SERVICE, + createInvitationPlacementService, +} from './invitation-placement.js'; import { explainAccess, buildContextForUser, @@ -595,6 +599,21 @@ export class SecurityPlugin implements Plugin { logger: ctx.logger, }); + // [ADR-0105 D8] Scoped-invitation placement. Registered HERE because the + // authority it enforces is this plugin's: issuance dry-runs the very same + // DelegatedAdminGate above against the `sys_user_position` rows the + // acceptance would write, so an invitation can never place what its issuer + // could not have assigned directly. plugin-auth consumes the service and + // REFUSES placement intent when it is missing — no gate, no placement. + ctx.registerService( + INVITATION_PLACEMENT_SERVICE, + createInvitationPlacementService({ + ql, + gate: this.delegatedAdminGate, + logger: ctx.logger, + }), + ); + // ADR-0021 D-C — expose the per-request READ scope as a reusable service. // The analytics raw-SQL path (which bypasses this engine middleware) // auto-bridges to `getService('security').getReadFilter(object, context)`