Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions .changeset/scoped-invitation-placement.md
Original file line number Diff line number Diff line change
@@ -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.
21 changes: 21 additions & 0 deletions packages/platform-objects/src/identity/sys-invitation.object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
Expand Down
155 changes: 155 additions & 0 deletions packages/plugins/plugin-auth/src/auth-manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down
135 changes: 130 additions & 5 deletions packages/plugins/plugin-auth/src/auth-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>;
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<string, unknown>;
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<AuthConfig> {
/**
* Better-Auth instance (for advanced use cases)
Expand Down Expand Up @@ -284,6 +333,20 @@ export interface AuthManagerOptions extends Partial<AuthConfig> {
* 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<InvitationPlacementServiceLike | undefined>;

onInvitationAccepted?: (data: {
invitationId?: string;
organizationId?: string;
Expand Down Expand Up @@ -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 {
Expand Down
Loading
Loading