Skip to main content

RBAC & permissions

Three roles, one shared statement, two files that must stay in sync.

Roles

RoleDB valueDefault?
Platform adminplatform_adminNo — promoted via seed or admin set-role
Org adminorg_adminNo
Org memberorg_memberYes — set in createAuth() via admin({ defaultRole: 'org_member' })

Permission statement

src/auth/permissions.ts:

export const statement = {
agent: ["create", "read", "update", "delete"],
organization: ["read", "update", "manage_members"],
audit_log: ["read"],
user: ["create", "list", "set-role", "ban", "impersonate", "delete", "set-password", "get", "update", "read"],
session: ["list", "revoke", "delete"],
} as const;
Roleagentorganizationaudit_logusersession
platform_adminallallreadallall
org_adminallreadget, read
org_memberreadread

How guards work

Each guard reads req.user.role (set by AuthMiddleware) and compares against an allow-list:

@Injectable()
export class PlatformAdminGuard implements CanActivate {
canActivate(ctx: ExecutionContext): boolean {
const req = ctx.switchToHttp().getRequest();
if (req.user?.role !== "platform_admin") {
throw new ForbiddenException({
code: "FORBIDDEN",
message: "Only platform admins can access this resource",
});
}
return true;
}
}

Guards do not call the DB. They trust the session payload. This is fast and correct as long as session tokens are short-lived enough that role changes propagate within the next token refresh.

Critical: statement must be exhaustive

better-auth's admin plugin uses hasPermission(role, resource, action) internally for the /api/auth/admin/* endpoints. If an action you call (e.g. user.list) is not in statement, hasPermission returns false — even for platform_admin. That'll surface as 403 on /api/auth/admin/list-users.

So: whenever you add a new better-auth admin plugin version or a new action, add it to statement even if you don't intend to gate it from a custom role. The platform admin role grants all statement actions, so listing keeps everything working.

Frontend parity

fleapo-marketplace/src/lib/permissions.ts must be an exact byte-for-byte copy of the backend file (modulo TypeScript imports). The admin SPA uses adminClient.checkRolePermission() to decide whether to render admin UI affordances. If the two files diverge, the UI shows actions the backend will then 403 on (or hides actions the backend would actually allow).

Discipline

  • Edit both files in the same PR.
  • Add a CI check that diffs statement between the two (a 10-line script).
  • Treat the backend as canonical; the frontend file is a mirror.

Adding a custom role

  1. Add it to the statement resources / accessControl config in src/auth/permissions.ts.
  2. Mirror to the frontend.
  3. Add a guard in src/auth/guards/.
  4. Use @UseGuards(YourGuard) on the controller methods that should require it.
  5. Run a migration if user.role is constrained by an enum.

Default role for sign-ups

admin({ defaultRole: "org_member" })

Set in auth.ts. New users land here. Promote via POST /api/auth/admin/set-role (platform_admin only).

Seed admin

seed.ts creates the initial platform_admin from ADMIN_EMAIL / ADMIN_PASSWORD env vars. Runs idempotently from docker-entrypoint.sh.