PrimeBrickPrimeBrick
  • Docs
  • Contact
  • MIT License
  • Documentation
  • MCP Server
  • API Catalog
  • Services
  • Libraries
PrimeBrickPrimeBrick

© 2026 PrimeBrick. MIT License. v3.11.2

github
Backend
    Roles and PermissionsWorking Together on the Same RecordManaging ModulesSigning In and MFA
    Generated
    Manual
    Developer Guide
      OverviewArchitectureAPI ConventionsEntity metadata contractEntity CRUD PipelineEntities and CRUD conventionsModule Navigation MetadataService RegistryMCP GatewayThe Service ProxyRotating Secrets EngineTranslationsAuthenticationRBACEnvironment ConfigurationCollaboration & Visual Merge
Frontend
Microservices
powered by Zudoku
Developer Guide

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

PermissionDescription
customer.read.allList all customers
customer.read.singleRead a single customer
customer.read.auditRead customer audit trail
customer.create.singleCreate a single customer
customer.create.bulkBulk create customers
customer.update.singleUpdate a single customer
customer.update.bulkBulk update customers
customer.delete.singleDelete a single customer
customer.delete.bulkBulk delete customers
customer.restore.singleRestore a soft-deleted customer
customer.restore.bulkBulk restore customers
customer.duplicate.singleDuplicate a single customer
customer.duplicate.bulkBulk duplicate customers
customer.exportExport customers
modules.read.allList all registered modules

Wildcard support

Wildcards use * to match any segment:

PatternMatches
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:

ColumnTypeDescription
idp_roletextCasdoor role name (snake_case)
permissionstext[]Array of permission patterns
is_adminbooleanIf true, bypasses all permission checks

Default roles

RolePermissionsAdmin
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):

FilePurpose
@primebrick/sdk → auth/permissions.tsPermission registry, matchesWildcard, isPermissionGranted, expandPermissions
@primebrick/sdk → auth/rbac.tscheckRbac(user, requiredPerms, mode) — framework-agnostic evaluation
@primebrick/sdk → auth/types.tsAuthUser type with isAdmin / isSystem / permissions

Key files (BE — Express integration):

FilePurpose
src/modules/auth/rbac.middleware.tsrbacHandler(...) / rbacHandler.all(...) factory + client-shield secret + auth delegation
src/modules/auth/auth.middleware.tsauthMiddleware() — populates req.user via SDK verifyAuth()
src/modules/auth/sdk-auth-ports.tsDomainUserResolverPort / DomainRoleMappingPort — DB-backed ports for the SDK
src/modules/auth/services/role.service.tsRoleService — role mapping logic + Casdoor sync (reads role_mappings table; DomainService base)
src/http/protected-router.tsmakeProtectedRouter() — 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 wildcard
  • matchesWildcard(pattern, permission) — converts * to regex (customer.read.* → /^customer\.read\..*$/)
  • isPermissionSentinel(p) — true for PUBLIC / AUTHENTICATED_USER / AUTHENTICATED_ADMIN
  • listNonSentinelPermissions() — 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:

  1. In GATEWAY mode, validates the client-shield secret header (PUBLIC routes use the public secret; authenticated routes use the regular secret).
  2. For non-PUBLIC declarations, delegates to authMiddleware() to populate req.user and the AsyncLocalStorage session.
  3. Evaluates checkRbac(req.user, declaredPerms, mode) — OR (any-of) by default, AND (all-of) via rbacHandler.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
