Admin RBAC Module
HTTP surface for managing platform-admin roles (custom role definitions with a permission map) and reading the catalog of available permissions. Also exports the PermissionsGuard…
HTTP surface for managing platform-admin roles (custom role definitions with a permission map) and reading the catalog of available permissions. Also exports the PermissionsGuard and @RequirePermissions(...) decorator that every admin endpoint across the platform uses to gate access.
Source:
api-modules/admin-rbac(registered viaAdminRbacModule.forRoot()inapps/api/src/app.module.ts).RBAC works on platform admins only — vendor-side scoping uses
resolveActiveVendorId(session)(active organization). Built-in admin roles (superAdmin,admin) live in code; this module'sroletable holds dynamic roles administrators create at runtime.
Conventions
Authentication
All endpoints require a Better-Auth session and a role with the matching role:* permission.
| Endpoint group | Permission |
|---|---|
GET /admin/rbac/roles, GET /admin/rbac/roles/:id | role: read |
POST /admin/rbac/roles | role: create |
PUT /admin/rbac/roles/:id | role: update |
DELETE /admin/rbac/roles/:id | role: delete |
GET /admin/rbac/permissions | any platform admin (no per-resource permission — listing the catalog is read-only metadata) |
GET /admin/rbac/me/permissions | any platform admin (no per-resource permission — your own effective grants) |
GET /admin/staff | user: list |
POST /admin/staff | user: create |
POST /admin/staff/:id/ban, POST /admin/staff/:id/unban | user: ban |
POST /admin/staff/:id/set-role | user: set-role |
Every @RequirePermissions endpoint additionally requires the session to be a staff/admin session (a built-in admin/superAdmin role or a dynamicRole); a customer/vendor session is rejected 403 even if it somehow held a matching grant. This is defense-in-depth on top of the catalog check.
Response envelope
Successful responses are wrapped by ResponseInterceptor:
{
"data": <payload>,
"message": "Success",
"statusCode": 200,
"metadata": { /* optional, e.g. pagination */ }
}Error envelope
statusCode | errorCode examples |
|---|---|
| 400 | BAD_REQUEST, VALIDATION_ERROR |
| 401 | UNAUTHORIZED |
| 403 | FORBIDDEN |
| 404 | NOT_FOUND |
| 409 | UNIQUE_VIOLATION (role name taken) |
| 500 | INTERNAL_SERVER_ERROR, DATABASE_ERROR |
How permissions work
A permission requirement is a record from resource → array of allowed actions. A user satisfies it if any of their assigned roles grants every listed action on every listed resource.
Controller-side, the requirement is declared via @RequirePermissions(...):
import { PermissionsGuard, RequirePermissions } from "@sc/admin-rbac";
@Controller("admin/orders")
@UseGuards(BetterAuthGuard, PermissionsGuard)
export class AdminOrdersController {
@Get()
@RequirePermissions({ order: ["view"] }) // single resource
list() { /* ... */ }
@Post(":id/cancel")
@RequirePermissions({ order: ["cancel"] }) // distinct action
cancel() { /* ... */ }
}Multi-resource requirements are an AND — the user needs all of them:
@RequirePermissions({ order: ["view"], user: ["list"] })
// requires: order:view AND user:listMultiple actions on one resource is also AND:
@RequirePermissions({ vendor: ["view", "update"] })
// requires: vendor:view AND vendor:updateWhen the guard rejects, it responds 403 Forbidden (FORBIDDEN).
Permission catalog
The full platform permissions map lives in @sc/auth/access-control and is the source of truth for what's grantable to a dynamic role.
| Resource | Actions |
|---|---|
user | create, update, list, set-role, ban, impersonate, impersonate-admins, delete, set-password |
session | list, revoke, delete |
vendor | approve, suspend, view, update |
order | view, update, refund, cancel |
invoice | view, regenerate |
product | view, update, delete, approve |
productAttribute | create, read, update, delete |
category | create, read, update, delete, approve |
tag | create, read, update, delete, approve |
brand | create, read, update, delete, approve |
ingredient | create, read, update, delete, approve |
banner | create, read, update, delete |
content | create, read, update, delete, manageRawHtml |
globalScript | create, read, update, delete |
adminSetting | read, update |
storeSetting | read, update |
platformVendorSetting | read, update |
role | create, read, update, delete |
discount | create, read, update, archive, delete |
freeGift | create, read, update, archive, delete |
dynamicLinkGroup | create, read, update, delete |
dynamicLink | create, read, update, delete |
cart | view, manage |
analytics | view, refresh |
productSoldMetrics | view, refresh |
audit | view |
search | reindex |
searchAnalytics | view |
reports | view, export |
review | read, create, update, delete, approve, reject, mark-spam |
notifications | view, broadcast |
stockNotification | read, delete, notify-vendor |
serviceability | create, read, update, delete |
emailTemplate | view, update, test |
notificationTemplate | view, update |
campaignTemplate | view, manage |
payout | view, create, mark_paid, cancel, adjust, configure |
fbt | view, rebuild |
rewards | read, manage |
affiliateApplication | read, review |
affiliatePayout | read, create, process |
affiliateProfile | read, manage, suspend |
affiliateOverride | read, manage |
affiliateCommission | read |
googleMerchant | view, manage |
metaCatalog | view, manage |
klaviyo | view, manage |
Built-in roles (not in this table — defined in code):
superAdmin— every action on every resource. Cannot be edited or deleted via this API. The break-glass role.admin— grants nothing on its own. A staff admin's capabilities come exclusively from adynamicRoleassigned to them; the built-inadminstatement is empty. This makesdynamicRolethe single source of truth for staff (including for Better-Auth-backed actions like impersonate/set-password, which the platform performs behindPermissionsGuardrather than via Better-Auth's static-role check). Better-Auth'sadminRolesis set to["admin", "superAdmin"]purely for impersonation target protection (neither can be impersonated withoutuser:impersonate-admins).
Use the dynamic-roles API below to build the roles staff actually need (e.g. "Support" with order:view + user:list + review:read). Every staff admin must be assigned a dynamicRole — without one they can do nothing.
Domain types
RoleResponse
type RoleResponse = {
id: string; // UUID
name: string; // 1..255 chars; unique
description: string | null;
permissions: Record<string, string[]>; // resource → actions
createdAt: string; // ISO
updatedAt: string;
};PermissionRequirement / StoredPermissions
Type-narrowed equivalents used inside the codebase. Each resource key is constrained to the literal union from platformPermissions, and the action array to the actions that resource declares:
type PermissionRequirement = {
user?: ("create" | "update" | "list" | ...)[];
order?: ("view" | "update" | "refund" | "cancel")[];
// ... one optional key per resource in the catalog
};The wire format (above) is the looser Record<string, string[]> because zod can't enforce the per-resource action constraint cleanly — the service validates each (resource, action) pair against the catalog before persisting.
Roles
Base path: /admin/rbac/roles. Permissions on the role:* resource.
GET /admin/rbac/roles — List dynamic roles
Required permission: role: read. Standard QueryDto (page / limit / search / filters[]).
Response 200 — paginated envelope of RoleResponse[].
GET /admin/rbac/roles/:id — Get a role
Required permission: role: read.
Errors
| Status | Code | When |
|---|---|---|
| 404 | NOT_FOUND | Unknown id |
POST /admin/rbac/roles — Create a role
Required permission: role: create. The service validates every (resource, action) pair against platformPermissions — unknown pairs are rejected with 400 VALIDATION_ERROR (service-layer, not zod).
Privilege-escalation guard: a non-superAdmin author may only grant permissions they themselves hold (their own effective set, per GET /admin/rbac/me/permissions). Requesting a (resource, action) outside that set is rejected 403 FORBIDDEN. superAdmin is unrestricted. This applies to both create and update.
Body
{
"name": "Support",
"description": "Read-only support staff",
"permissions": {
"order": ["view"],
"user": ["list"],
"review": ["read", "mark-spam"]
}
}| Field | Type | Constraints |
|---|---|---|
name | string | 1..255 chars. Unique among non-deleted roles |
description | string? | Free-form |
permissions | Record<string, string[]> | At least one resource. Each (resource, action) must exist in platformPermissions |
Response 201 — RoleResponse.
Errors
| Status | Code | When |
|---|---|---|
| 400 | VALIDATION_ERROR | Body fails zod; unknown resource/action pair |
| 409 | UNIQUE_VIOLATION | Role with this name already exists |
PUT /admin/rbac/roles/:id — Update a role
Required permission: role: update. Partial update — every field is optional, but permissions (when sent) replaces the entire map (whole-set semantics; not deep-merge).
Changes take effect on the next request a user makes — there is no session invalidation; the next call evaluates against the new map.
Body
{
"name": "Support Lead",
"description": "...",
"permissions": { "order": ["view", "update"], "user": ["list"] }
}Response 200 — updated RoleResponse.
Errors
| Status | Code | When |
|---|---|---|
| 404 | NOT_FOUND | Unknown id |
DELETE /admin/rbac/roles/:id — Delete a role
Required permission: role: delete. Users currently holding this role lose its grants on their next request; their session itself is not affected.
Response 200 — the deleted RoleResponse.
Errors
| Status | Code | When |
|---|---|---|
| 404 | NOT_FOUND | Unknown id |
Permissions catalog endpoint
GET /admin/rbac/permissions — List all permissions
Returns the full platformPermissions map verbatim. The role-builder UI uses this to render a checklist that's always in sync with the codebase.
Response 200
{
"data": {
"user": ["create", "update", "list", "set-role", "ban", "impersonate", "impersonate-admins", "delete", "set-password"],
"order": ["view", "update", "refund", "cancel"],
"review": ["read", "create", "update", "delete", "approve", "reject", "mark-spam"]
/* ...one entry per resource */
},
"message": "Success",
"statusCode": 200
}GET /admin/rbac/me/permissions — Current admin's effective permissions
Authenticated only — no per-resource permission (every admin may read their own grants). Returns the caller's effective permission map, mirroring PermissionsGuard's resolution: superAdmin → the full catalog; everyone else → their dynamicRole's stored permissions (the built-in admin role contributes nothing). Frontends use this to show/hide action UI; the backend still enforces every action.
Response 200
{
"data": { "user": ["list", "ban"], "session": ["revoke"] },
"message": "Success",
"statusCode": 200
}Staff management
Base path: /admin/staff. First-party admin-user management gated by the user:* permissions — the platform-native replacement for calling Better-Auth's admin plugin from the client (which only works for a static admin/superAdmin role and bypasses the permission catalog). Each action performs the Better-Auth operation server-side behind PermissionsGuard, mirroring the native-mint pattern used for impersonation/set-password.
A staff account is any user with a built-in admin role (admin/superAdmin) or a dynamicRole. Customer ids resolve to 404 here (their existence can't be probed). A superAdmin target may only be managed by a superAdmin (else 403).
StaffResponse
type StaffResponse = {
id: string;
name: string;
email: string;
emailVerified: boolean;
image: string | null;
role: string | null; // built-in role: "admin" | "superAdmin"
dynamicRole: string | null; // admin_role id conferring permissions
banned: boolean;
banReason: string | null;
banExpires: string | null; // ISO
createdAt: string; // ISO
};GET /admin/staff — List staff accounts
Required permission: user: list. Standard QueryDto (limit / offset / searchValue matches name or email / sortBy ∈ name|email|createdAt / sortDirection).
Response 200 — paginated envelope of StaffResponse[].
POST /admin/staff — Create a staff account
Required permission: user: create. Mints a user with the built-in admin role (grants nothing on its own) plus an optional dynamicRole, and a credential account with the configured password hasher.
Body
{ "name": "Jane", "email": "jane@shop.test", "password": "min-8-chars", "dynamicRole": "<role-uuid|null>" }Response 201 — StaffResponse.
Errors — 409 UNIQUE_VIOLATION (email taken); 400 BAD_REQUEST (unknown dynamicRole).
POST /admin/staff/:id/ban — Ban a staff account
Required permission: user: ban. Sets the ban and revokes all the target's sessions in the same transaction. Body: { reason?: string, banExpiresInDays?: number } (omit banExpiresInDays ⇒ permanent).
Response 200 — updated StaffResponse.
POST /admin/staff/:id/unban — Lift a ban
Required permission: user: ban. Response 200 — updated StaffResponse.
POST /admin/staff/:id/set-role — Assign/clear dynamicRole
Required permission: user: set-role. Body { dynamicRole: string | null } (null clears). Validates the role exists and revokes the target's sessions so a shrunk role can't be used until re-auth.
Response 200 — updated StaffResponse.
Errors — 404 NOT_FOUND (not a staff account); 403 FORBIDDEN (superAdmin target, non-superAdmin caller); 400 BAD_REQUEST (unknown dynamicRole).
Programmatic API
Other modules consume admin-rbac via the exported PermissionsGuard + RequirePermissions decorator. To gate a new endpoint:
import { PermissionsGuard, RequirePermissions } from "@sc/admin-rbac";
import { BetterAuthGuard } from "@sc/auth";
@Controller("admin/widgets")
@UseGuards(BetterAuthGuard, PermissionsGuard)
export class AdminWidgetController {
@Get()
@RequirePermissions({ widget: ["read"] }) // requires `widget:read`
list() { /* ... */ }
}For new permissions to exist, add the resource + actions to platformPermissions in api-modules/auth/src/access-control/permissions.ts. The catalog endpoint, role builder, and @RequirePermissions type checker all pick them up at compile time.
Related modules
auth— ownsplatformPermissions, theuser/roletables, and theBetterAuthGuardthat runs beforePermissionsGuard. Without the session,PermissionsGuardhas nothing to check against.vendor— usesRequirePermissions({ vendor: ["approve"] })to gate the vendor-application approval flow. Seevendor.md.- Every other admin-facing module (
order,catalog,reviews,discount,banner, …) gates its admin controllers via this module.
Wishlist Module — Storefront
HTTP surface for the customer wishlist — one wishlist per customer ("My Wishlist"), keyed at the variant level. The default wishlist is lazy-materialized on first access so the…
Affiliate Module — Admin
HTTP surface for platform staff managing the affiliate plugin: review applications, manage affiliates (incl. suspend/resume + per-affiliate commission overrides), configure…