Policy engine: CASL v6 — Attribute-Based Access Control (ABAC)
Location:backend/src/policies/
Last updated: 2026-07
- Overview
- Architecture
- Roles
- Resources (Subjects)
- Actions
- Policy Matrix
- Conditional (Attribute-Based) Rules
- How Guards Work Together
- How to Use in a Controller
- How to Extend Policies
- Testing Policies
- Design Decisions
CarbonLedger uses Attribute-Based Access Control (ABAC) expressed via the CASL library. Every permission decision answers the question:
Can user with role X perform action Y on resource Z when attribute W is true?
This replaces the previous RBAC-only approach where:
@Roles('corporation')checked the role but not resource ownership- Inline
if (resource.owner !== req.user.publicKey)checks scattered throughout controllers made authorization logic hard to audit
All permission logic now lives in a single file: src/policies/ability.factory.ts.
src/policies/
├── types.ts # Action vocabulary, Subject classes, AppAbility type
├── ability.factory.ts # Single source of truth — builds AppAbility per user
├── check-policies.decorator.ts # @CheckPolicies() decorator for route handlers
├── policies.guard.ts # NestJS guard that evaluates @CheckPolicies() handlers
├── policies.module.ts # NestJS module — import in feature modules
├── index.ts # Barrel exports
└── __tests__/
├── ability.factory.spec.ts # All roles × resources × positive/negative cases
├── policies.guard.spec.ts # Guard skip, allow, deny, missing user
└── policy-scenarios.spec.ts # IDOR scenarios, owner scoping, status conditions
HTTP Request
│
▼
RolesGuard (APP_GUARD — global)
│ ├─ Verifies JWT signature
│ ├─ Loads user from DB (role from DB, not JWT)
│ ├─ Attaches user to req.user
│ └─ Checks @Roles() — coarse role gate
│
▼
PoliciesGuard (@UseGuards(PoliciesGuard) per route)
│ ├─ Reads @CheckPolicies() handlers
│ ├─ Builds AppAbility for req.user via AbilityFactory
│ └─ Evaluates each handler — throws ForbiddenException if any fails
│
▼
Controller method
Important:
PoliciesGuardruns afterRolesGuard. It relies onreq.userbeing already set. Always use@Roles()alongside@CheckPolicies()to ensure the coarse role gate fires first.
| Role | Description |
|---|---|
admin |
Unrestricted access to all resources and actions |
verifier |
Reads and approves/rejects carbon projects; read-only on credits |
project_developer |
Creates and manages own projects; uploads documents; lists credits for sale |
corporation |
Purchases and retires credits; exports own ESG reports; manages own listings |
public |
Unauthenticated/anonymous — read-only on verified projects, public audit trail |
Each subject maps to a domain entity from the Prisma schema. Subject classes are defined in src/policies/types.ts.
| Subject Class | Prisma Model | Key Attributes |
|---|---|---|
ProjectSubject |
CarbonProject |
ownerAddress, status |
CreditBatchSubject |
CreditBatch |
projectId |
RetirementSubject |
RetirementRecord |
retiredBy |
MarketListingSubject |
MarketListing |
seller |
OracleDataSubject |
MonitoringData / OracleJob |
— |
UserSubject |
User |
publicKey |
AuditLogSubject |
AuditLog |
— |
UploadSubject |
IPFSFile |
uploaderPublicKey |
ExportSubject |
(derived — export operations) | — |
StatsSubject |
(derived — stats queries) | — |
NotificationSubject |
NotificationPreference |
ownerPublicKey |
ZkProofSubject |
ZkRetirementProof |
retiredBy |
| Action | Meaning |
|---|---|
manage |
Wildcard — all actions (admin only) |
create |
Create a new resource |
read |
Read / list a resource |
update |
Modify an existing resource |
delete |
Remove a resource |
verify |
Verifier approves a project |
reject |
Verifier rejects a project |
mint |
Admin mints a new credit batch |
retire |
Corporation retires credits on-chain |
list |
List credits for sale in the marketplace |
delist |
Remove a listing from the marketplace |
purchase |
Buy credits from a marketplace listing |
export |
Export data (CSV/PDF) |
ingest |
Oracle ingest (monitoring data / price feed) |
hold |
Admin places a price update on hold |
approve |
Admin approves a held price update |
generateProof |
Corporation generates a ZK retirement proof |
assignRole |
Admin assigns a role to a user |
reindex |
Admin triggers a re-index of on-chain data |
✅ = allowed ❌ = denied 🔑 = conditional (see next section)
| Action | admin | verifier | project_developer | corporation | public |
|---|---|---|---|---|---|
create |
✅ | ❌ | ✅ | ❌ | ❌ |
read |
✅ | ✅ | ✅ | ✅ | 🔑 status=Verified |
update |
✅ | ❌ | 🔑 own | ❌ | ❌ |
verify |
✅ | ✅ | ❌ | ❌ | ❌ |
reject |
✅ | ✅ | ❌ | ❌ | ❌ |
| Action | admin | verifier | project_developer | corporation | public |
|---|---|---|---|---|---|
read |
✅ | ✅ | ✅ | ✅ | ✅ |
mint |
✅ | ❌ | ❌ | ❌ | ❌ |
retire |
✅ | ❌ | ❌ | ✅ | ❌ |
| Action | admin | verifier | project_developer | corporation | public |
|---|---|---|---|---|---|
read |
✅ | ❌ | ❌ | 🔑 own | ✅ (audit trail) |
export |
✅ | ❌ | ❌ | 🔑 own | ❌ |
| Action | admin | verifier | project_developer | corporation | public |
|---|---|---|---|---|---|
generateProof |
✅ | ❌ | ❌ | 🔑 own | ❌ |
read |
✅ | ❌ | ❌ | 🔑 own | ❌ |
| Action | admin | verifier | project_developer | corporation | public |
|---|---|---|---|---|---|
read |
✅ | ❌ | ✅ | ✅ | ✅ |
list |
✅ | ❌ | ✅ | ✅ | ❌ |
delist |
✅ | ❌ | 🔑 own | 🔑 own | ❌ |
purchase |
✅ | ❌ | ❌ | ✅ | ❌ |
| Action | admin | verifier | project_developer | corporation | public |
|---|---|---|---|---|---|
read |
✅ | ✅ | ❌ | ❌ | ❌ |
create |
✅ | ❌ | ❌ | ❌ | ❌ |
update |
✅ | ❌ | ❌ | ❌ | ❌ |
delete |
✅ | ❌ | ❌ | ❌ | ❌ |
assignRole |
✅ | ❌ | ❌ | ❌ | ❌ |
| Action | admin | verifier | project_developer | corporation | public |
|---|---|---|---|---|---|
read |
✅ | ✅ | ❌ | ❌ | ❌ |
| Action | admin | verifier | project_developer | corporation | public |
|---|---|---|---|---|---|
read |
✅ | ❌ | ❌ | ❌ | ❌ |
hold |
✅ | ❌ | ❌ | ❌ | ❌ |
approve |
✅ | ❌ | ❌ | ❌ | ❌ |
reject |
✅ | ❌ | ❌ | ❌ | ❌ |
ingest |
✅ | ❌ | ❌ | ❌ | ❌ (OracleGuard) |
| Action | admin | verifier | project_developer | corporation | public |
|---|---|---|---|---|---|
create |
✅ | ❌ | ✅ | ✅ | ❌ |
read |
✅ | ❌ | ✅ | ✅ | ✅ (by CID) |
| Action | admin | verifier | project_developer | corporation | public |
|---|---|---|---|---|---|
export |
✅ | ❌ | ❌ | ✅ | ❌ |
| Action | admin | verifier | project_developer | corporation | public |
|---|---|---|---|---|---|
read |
✅ | ✅ | ✅ | ✅ | ✅ |
| Action | admin | verifier | project_developer | corporation | public |
|---|---|---|---|---|---|
read |
✅ | ❌ | 🔑 own | 🔑 own | ❌ |
update |
✅ | ❌ | 🔑 own | 🔑 own | ❌ |
The following rules use resource attributes (the "A" in ABAC) to scope permissions beyond the role:
// In AbilityFactory (corporation role):
can('read', RetirementSubject, { retiredBy: user.publicKey });
can('export', RetirementSubject, { retiredBy: user.publicKey });In the controller:
const retirement = await this.retirementsService.findOne(id);
const ability = this.abilityFactory.createForUser(req.user);
if (ability.cannot('read', subject(RetirementSubject, { retiredBy: retirement.retiredBy }))) {
throw new ForbiddenException('Access denied');
}// In AbilityFactory (corporation role):
can('delist', MarketListingSubject, { seller: user.publicKey });In the controller:
const listing = await this.marketplaceService.findOne(id);
const ability = this.abilityFactory.createForUser(req.user);
if (ability.cannot('delist', subject(MarketListingSubject, { seller: listing.seller }))) {
throw new ForbiddenException('You can only delist your own listings');
}// In AbilityFactory (corporation role):
can('generateProof', ZkProofSubject, { retiredBy: user.publicKey });
can('read', ZkProofSubject, { retiredBy: user.publicKey });// In AbilityFactory (corporation / project_developer roles):
can('read', NotificationSubject, { ownerPublicKey: user.publicKey });
can('update', NotificationSubject, { ownerPublicKey: user.publicKey });// In AbilityFactory (project_developer role):
can('update', ProjectSubject, { ownerAddress: user.publicKey });// In AbilityFactory (public role):
can('read', ProjectSubject, { status: 'Verified' });-
RolesGuard (global
APP_GUARD) — coarse-grained. Validates JWT, loadsreq.userfrom DB, checks@Roles()decoration. All controllers benefit automatically. -
PoliciesGuard — fine-grained. Evaluates
@CheckPolicies()handlers using the builtAppAbility. Applied per-route via@UseGuards(PoliciesGuard).
Oracle ingest endpoints (POST /oracle/ingest/*) use a completely separate authentication mechanism: an Ed25519 Stellar keypair signature (not JWT). These routes use @Public() to bypass RolesGuard, and @UseGuards(OracleGuard) to verify the oracle's cryptographic signature. No CASL policy applies here.
// my-feature/my-feature.module.ts
import { PoliciesModule } from '../policies/policies.module';
@Module({
imports: [AuthModule, PoliciesModule],
...
})
export class MyFeatureModule {}import { UseGuards } from '@nestjs/common';
import { Roles } from '../auth/decorators';
import { CheckPolicies, PoliciesGuard, CreditBatchSubject } from '../policies';
@Post('mint')
@Roles('admin')
@UseGuards(PoliciesGuard)
@CheckPolicies((ability) => ability.can('mint', CreditBatchSubject))
mint(@Body() dto: MintCreditsDto) { ... }import { AbilityFactory } from '../policies/ability.factory';
import { subject } from '@casl/ability';
@Get(':id')
async findOne(@Param('id') id: string, @Request() req: any) {
const retirement = await this.retirementsService.findOne(id);
const ability = this.abilityFactory.createForUser(req.user);
if (ability.cannot('read', subject(RetirementSubject, { retiredBy: retirement.retiredBy }))) {
throw new ForbiddenException('Access denied');
}
return retirement;
}Why not
@CheckPolicies()here? The@CheckPolicies()decorator runs before the handler executes, so the resource hasn't been loaded yet. When an ownership check requires a DB value, useAbilityFactorydirectly inside the handler after loading the resource.
- Add a new subject class to
src/policies/types.ts:
export class NewResourceSubject {
ownerPublicKey: string;
}-
Add it to the
Subjectsunion type intypes.ts. -
Add rules to
ability.factory.ts:
case 'corporation':
can('create', NewResourceSubject);
can('read', NewResourceSubject, { ownerPublicKey: user.publicKey });
break;-
Add
@CheckPolicies()decoration to the new controller routes. -
Add unit tests to
ability.factory.spec.tsandpolicy-scenarios.spec.ts.
- Add it to the
Actionunion intypes.ts:
export type Action = ... | 'myNewAction';-
Add the rule in
ability.factory.tsfor the appropriate role(s). -
Use it in a
@CheckPolicies()handler:
@CheckPolicies((ability) => ability.can('myNewAction', SomeSubject))-
Add the role literal to
AuthenticatedUserintypes.tsand toUserRoleinauth/decorators.ts. -
Add a
case 'newRole':block inability.factory.ts. -
Update DB schema and seed data.
-
Add tests for the new role in
ability.factory.spec.ts.
Tests live in src/policies/__tests__/:
| File | Covers |
|---|---|
ability.factory.spec.ts |
All roles × resources × positive/negative cases |
policies.guard.spec.ts |
Guard skip (public), allow (handlers pass), deny (handlers fail), missing user |
policy-scenarios.spec.ts |
Ownership scoping (retirement, ZK proof, notifications, listings, projects) |
Running the policy tests in isolation:
cd backend
npx jest src/policies --no-coverageCASL is the most widely adopted ABAC library for NestJS/TypeScript. It provides:
- A fluent builder API (
can()/cannot()) - Mongo-style conditions for attribute matching
- TypeScript generics for type-safe subject/action definitions
- No runtime dependencies on a policy engine server
Making PoliciesGuard global (via APP_GUARD) would require every route to declare @CheckPolicies() or be marked @Public(). This would break existing routes without explicit policies. Instead, PoliciesGuard is applied per-route with @UseGuards(PoliciesGuard), making adoption incremental and explicit.
RolesGuard is the existing coarse-grained gate. It handles:
- JWT validation
- Loading
req.userfrom the database (role comes from DB, not JWT payload) @Roles()decoration for fast role-level rejection
PoliciesGuard adds the fine-grained layer on top. Both guards complement each other.
For post-load ownership checks (e.g., retirement.retiredBy), the resource must be fetched from the DB first. CASL conditions are evaluated at the time ability.cannot() is called — so calling it with the loaded resource attributes works correctly. Using subject(SubjectClass, resourceInstance) attaches the correct subject type for CASL to evaluate conditions against.