Skip to content

Commit aa8b847

Browse files
os-zhuangclaude
andauthored
feat(authz): scoped invitations — placement intent gated by the issuer's adminScope, applied on acceptance (ADR-0105 D8) (#3663)
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. Claude-Session: https://claude.ai/code/session_015FebXPaaGrLhGKw1LHPbpL Co-authored-by: Claude <noreply@anthropic.com>
1 parent 1b717e5 commit aa8b847

11 files changed

Lines changed: 774 additions & 5 deletions

File tree

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
---
2+
"@objectstack/platform-objects": minor
3+
"@objectstack/plugin-auth": minor
4+
"@objectstack/plugin-security": minor
5+
---
6+
7+
feat(authz): scoped invitations — placement intent on an invitation, gated by
8+
the issuer's adminScope and applied on acceptance (ADR-0105 D8)
9+
10+
An invitation may now carry PLACEMENT INTENT — the business unit the invitee
11+
lands in and the positions they are assigned — so a delegated (plant) admin's
12+
invitee arrives already in the right unit and role instead of waiting on a
13+
platform admin. This closes the structural gap ADR-0105 D8 names for
14+
`single`-posture deployments and is the natural admission path under `group`.
15+
16+
The two halves ship together, deliberately:
17+
18+
- **Issuance is authorized** against the ISSUER's `adminScope` (ADR-0090 D12),
19+
by dry-running the existing `DelegatedAdminGate` against the very
20+
`sys_user_position` rows the acceptance would write. The gate is reused
21+
verbatim — no second copy of the subtree/allowlist logic to drift — so an
22+
invitation can never place what its issuer could not have assigned directly.
23+
Without that gate the feature would be an escalation hole: the built-in
24+
`organization_admin` is deliberately read-only on the RBAC tables precisely
25+
so a fresh org admin cannot rebind themselves, and applying an unchecked
26+
invitation payload under system context would hand that authority straight
27+
back.
28+
- **Acceptance applies it**, idempotently and failure-isolated: a replayed
29+
acceptance converges instead of duplicating assignments, and a placement
30+
miss never undoes a valid membership.
31+
32+
Surface:
33+
34+
- `sys_invitation` gains `business_unit_id` + `positions` (ADR-0092 extension
35+
fields, registered in the D7 collision-guarded whitelist; NOT generically
36+
editable — placement is set only at issuance, through the gate).
37+
- `@objectstack/plugin-security` registers the `invitation-placement` service
38+
(`assertIssuable` / `apply`).
39+
- `@objectstack/plugin-auth` wires better-auth's `beforeCreateInvitation` /
40+
`afterAcceptInvitation` to it. **Fail closed**: an invitation that requests
41+
placement in a deployment without the delegated-administration runtime is
42+
refused, never silently placed unchecked.
43+
44+
Existing invitations are unaffected — an invitation without placement intent
45+
never consults the gate and behaves exactly as before.

packages/platform-objects/src/identity/sys-invitation.object.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -230,6 +230,27 @@ export const SysInvitation = ObjectSchema.create({
230230
required: false,
231231
description: 'Optional team to assign upon acceptance',
232232
}),
233+
234+
// ── [ADR-0105 D8] Placement intent (ObjectStack extension fields) ──
235+
// Carried on the invitation and applied WITH the better-auth membership
236+
// on acceptance, so a delegated (plant) admin's invitee arrives already
237+
// in the right unit and role instead of waiting on a platform admin.
238+
// Issuance is authorized against the issuer's `adminScope` (ADR-0090
239+
// D12) by the `invitation-placement` service — an invitation can never
240+
// place what its issuer could not have assigned directly.
241+
business_unit_id: Field.lookup('sys_business_unit', {
242+
label: 'Placement Business Unit',
243+
required: false,
244+
description:
245+
'Business unit the invitee is placed under on acceptance (ADR-0105 D8). Must lie inside the issuer\'s delegated subtree.',
246+
}),
247+
248+
positions: Field.json({
249+
label: 'Placement Positions',
250+
required: false,
251+
description:
252+
'sys_position names assigned on acceptance (ADR-0105 D8). Every position\'s permission sets must be allowlisted by the issuer\'s adminScope.',
253+
}),
233254
},
234255

235256
indexes: [

packages/plugins/plugin-auth/src/auth-manager.test.ts

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -706,6 +706,161 @@ describe('AuthManager', () => {
706706
).resolves.toBeUndefined();
707707
});
708708

