RBAC & permissions
Three roles, one shared statement, two files that must stay in sync.
Roles
| Role | DB value | Default? |
|---|---|---|
| Platform admin | platform_admin | No — promoted via seed or admin set-role |
| Org admin | org_admin | No |
| Org member | org_member | Yes — 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;
| Role | agent | organization | audit_log | user | session |
|---|---|---|---|---|---|
platform_admin | all | all | read | all | all |
org_admin | — | all | read | get, read | — |
org_member | — | read | read | — | — |
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
statementbetween the two (a 10-line script). - Treat the backend as canonical; the frontend file is a mirror.
Adding a custom role
- Add it to the
statementresources /accessControlconfig insrc/auth/permissions.ts. - Mirror to the frontend.
- Add a guard in
src/auth/guards/. - Use
@UseGuards(YourGuard)on the controller methods that should require it. - Run a migration if
user.roleis 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.