Skip to content

Commit 4135b51

Browse files
hotlongCopilot
andcommitted
M10.2: user invite + custom org roles
Wire CRM profile names (sales_rep, sales_manager, service_agent, marketing_user, system_admin) through Better-Auth's organization plugin so /api/v1/auth/organization/invite-member accepts invitations to application-defined roles instead of rejecting them with ROLE_NOT_FOUND. Changes: - AuthManagerOptions / AuthPluginOptions gain additionalOrgRoles: string[] forwarded into better-auth's organization() roles map. - Each extra role is registered with defaultAc.newRole(memberAc.statements) so it satisfies better-auth's access-control validator with the same minimum capabilities as 'member'. Real RBAC enforcement remains in ObjectStack's SecurityPlugin, which matches role names against 'permission' metadata (PermissionSets). - Critical: re-include better-auth's built-in defaultRoles when populating roles, because hasPermission() does '{...options.roles || defaultRoles}' (|| then spread) and would otherwise drop owner/admin/member entirely, breaking the inviter. - Default requireEmailVerificationOnInvitation:false since framework ships no mailer; operators wiring real email can opt back in. - CLI serve.ts auto-collects roles from stack config: top-level roles[] and permissions[] with isProfile=true. Built-in owner/admin/member are filtered out. Verified end-to-end with curl: - POST invite-member role=sales_rep -> 200 sys_invitation row - POST invite-member role=service_agent -> 200 - POST invite-member role=hacker -> 400 ROLE_NOT_FOUND: hacker - sign-up + accept-invitation -> 200 sys_member.role=sales_rep - sales_rep query against /api/v1/lead honours sharing rules (0 rows) - admin retains owner privileges (no FORBIDDEN regression) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent bd5490d commit 4135b51

3 files changed

Lines changed: 105 additions & 0 deletions

File tree

packages/cli/src/commands/serve.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -671,11 +671,43 @@ export default class Serve extends Command {
671671
if (!trustedOrigins.includes(wildcard)) trustedOrigins.push(wildcard);
672672
}
673673

