RBAC
The backend uses a wildcard-based RBAC (Role-Based Access Control) system
with pattern matching. Permissions are defined in code and mapped to
Casdoor roles via the role_mappings table.
Permission format
Permissions follow the pattern: module.action.granularity
| Permission | Description |
|---|---|
customer.read.all | List all customers |
customer.read.single | Read a single customer |
customer.read.audit | Read customer audit trail |
customer.create.single | Create a single customer |
customer.create.bulk | Bulk create customers |
customer.update.single | Update a single customer |
customer.update.bulk | Bulk update customers |
customer.delete.single | Delete a single customer |
customer.delete.bulk | Bulk delete customers |
customer.restore.single | Restore a soft-deleted customer |
customer.restore.bulk | Bulk restore customers |
customer.duplicate.single | Duplicate a single customer |
customer.duplicate.bulk | Bulk duplicate customers |
customer.export | Export customers |
modules.read.all | List all registered modules |
Wildcard support
Wildcards use * to match any segment:
| Pattern | Matches |
|---|---|
customer.* | All customer permissions |
customer.read.* | All customer read permissions |
* | Everything (equivalent to admin) |
The wildcard matching converts the pattern to a regex at runtime
(matchesWildcard(pattern, permission) in permissions.ts).
Role mappings
Role mappings link Casdoor roles to Primebrick permissions. They are
stored in the role_mappings table:
| Column | Type | Description |
|---|---|---|
idp_role | text | Casdoor role name (snake_case) |
permissions | text[] | Array of permission patterns |
is_admin | boolean | If true, bypasses all permission checks |
Default roles
| Role | Permissions | Admin |
|---|---|---|
administrators | (bypass) | true |
collaborator | ["customer.*"] | false |
guest | ["customer.read.*"] | false |
Implementation
The Permission registry, wildcard matching, role expansion, and RBAC
evaluation all live in @primebrick/sdk (the single source of truth shared by
the BE and microservices). The BE's rbac.middleware.ts is a thin Express
wrapper that calls the SDK's checkRbac().
Key files (SDK — source of truth):
| File | Purpose |
|---|---|
@primebrick/sdk → auth/permissions.ts | Permission registry, matchesWildcard, isPermissionGranted, expandPermissions |
@primebrick/sdk → auth/rbac.ts | checkRbac(user, requiredPerms, mode) — framework-agnostic evaluation |
@primebrick/sdk → auth/types.ts | AuthUser type with isAdmin / isSystem / permissions |
Key files (BE — Express integration):
| File | Purpose |
|---|---|
src/modules/auth/rbac.middleware.ts | rbacHandler(...) / rbacHandler.all(...) factory + client-shield secret + auth delegation |
src/modules/auth/auth.middleware.ts | authMiddleware() — populates req.user via SDK verifyAuth() |
src/modules/auth/sdk-auth-ports.ts | DomainUserResolverPort / DomainRoleMappingPort — DB-backed ports for the SDK |
src/modules/auth/services/role.service.ts | RoleService — role mapping logic + Casdoor sync (reads role_mappings table; DomainService base) |
src/http/protected-router.ts | makeProtectedRouter() — secure-first factory, default-deny on missing declaration |
Key functions:
checkRbac(user, requiredPermissions, mode)— returns{ allowed: boolean, missing?: string[] }expandPermissions(roles, getRoleMappingFn)— returns{ patterns, isAdmin }isPermissionGranted(userPermissions, requiredPermission)— exact match or wildcardmatchesWildcard(pattern, permission)— converts*to regex (customer.read.*→/^customer\.read\..*$/)isPermissionSentinel(p)—trueforPUBLIC/AUTHENTICATED_USER/AUTHENTICATED_ADMINlistNonSentinelPermissions()— all real permissions (used by the FE role-management UI)
The rbacHandler(...) declaration
Every protected route MUST attach a handler produced by rbacHandler(...) as
the first handler in the chain. The handler:
- In GATEWAY mode, validates the client-shield secret header (PUBLIC routes use the public secret; authenticated routes use the regular secret).
- For non-PUBLIC declarations, delegates to
authMiddleware()to populatereq.userand the AsyncLocalStorage session. - Evaluates
checkRbac(req.user, declaredPerms, mode)— OR (any-of) by default, AND (all-of) viarbacHandler.all([...]).
The handler is tagged with a PERMISSION_DECLARED symbol so
makeProtectedRouter() can detect routes that forgot the declaration and
inject a 403 default-deny (see
Architecture: secure-first policy).
OR semantics (default) — any-of
The user passes if they hold at least one of the declared permissions. This is the common case for read endpoints that accept either a list-all or a read-single permission.
Code
AND semantics — all-of
Use rbacHandler.all([...]) when the user must hold every declared
permission. Useful for high-risk operations that combine two concerns (e.g.
"can delete AND can export").
Code
Sentinels — PUBLIC, AUTHENTICATED_USER, AUTHENTICATED_ADMIN
Three pseudo-permissions are handled directly by the middleware (they are NOT
stored in role_mappings). They must appear alone in the permission array
— combining a sentinel with CRUD perms throws at route-registration time.
Code
Bypass rules
The SDK's checkRbac() applies these bypasses in order, before pattern matching:
Permission.PUBLIC→ always allowed.Permission.AUTHENTICATED_USER→ allowed if user is authenticated.user.isAdmin === true→ bypasses all checks (admin role).user.isSystem === true→ bypasses all checks (system API key; actor ="system").Permission.AUTHENTICATED_ADMIN→ allowed only for admins (after the bypasses above).
403 response shape
When checkRbac() returns { allowed: false }, the middleware throws a
ForbiddenError (or UnauthorizedError for the admin-only sentinel) with the
missing permissions in extra.issues. The frontend reads extra.issues to
show a precise "you need X" message.
Code
For rbacHandler.all([...]), kind is "missing_permissions" and missing
lists the specific perms the user doesn't hold. For AUTHENTICATED_ADMIN, the
middleware throws UnauthorizedError with internal_code: "RBAC_ADMIN_ONLY"
and extra.issues[0].kind === "missing_admin".
Adding a new permission
1. Add the constant to the Permission registry
The Permission object in @primebrick/sdk → src/auth/permissions.ts is the
single source of truth. Entries follow a closed grammar — entity ops are
{scope}.{action}.{qualifier} where read allows single|all|audit (+ the
qualifier-free {scope}.export) and writes allow
{create|update|delete|restore|duplicate}.{single|bulk}. Non-CRUD operations
use the service-action form {scope}.{verb} (e.g. emailsender.send). The
convention test (src/http/__tests__/rbac-convention.test.ts) rejects any
permission outside this grammar.
Code
2. Declare it on the route
Code
3. Grant it via a role mapping (SQL)
Insert a row into role_mappings (or update an existing one) to grant the new
permission to a role. Wildcards work — customer.* already covers
customer.import.
Code
4. Reload the role mapping cache
Role mappings are loaded into memory at startup. After a DB update, restart the
backend so loadRoleMappings() re-reads the table:
Code
There is no need to update listNonSentinelPermissions() — it iterates
Object.values(Permission) at runtime, so the FE role-management UI picks up
the new permission automatically on the next permissions-catalog fetch.
Testing RBAC changes
After updating role mappings in the database, restart the backend server to
reload the role mapping cache. In dev, tsx watch restarts on file changes but
NOT on DB changes — restart it manually after a UPDATE role_mappings.
A quick sanity check with curl:
Code
Next steps
- Authentication — the auth middleware that populates
req.userbefore RBAC runs. - Architecture — the secure-first
makeProtectedRouter()and the request-flow diagram. - Collaboration — how the actor context feeds audit fields.
- SDK API reference —
checkRbac,expandPermissions,Permissionregistry.