709+
// ── [ADR-0105 D8] Scoped invitations: issuance gate + accept-time apply ──
710+
//
711+
// The security property: authority is judged at ISSUANCE (actor = the
712+
// inviter) and only the already-approved placement is applied at
713+
// acceptance (actor = the invitee, who holds no RBAC-write authority).
714+
const bootWithPlacement = async (placement?: any) => {
715+
let capturedConfig: any;
716+
(betterAuth as any).mockImplementation((config: any) => {
717+
capturedConfig = config;
718+
return { handler: vi.fn(), api: {} };
719+
});
720+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
721+
const manager = new AuthManager({
722+
secret: 'test-secret-at-least-32-chars-long',
723+
baseUrl: 'http://localhost:3000',
724+
plugins: { organization: true },
725+
...(placement !== undefined ? { invitationPlacement: async () => placement } : {}),
726+
});
727+
await manager.getAuthInstance();
728+
const orgPlugin = capturedConfig.plugins.find((p: any) => p.id === 'organization');
729+
return { hooks: orgPlugin._opts.organizationHooks, warnSpy };
730+
};
731+
732+
const PLACEMENT_INVITE = {
733+
id: 'inv-1',
734+
organizationId: 'org-42',
735+
email: 'p@x.test',
736+
role: 'member',
737+
businessUnitId: 'bu_plant_a',
738+
positions: ['qc_inspector'],
739+
};
740+
741+
it('issuance: authorizes placement intent against the ISSUER, not the invitee', async () => {
742+
const assertIssuable = vi.fn(async () => {});
743+
const { hooks, warnSpy } = await bootWithPlacement({ assertIssuable, apply: vi.fn() });
744+
warnSpy.mockRestore();
745+
746+
await hooks.beforeCreateInvitation({
747+
invitation: PLACEMENT_INVITE,
748+
inviter: { id: 'u_issuer' },
749+
organization: { id: 'org-42' },
750+
});
751+
752+
expect(assertIssuable).toHaveBeenCalledTimes(1);
753+
expect(assertIssuable).toHaveBeenCalledWith({
754+
intent: { businessUnitId: 'bu_plant_a', positions: ['qc_inspector'] },
755+
actorContext: { userId: 'u_issuer', tenantId: 'org-42' },
756+
organizationId: 'org-42',
757+
});
758+
});
759+
760+
it('issuance: a refused placement REJECTS the whole invitation (no row is created)', async () => {
761+
const assertIssuable = vi.fn(async () => {
762+
throw new Error("business unit 'bu_plant_b' is outside the delegated subtree");
763+
});
764+
const { hooks, warnSpy } = await bootWithPlacement({ assertIssuable, apply: vi.fn() });
765+
warnSpy.mockRestore();
766+
767+
await expect(
768+
hooks.beforeCreateInvitation({
769+
invitation: PLACEMENT_INVITE,
770+
inviter: { id: 'u_issuer' },
771+
organization: { id: 'org-42' },
772+
}),
773+
).rejects.toThrow(/outside the delegated subtree/);
774+
});
775+
776+
it('issuance: NO placement runtime ⇒ a placement-carrying invitation is refused (fail closed)', async () => {
777+
// Without plugin-security there is no gate; honouring the request would
778+
// hand org_admin the RBAC-write authority it is deliberately denied.
779+
const { hooks, warnSpy } = await bootWithPlacement(undefined);
780+
warnSpy.mockRestore();
781+
782+
await expect(
783+
hooks.beforeCreateInvitation({
784+
invitation: PLACEMENT_INVITE,
785+
inviter: { id: 'u_issuer' },
786+
organization: { id: 'org-42' },
787+
}),
788+
).rejects.toThrow(/requires the delegated-administration runtime/);
789+
});
790+
791+
it('issuance: an ordinary invitation (no placement) never consults the gate', async () => {
792+
const assertIssuable = vi.fn(async () => {});
793+
const { hooks, warnSpy } = await bootWithPlacement({ assertIssuable, apply: vi.fn() });
794+
warnSpy.mockRestore();
795+
796+
await expect(
797+
hooks.beforeCreateInvitation({
798+
invitation: { id: 'inv-2', organizationId: 'org-42', email: 'e@x.test', role: 'member' },
799+
inviter: { id: 'u_issuer' },
800+
organization: { id: 'org-42' },
801+
}),
802+
).resolves.toBeUndefined();
803+
expect(assertIssuable).not.toHaveBeenCalled();
804+
});
805+
806+
it('acceptance: applies the approved placement, stamping the issuer as grantor', async () => {
807+
const apply = vi.fn(async () => ({ created: 1, skipped: 0 }));
808+
const { hooks, warnSpy } = await bootWithPlacement({ assertIssuable: vi.fn(), apply });
809+
warnSpy.mockRestore();
810+
811+
await hooks.afterAcceptInvitation({
812+
invitation: { ...PLACEMENT_INVITE, inviterId: 'u_issuer' },
813+
member: { id: 'mem-9', userId: 'u-3', organizationId: 'org-42' },
814+
user: { id: 'u-3' },
815+
organization: { id: 'org-42' },
816+
});
817+
818+
expect(apply).toHaveBeenCalledWith({
819+
intent: { businessUnitId: 'bu_plant_a', positions: ['qc_inspector'] },
820+
userId: 'u-3',
821+
organizationId: 'org-42',
822+
grantedBy: 'u_issuer',
823+
});
824+
});
825+
826+
it('acceptance: a placement failure keeps the membership and still fires the host seam', async () => {
827+
const apply = vi.fn(async () => {
828+
throw new Error('bu vanished');
829+
});
830+
const onInvitationAccepted = vi.fn();
831+
let capturedConfig: any;
832+
(betterAuth as any).mockImplementation((config: any) => {
833+
capturedConfig = config;
834+
return { handler: vi.fn(), api: {} };
835+
});
836+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
837+
const manager = new AuthManager({
838+
secret: 'test-secret-at-least-32-chars-long',
839+
baseUrl: 'http://localhost:3000',
840+
plugins: { organization: true },
841+
invitationPlacement: async () => ({ assertIssuable: vi.fn(), apply }) as never,
842+
onInvitationAccepted,
843+
});
844+
await manager.getAuthInstance();
845+
const orgPlugin = capturedConfig.plugins.find((p: any) => p.id === 'organization');
846+
847+
await expect(
848+
orgPlugin._opts.organizationHooks.afterAcceptInvitation({
849+
invitation: PLACEMENT_INVITE,
850+
member: { id: 'mem-9', userId: 'u-3' },
851+
user: { id: 'u-3' },
852+
organization: { id: 'org-42' },
853+
}),
854+
).resolves.toBeUndefined();
855+
856+
expect(
857+
warnSpy.mock.calls.some((c) => String(c[0]).includes('invitation placement failed')),
858+
).toBe(true);
859+
warnSpy.mockRestore();
860+
// The host seam is independent of placement — it still runs.
861+
expect(onInvitationAccepted).toHaveBeenCalledTimes(1);
862+
});
863+
709864
it('should register twoFactor plugin with schema mapping when enabled', async () => {
710865
let capturedConfig: any;
711866
(betterAuth as any).mockImplementation((config: any) => {

packages/plugins/plugin-auth/src/auth-manager.ts

Lines changed: 130 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -240,6 +240,55 @@ export function isOAuthEligibleBaseUrl(url: string): boolean {
240240
/**
241241
* Extended options for AuthManager
242242
*/
243+
/**
244+
* [ADR-0105 D8] The slice of `@objectstack/plugin-security`'s
245+
* `invitation-placement` service plugin-auth depends on. Structural, so
246+
* plugin-auth gains no dependency on plugin-security.
247+
*/
248+
export interface InvitationPlacementServiceLike {
249+
assertIssuable(args: {
250+
intent: { businessUnitId: string; positions: string[] };
251+
actorContext: unknown;
252+
organizationId?: string | null;
253+
}): Promise<void>;
254+
apply(args: {
255+
intent: { businessUnitId: string; positions: string[] };
256+
userId: string;
257+
organizationId?: string | null;
258+
grantedBy?: string | null;
259+
}): Promise<{ created: number; skipped: number }>;
260+
}
261+
262+
/**
263+
* [ADR-0105 D8] Normalize placement intent off an invitation draft/row.
264+
* Accepts better-auth's camelCase draft shape and the snake_case persisted
265+
* row, and a `positions` value that survived a JSON round-trip as a string.
266+
* Returns `null` when there is no usable intent — an invitation WITHOUT
267+
* placement is the ordinary case, not an error.
268+
*/
269+
export function readInvitationPlacementIntent(
270+
row: unknown,
271+
): { businessUnitId: string; positions: string[] } | null {
272+
const r = (row ?? {}) as Record<string, unknown>;
273+
const buRaw = r.businessUnitId ?? r.business_unit_id;
274+
const businessUnitId = typeof buRaw === 'string' && buRaw !== '' ? buRaw : null;
275+
276+
let posRaw: unknown = r.positions;
277+
if (typeof posRaw === 'string') {
278+
try {
279+
posRaw = JSON.parse(posRaw);
280+
} catch {
281+
posRaw = [posRaw];
282+
}
283+
}
284+
const positions = Array.isArray(posRaw)
285+
? posRaw.filter((p): p is string => typeof p === 'string' && p !== '')
286+
: [];
287+
288+
if (!businessUnitId || positions.length === 0) return null;
289+
return { businessUnitId, positions };
290+
}
291+
243292
export interface AuthManagerOptions extends Partial<AuthConfig> {
244293
/**
245294
* Better-Auth instance (for advanced use cases)
@@ -284,6 +333,20 @@ export interface AuthManagerOptions extends Partial<AuthConfig> {
284333
* needs placement to be effectively atomic should make the callback
285334
* idempotent and reconcile on retry.
286335
*/
336+
/**
337+
* [ADR-0105 D8] Lazy accessor for the `invitation-placement` service
338+
* (registered by `@objectstack/plugin-security`). Wired by `AuthPlugin`;
339+
* lazy because plugin-security starts after plugin-auth.
340+
*
341+
* It carries the authority for scoped invitations: issuance authorizes the
342+
* requested placement against the ISSUER's `adminScope`, acceptance applies
343+
* it. Absent (an embedding without plugin-security) ⇒ an invitation that
344+
* requests placement is REFUSED — there is no gate to check it against, and
345+
* an unchecked placement would hand `organization_admin` the RBAC-write
346+
* authority it is deliberately denied elsewhere.
347+
*/
348+
invitationPlacement?: () => Promise<InvitationPlacementServiceLike | undefined>;
349+
287350
onInvitationAccepted?: (data: {
288351
invitationId?: string;
289352
organizationId?: string;
@@ -1604,12 +1667,74 @@ export class AuthManager {
16041667
console.warn('[auth] onOrganizationCreated callback failed:', err?.message ?? String(err));
16051668
}
16061669
},
1607-
// [ADR-0105 D8] Accept-time seam — the host applies the invitation's
1608-
// placement intent (BU membership + positions, extension fields on
1609-
// sys_invitation) as soon as the better-auth membership lands. Same
1610-
// shape and failure isolation as afterCreateOrganization above:
1611-
// acceptance never rolls back on a side-effect miss.
1670+
// [ADR-0105 D8] Issuance gate. A placement intent on an invitation
1671+
// is a REQUEST; the authority to honour it is the ISSUER's
1672+
// `adminScope` (ADR-0090 D12), judged HERE — at issuance, while the
1673+
// actor is the inviter — not at acceptance, when the actor is the
1674+
// invitee. Rejecting throws out of the endpoint, so no invitation
1675+
// row is created.
1676+
//
1677+
// Fail closed on a missing service: without plugin-security there
1678+
// is no gate, and honouring an unchecked placement would hand
1679+
// `organization_admin` the RBAC-write authority it is deliberately
1680+
// denied (auto-org-admin-grant.ts keeps it read-only on those
1681+
// tables precisely so a fresh org admin cannot rebind themselves).
1682+
beforeCreateInvitation: async ({ invitation, inviter, organization }: any) => {
1683+
const intent = readInvitationPlacementIntent(invitation);
1684+
if (!intent) return;
1685+
1686+
const svc = await this.config.invitationPlacement?.().catch(() => undefined);
1687+
if (!svc) {
1688+
const { APIError } = await import('better-auth/api');
1689+
throw new APIError('FORBIDDEN', {
1690+
message:
1691+
'Invitation placement (business unit + positions) requires the delegated-administration runtime; this deployment cannot authorize it.',
1692+
});
1693+
}
1694+
1695+
const organizationId = organization?.id ?? invitation?.organizationId;
1696+
try {
1697+
await svc.assertIssuable({
1698+
intent,
1699+
// The gate resolves the issuer's permission sets from this
1700+
// context, exactly as the CRUD middleware would.
1701+
actorContext: { userId: inviter?.id, tenantId: organizationId },
1702+
organizationId,
1703+
});
1704+
} catch (err: any) {
1705+
const { APIError } = await import('better-auth/api');
1706+
throw new APIError('FORBIDDEN', {
1707+
message: err?.message ?? 'Invitation placement is outside your delegated administration scope.',
1708+
});
1709+
}
1710+
},
1711+
// [ADR-0105 D8] Accept-time application + host seam. The placement
1712+
// the issuance gate approved lands WITH the better-auth membership,
1713+
// so the invitee arrives already in the right unit and role. The
1714+
// apply is idempotent (a replayed acceptance converges) and
1715+
// failure-isolated — a placement miss must not undo a valid
1716+
// membership; the host seam still fires either way.
16121717
afterAcceptInvitation: async ({ invitation, member, user, organization }: any) => {
1718+
const intent = readInvitationPlacementIntent(invitation);
1719+
if (intent) {
1720+
try {
1721+
const svc = await this.config.invitationPlacement?.();
1722+
if (svc) {
1723+
await svc.apply({
1724+
intent,
1725+
userId: String(user?.id ?? member?.userId),
1726+
organizationId: organization?.id ?? invitation?.organizationId ?? member?.organizationId,
1727+
grantedBy: invitation?.inviterId ?? invitation?.inviter_id ?? null,
1728+
});
1729+
}
1730+
} catch (err: any) {
1731+
console.warn(
1732+
'[auth] invitation placement failed (membership kept):',
1733+
err?.message ?? String(err),
1734+
);
1735+
}
1736+
}
1737+
16131738
const cb = this.config.onInvitationAccepted;
16141739
if (typeof cb !== 'function') return;
16151740
try {

0 commit comments

Comments
 (0)