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) |
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 |
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 |
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 |
search | reindex |
review | read, create, update, delete, approve, reject, mark-spam |
notifications | view, broadcast |
payout | view, create, mark_paid, cancel, adjust |
fbt | view, rebuild |
rewards | manage |
affiliateApplication | read, review |
affiliatePayout | read, create, process |
affiliateProfile | read, manage, suspend |
affiliateOverride | read, manage |
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.admin— every action except destructive user/role management (nouser:impersonate-admins,user:set-password,role:create).
Use the dynamic-roles API below to build narrower roles (e.g. "Support" with order:view + user:list + review:read).
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).
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
}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…