674+
// Collect application-defined org roles from the stack so
675+
// Better-Auth's organization plugin accepts invitations to
676+
// those roles (otherwise it 400s with `ROLE_NOT_FOUND`).
677+
// Sources:
678+
// - top-level `roles[]` (role hierarchy entries)
679+
// - `permissions[]` PermissionSets where `isProfile === true`
680+
// (these double as role identifiers; e.g. CRM Profiles)
681+
// Real RBAC enforcement is still owned by SecurityPlugin, which
682+
// matches these names against `permission` metadata entries.
683+
const additionalOrgRoles = new Set<string>();
684+
try {
685+
const stackAny: any = config ?? {};
686+
const collect = (arr: any) => {
687+
if (!Array.isArray(arr)) return;
688+
for (const r of arr) {
689+
const n = typeof r === 'string' ? r : (r && typeof r.name === 'string' ? r.name : null);
690+
if (n && n !== 'owner' && n !== 'admin' && n !== 'member') additionalOrgRoles.add(n);
691+
}
692+
};
693+
collect(stackAny.roles);
694+
if (Array.isArray(stackAny.permissions)) {
695+
for (const p of stackAny.permissions) {
696+
if (p && typeof p.name === 'string' && p.isProfile !== false) {
697+
if (p.name !== 'owner' && p.name !== 'admin' && p.name !== 'member') additionalOrgRoles.add(p.name);
698+
}
699+
}
700+
}
701+
} catch {
702+
// best-effort
703+
}
704+
674705
await kernel.use(new AuthPlugin({
675706
secret,
676707
baseUrl,
677708
socialProviders: Object.keys(socialProviders).length > 0 ? socialProviders : undefined,
678709
trustedOrigins: trustedOrigins.length ? trustedOrigins : undefined,
710+
...(additionalOrgRoles.size > 0 ? { additionalOrgRoles: Array.from(additionalOrgRoles) } : {}),
679711
advanced: process.env.OS_COOKIE_DOMAIN
680712
? ({
681713
crossSubDomainCookies: {

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

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,26 @@ export interface AuthManagerOptions extends Partial<AuthConfig> {
5353
* Each entry is passed to better-auth's genericOAuth plugin.
5454
*/
5555
oidcProviders?: OidcProvidersConfig;
56+
57+
/**
58+
* Application-specific organization roles to register with Better-Auth's
59+
* organization plugin. Each name becomes a valid role for invitations and
60+
* member assignments without going through Better-Auth's default
61+
* `owner|admin|member` whitelist.
62+
*
63+
* The ObjectStack SecurityPlugin handles real RBAC enforcement by matching
64+
* these role names against `permission` metadata (PermissionSets / Profiles),
65+
* so Better-Auth only needs to accept them as opaque strings. Each role is
66+
* registered with the minimum access-control privileges (equivalent to
67+
* Better-Auth's `member` role) so it cannot inadvertently grant org-level
68+
* admin capabilities.
69+
*
70+
* Typical source: the union of `permission` metadata names that have
71+
* `isProfile: true`, collected from the loaded stack at CLI boot.
72+
*
73+
* @example ['sales_rep', 'sales_manager', 'service_agent']
74+
*/
75+
additionalOrgRoles?: string[];
5676
}
5777

5878
/**
@@ -248,6 +268,44 @@ export class AuthManager {
248268

249269
if (enabled.organization) {
250270
const { organization } = await import('better-auth/plugins/organization');
271+
// Build a `roles` map that registers each app-supplied org role
272+
// (e.g. CRM's sales_rep, sales_manager) as a valid Better-Auth role
273+
// so invitations to those roles aren't rejected with ROLE_NOT_FOUND.
274+
// Real RBAC enforcement is handled by ObjectStack's SecurityPlugin,
275+
// which matches the role name against `permission` metadata
276+
// (PermissionSets). Here we register them with minimum org-plugin
277+
// capabilities (same as the built-in `member` role) so they cannot
278+
// inadvertently grant org-level admin powers.
279+
let customOrgRoles: Record<string, any> | undefined;
280+
const extra = this.config.additionalOrgRoles;
281+
if (extra && extra.length > 0) {
282+
try {
283+
const accessMod = await import('better-auth/plugins/organization/access');
284+
const stmtMod = await import('better-auth/plugins/organization/access/statement' as any).catch(() => null as any);
285+
const { defaultAc, memberAc } = accessMod as any;
286+
// Better-Auth's `hasPermission` does `{...options.roles || defaultRoles}`
287+
// (precedence: `||` then spread). When we pass our own `roles`, the
288+
// built-in owner/admin/member are silently dropped, so even the org
289+
// owner loses `invitation:create` and every mutation 403s. We must
290+
// re-include the defaults alongside our extras.
291+
const defaultRoles =
292+
(stmtMod && (stmtMod as any).defaultRoles) ||
293+
(accessMod as any).defaultRoles ||
294+
null;
295+
if (defaultAc && memberAc && typeof memberAc.statements === 'object') {
296+
const built: Record<string, any> = defaultRoles ? { ...defaultRoles } : {};
297+
const stmts = memberAc.statements;
298+
for (const name of extra) {
299+
if (!name) continue;
300+
if (built[name]) continue;
301+
built[name] = defaultAc.newRole(stmts);
302+
}
303+
customOrgRoles = built;
304+
}
305+
} catch {
306+
customOrgRoles = undefined;
307+
}
308+
}
251309
plugins.push(organization({
252310
schema: buildOrganizationPluginSchema(),
253311
// Enable the team sub-feature so the framework's `sys_team` /
@@ -258,6 +316,13 @@ export class AuthManager {
258316
// portal exposes a Teams page; without this flag those endpoints
259317
// 404 and the section silently breaks.
260318
teams: { enabled: true },
319+
// Without a mailer wired in framework, requiring email verification
320+
// before accepting invitations dead-ends every invite flow with
321+
// FORBIDDEN EMAIL_VERIFICATION_REQUIRED…. Default-off here keeps
322+
// the built-in /accept-invitation route usable for pilots; operators
323+
// who wire a real mailer can re-enable downstream.
324+
requireEmailVerificationOnInvitation: false,
325+
...(customOrgRoles ? { roles: customOrgRoles } : {}),
261326
// No mailer is wired in framework yet — log the accept URL so
262327
// operators / UI can fall back to copy-paste flows. Replace this
263328
// with a real mail integration when available.

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

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,14 @@ export interface AuthPluginOptions extends Partial<AuthConfig> {
4646
* project's own database — each project owns its own users.
4747
*/
4848
manifestDatasource?: string;
49+
50+
/**
51+
* Application-specific organization roles to register with Better-Auth's
52+
* organization plugin so invitations to those roles aren't rejected with
53+
* ROLE_NOT_FOUND. Forwarded as-is to AuthManager. See
54+
* {@link AuthManagerOptions.additionalOrgRoles} for details.
55+
*/
56+
additionalOrgRoles?: string[];
4957
}
5058

5159
/**

0 commit comments

Comments
 (0)