import { rbacHandler } from "../auth/rbac.middleware.js"; import { Permission } from "@primebrick/sdk"; // The user passes with EITHER customer.read.all OR customer.read.single. registerRoutes(router, [ { method: "get", path: "/api/v1/entities/customer/meta", permission: rbacHandler([Permission.CUSTOMER_READ_ALL, Permission.CUSTOMER_READ_SINGLE]), handler: getMeta, }, ]);

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
// The user must hold BOTH customer.delete.bulk AND customer.export. registerRoutes(router, [ { method: "post", path: "/api/v1/entities/customer/bulk-delete-and-export", permission: rbacHandler.all([ Permission.CUSTOMER_DELETE_BULK, Permission.CUSTOMER_EXPORT, ]), handler: bulkDeleteAndExport, }, ]);

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
// Public — reachable without a JWT. STILL requires client-shield secret in GATEWAY mode. rbacHandler([Permission.PUBLIC]) // Any authenticated caller passes, regardless of roles. rbacHandler([Permission.AUTHENTICATED_USER]) // Only callers with isAdmin === true pass. Use for high-risk non-CRUD admin-only // operations (e.g. admin change-password). System API keys (isSystem === true) // also bypass. rbacHandler([Permission.AUTHENTICATED_ADMIN])

Bypass rules

The SDK's checkRbac() applies these bypasses in order, before pattern matching:

  1. Permission.PUBLIC → always allowed.
  2. Permission.AUTHENTICATED_USER → allowed if user is authenticated.
  3. user.isAdmin === true → bypasses all checks (admin role).
  4. user.isSystem === true → bypasses all checks (system API key; actor = "system").
  5. 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
{ "type": "/errors/forbidden", "title": "Forbidden", "status": 403, "detail": "Insufficient permissions to perform this action", "instance": "/api/v1/entities/customer/list", "internal_code": "RBAC_PERMISSION_DENIED", "severity": "MEDIUM", "extra": { "issues": [{ "kind": "missing_any_of_permissions", "required": ["customer.read.all"], "missing": ["customer.read.all"], "mode": "any", "user_roles": ["guest"] }] } }

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
// @primebrick/sdk → src/auth/permissions.ts export const Permission = { // ...existing entries... CUSTOMER_IMPORT: "customer.import", // new! — service-action form {scope}.{verb} } as const;

2. Declare it on the route

Code
// src/modules/customers/router.ts import { Permission } from "@primebrick/sdk"; registerRoutes(router, [ { method: "post", path: "/api/v1/entities/customer/import", permission: rbacHandler([Permission.CUSTOMER_IMPORT]), middlewares: [validateBody(CustomerImportBodySchema)], handler: importCustomers, }, ]);

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
-- Grant the new permission to the "collaborator" role. UPDATE role_mappings SET permissions = array_append(permissions, 'customer.import') WHERE idp_role = 'collaborator';

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:

TerminalCode
# In dev: tsx watch picks up the change automatically on the next restart. # In prod: restart the primebrick-api process. pnpm run dev

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:

TerminalCode
# Login as the user whose role you updated curl -X POST http://localhost:3001/api/v1/auth/login \ -H "Content-Type: application/json" \ -d '{"username":"<user>","password":"<password>"}' -c cookies.txt # Call the endpoint that requires the new permission curl http://localhost:3001/api/v1/entities/customer/import \ -X POST -H "Content-Type: application/json" \ -d '{"rows":[]}' -b cookies.txt -i # 200 → permission granted. 403 RBAC_PERMISSION_DENIED → role mapping not applied.

Next steps

  • Authentication — the auth middleware that populates req.user before 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, Permission registry.
Last modified on October 28, 2026
AuthenticationEnvironment Configuration
On this page
  • Permission format
  • Wildcard support
  • Role mappings
    • Default roles
  • Implementation
  • The rbacHandler(...) declaration
    • OR semantics (default) — any-of
    • AND semantics — all-of
    • Sentinels — PUBLIC, AUTHENTICATED_USER, AUTHENTICATED_ADMIN
    • Bypass rules
  • 403 response shape
  • Adding a new permission
    • 1. Add the constant to the Permission registry
    • 2. Declare it on the route
    • 3. Grant it via a role mapping (SQL)
    • 4. Reload the role mapping cache
  • Testing RBAC changes
  • Next steps
TypeScript
TypeScript
TypeScript
JSON
TypeScript
TypeScript