Roles & Authorization Module
Overview
The roles module provides CASL-based RBAC/ABAC authorization for the entire FlowPOS backend. It is a guard-only module — it has no API endpoints and no CRUD operations. Instead, it enforces access control on every protected endpoint across the system via NestJS guards and metadata decorators.
Authorization operates at two levels:
- App-level (platform-wide): Root, Admin, Support, Default
- Business-scoped: Owner, Admin, Cashier, StoreManager, SalesAssociate, and more
Architecture
roles/
├── roles.module.ts # NestJS module wiring
└── infrastructure/
├── roles.guard.ts # RolesGuard — CASL ability evaluation
└── roles.decorators.ts # Metadata decorators for controllers
Why no domain / application / interfaces layers?
This module is a cross-cutting concern, not a business entity. It does not manage its own data — roles are assigned via the business-users module and stored in Firebase custom claims. The guard reads these at runtime to make authorization decisions.
Dependency rule
controllers (any module) → decorators → guard → CASL policies (packages/global)
↓
database (location / businessUser lookups)
How It Works
1. Controller decoration
Controllers annotate their handlers with permission metadata:
// Class-level resource (applies to all handlers)
@UseGuards(RolesGuard)
@PermissionResource(PolicyResource.Product)
export class ProductsController {
// Handler-level action
@PermissionAction(PolicyAction.Create)
@Post()
create(@Body() dto: CreateProductDto) { ... }
// Or use the composite decorator
@Permission(PolicyResource.Product, PolicyAction.Read)
@Get()
findAll() { ... }
}
2. Guard execution flow
When a request hits a decorated handler, RolesGuard.canActivate() executes:
- Extract metadata — reads
@PermissionResourceand@PermissionActionfrom handler/class - Skip if undecorated — returns
trueif no metadata (route is unprotected at RBAC level) - Resolve businessId — checks body → params → query → location DB lookup
- Extract user context — reads Firebase claims (
db_user_id,role,role_by_business_id) - Enrich from DB — if token lacks business role, queries
businessUsertable - Build subject — merges body + params + query into a CASL subject, injects audit fields
- Create abilities — combines user rules (app-level) + business rules (business-scoped) via CASL
- Evaluate access — checks Owner → Admin → Root → specific permission, returns boolean
3. Access evaluation priority
The guard checks permissions in this order (first match wins):
| Priority | Check | Meaning |
|---|---|---|
| 1 | ability.can(All, All, subjectObject) | Owner/Admin with full business access |
| 2 | ability.can(All, resource, subjectObject) | Full access to specific resource |
| 3 | ability.can(All, All, {}) | Root (platform-wide superadmin) |
| 4 | ability.can(action, resource, subjectObject) | Specific permission check |
Decorators
| Decorator | Level | Purpose |
|---|---|---|
@PermissionResource(resource) | Class or method | Sets which PolicyResource the handler protects |
@PermissionAction(action) | Method | Sets which PolicyAction is required |
@Permission(resource, action) | Method | Composite — sets both in one call |
PolicyAction values
Create, Read, Update, Delete, Misc, All
PolicyResource values
60+ resources including: Business, Location, User, Product, Sale, Order, CashRegisterSession, Promotion, Collection, Markdown, etc.
Full list: packages/global/policies/src/enums/resource.enums.ts
Role Hierarchy
App-level roles (AppRoleName)
| Role | Permissions |
|---|---|
| Root | All on All — unrestricted |
| Admin | All on All — platform admin |
| Support | Limited support access |
| Default | Can create businesses, read services/UOM |
Business-scoped roles (UniqueRoleName)
| Role | Permissions |
|---|---|
| Owner | All on All within business |
| Admin / Administrator | Full access within business |
| StoreManager | Broad access, some restrictions |
| Cashier / SalesAssociate | Limited to sales, register, basic reads |
| InventoryManager | Inventory, purchasing, products |
| PurchasingManager | Purchasing, suppliers, GRN |
| AccountantFinanceManager | Financial modules |
| WarehouseStaff | Inventory operations |
| Other specialized roles | Granular per-module access |
Full definitions: packages/global/policies/src/rules/business.rules.ts
BusinessId Resolution
The guard resolves businessId using this fallback chain:
request.body.businessIdrequest.params.businessIdrequest.query.businessIdrequest.params.id(only for/businessesroutes)- DB lookup from
request.body.locationId→location.businessId - Firebase claim
current_business_id - First key from
role_by_business_id
Audit Field Injection
For write operations, the guard overrides audit fields to prevent client spoofing:
| Action | Field set |
|---|---|
| Create | createdBy = userId |
| Update | updatedBy = userId |
For read operations, minimal scoping is applied for specific resources (e.g., CashMovement.createdBy, CashRegisterSession.cashierId).
Usage in Other Modules
Every module that needs authorization:
- Imports
RolesModulein its module definition - Adds
@UseGuards(RolesGuard)at the controller class level - Adds
@PermissionResource(...)at the class level - Adds
@PermissionAction(...)on each handler method
Currently used by 29+ controllers and 236+ handler methods across the codebase.
Key Files
| File | Purpose |
|---|---|
apps/backend/src/roles/roles.module.ts | Module definition |
apps/backend/src/roles/infrastructure/roles.guard.ts | Authorization guard |
apps/backend/src/roles/infrastructure/roles.decorators.ts | Permission decorators |
packages/global/policies/src/rules/business.rules.ts | Business-scoped CASL rules |
packages/global/policies/src/rules/user.rules.ts | App-level CASL rules |
packages/global/policies/src/enums/action.enums.ts | PolicyAction enum |
packages/global/policies/src/enums/resource.enums.ts | PolicyResource enum |
packages/global/enums/role.enums.ts | Role name enums |
Design Decisions
- Guard-only module — Roles are not a CRUD resource. They are defined in code (CASL rules) and assigned via
business-users. No API needed. - CASL over custom — CASL provides battle-tested attribute-based access control with condition evaluation, reducing custom authorization code.
- DB fallback for missing claims — Firebase tokens may not always contain
role_by_business_id(e.g., first login). The guard falls back to a DB lookup. - Request mutation — The guard enriches
request.firebaseUserwith DB-resolved roles so downstream handlers benefit from the lookup. - Audit field injection — Prevents clients from spoofing
createdBy/updatedByby overwriting them in the CASL subject.