
Every application eventually needs to answer one question: "Is this user allowed to do this?" A naive approach — like hardcoding if (user.role === 'admin') checks throughout the codebase — breaks the moment requirements grow. You end up with access logic scattered across dozens of files, no single place to audit, and bugs that are nearly impossible to trace.
A production-ready access control system that combines two complementary models:
| Layer | Model | Answers |
|---|---|---|
| Layer 1 | RBAC — Role-Based Access Control | "What can this role do in general?" |
| Layer 2 | PBAC — Policy-Based Access Control | "Under what conditions is it allowed right now?" |
| Entry point | AccessGateway | "Is this specific request allowed at this moment?" |
The two layers work in sequence. RBAC gives every role a fixed set of permissions. PBAC then applies contextual rules — time of day, department, resource ownership — on top. The AccessGateway combines both into a single canAccess() call that returns a clear allow or deny with a reason.
Real-world example: A manager role can product:create (RBAC). But not at 3 AM (PBAC deny). And only if their department is engineering for certain resources (PBAC condition). The AccessGateway combines both answers into one allowed: true/false.
Without hierarchy, you would have to copy every user permission into editor, proof_reader, sales_manager, and every other role that should have basic access. That means updating multiple places whenever a base permission changes — a maintenance problem that grows with every new role.
A tree where each role automatically inherits all permissions of the roles below it. You only define what makes each role unique.
super_admin
└─ admin
└─ manager
├─ proof_reader
│ └─ user
├─ editor
│ └─ user
└─ sales_manager
└─ user
The PermissionManager walks this tree at startup and builds a flat Set of all permissions for every role. At check time it does a simple Set.has() lookup — no tree traversal needed at runtime.
Role permissions answer a static question: "can a manager create products?" But real applications need dynamic questions: "can this manager create products right now, from this location, on this resource?" Those contextual rules cannot live in a role definition — they depend on request-time data.
A condition is a rule that compares a field from the live request against an expected value using an operator. Fields are accessed via dot-notation paths into a context object shaped like:
{
subject: { id, roles, dept, clearance, ... },
resource: { id, owner, classification, ... },
env: { hour, ip, country, ... }
}
Every condition in a policy must pass for the policy to match. If any condition fails, that policy is skipped entirely and evaluation moves to the next one.
If you apply RBAC first, a high-privilege role could bypass a time-based or ownership-based block — which is usually not what you want. The order must be deliberate.
1. PBAC DENY → Hard block. Stops everything, even super_admin.
2. RBAC check → Does the role grant this permission?
3. PBAC ALLOW → Contextual allow (e.g. owner-only resources).
4. Default → RBAC passed and no PBAC rule matched — allow by role.
PBAC denies are evaluated before RBAC. This means a deny-outside-hours policy will block a super_admin attempting a write at 3 AM just as firmly as it blocks a user. The privilege level of the role cannot override an explicit policy denial.
Access rules change. If role names and permission strings are scattered as raw strings across the codebase, renaming one permission means a fragile find-and-replace across many files. A single config file gives you one place to audit, one place to change, and TypeScript enforcing correctness everywhere else.
Four things: a permission map, a role map, a role hierarchy, and a role-to-permissions mapping.
Step 1: Define permissions as a constant map
const PERMISSIONS = {
PRODUCT_CREATE: 'product:create',
PRODUCT_READ: 'product:read',
PRODUCT_UPDATE: 'product:update',
PRODUCT_DELETE: 'product:delete',
PRODUCT_REVIEW: 'product:review',
USER_CREATE: 'user:create',
USER_DELETE: 'user:delete',
} as const;
type Permission = typeof PERMISSIONS[keyof typeof PERMISSIONS];
// → "product:create" | "product:read" | ...
Why as const? Without it, TypeScript infers the values as string. With it, TypeScript infers them as their exact literal types, which means the Permission union is precise — "product:create" not just any string. If you mistype 'product:crate' anywhere in your code, TypeScript will report an error immediately.
Step 2: Define roles the same way
const ROLES = {
SUPER_ADMIN: 'super_admin',
ADMIN: 'admin',
MANAGER: 'manager',
SALES_MANAGER: 'sales_manager',
PROOF_READER: 'proof_reader',
EDITOR: 'editor',
PREMIUM_USER: 'premium_user',
USER: 'user',
} as const;
type Role = typeof ROLES[keyof typeof ROLES];
The Role type now acts as a compile-time whitelist. Any code that accepts a role will only accept the exact strings defined here.
Step 3: Define the role hierarchy
const RoleHierarchy: Record<Role, Role[]> = {
super_admin: ['admin'],
admin: ['manager'],
manager: ['proof_reader', 'editor', 'sales_manager'],
sales_manager: ['user'],
proof_reader: ['user'],
editor: ['user'],
premium_user: ['user'],
user: [],
};
The Record<Role, Role[]> type guarantees two things: every role in ROLES must appear as a key, and every value in the arrays must also be a valid role. If you add a new role to ROLES but forget to add it here, TypeScript will report a missing key error.
Step 4: Assign permissions per role
const RoleBasedPermissions: Record<Role, Permission[]> = {
super_admin: ['user:create', 'user:delete', 'product:delete'],
admin: ['user:create', 'user:delete', 'product:delete'],
manager: ['product:create', 'product:update'],
sales_manager: ['product:review'],
proof_reader: ['product:update'],
editor: ['product:create', 'product:update'],
premium_user: ['product:review'],
user: ['product:read'],
};
Only list permissions unique to each role. Never repeat a permission that will already be inherited. The PermissionManager handles inheritance automatically.
Types are the contract between every part of the system. Defining them clearly in one place means any change to the data shape of a request or a policy is caught immediately across every consumer — the RBAC engine, the PBAC engine, and the gateway all speak the same language.
Six types cover the entire system: PermissionContext, Condition, Policy, AccessRequest, PolicyResult, and GatewayResult.
PermissionContext — what the RBAC engine knows about the user
interface PermissionContext {
roles: Role[];
permissions: Permission[]; // explicit per-user overrides
}
The permissions array allows granting a specific user a one-off permission without changing their role. Useful for temporary access or exceptions.
AccessRequest — the data passed to the gateway for every action
interface AccessRequest {
subject: {
id: string;
roles: Role[];
[key: string]: unknown; // dept, clearance, region, etc.
};
action: string;
resource: {
id: string;
[key: string]: unknown; // owner, classification, etc.
};
environment?: Record<string, unknown>; // hour, ip, country, etc.
}
The [key: string]: unknown index signatures allow any extra attributes. These are what PBAC conditions reference via dot-notation paths like "subject.dept" or "env.hour".
Policy — a single PBAC rule
interface Policy {
id: string;
effect: 'allow' | 'deny';
subjects: string[]; // role names, user IDs, or "*"
actions: string[]; // permission strings or "*"
resources: string[]; // exact IDs or glob "product:*"
conditions?: Condition[];
priority?: number; // higher = evaluated first
}
Condition — an attribute-level constraint on a policy
interface Condition {
field: string; // dot-path into the request context
operator: 'eq' | 'neq' | 'in' | 'nin' | 'gt' | 'lt' | 'gte' | 'lte';
value: unknown;
}
GatewayResult — what the gateway returns
interface GatewayResult {
allowed: boolean;
reason: string;
source: 'PBAC_DENY' | 'RBAC_DENY' | 'PBAC_ALLOW' | 'RBAC_ALLOW';
}
The source field tells you exactly which layer made the decision. This is essential for debugging and audit logging — you can tell immediately whether a denial came from a policy rule or a missing role permission.
Every permission check in a live application happens at request time — potentially dozens of times per second. Walking the role hierarchy tree on every check would be wasteful. Instead, the PermissionManager does all the heavy computation once at construction time and stores the results in fast Map and Set structures.
It takes a user's roles and explicit permissions, resolves the full inherited permission set for each role, and exposes simple public methods for checking access.
Construction: precomputing hierarchies and permissions
constructor(private readonly context: PermissionContext) {
Object.keys(RoleHierarchy).forEach((role) => {
this.cachedRoleHierarchy.set(role, this.computeRoleHierarchy(role));
});
Object.keys(RoleBasedPermissions).forEach((role) => {
this.cachedRolePermissions.set(role, this.computePermissions(role));
});
}
Two caches are built: one that maps each role to all roles it inherits, and one that maps each role to all permissions it holds (including inherited ones).
computeRoleHierarchy — recursive tree walking with cycle protection
private computeRoleHierarchy(
role: string,
visitedRoles: Set<string> = new Set()
): Set<string>
The visitedRoles set prevents infinite loops if a misconfigured hierarchy accidentally creates a cycle (e.g. editor inherits from manager which inherits from editor). Without this guard, the function would recurse forever.
computePermissions — collecting all permissions including inherited
private computePermissions(
role: string,
visitedRoles: Set<string> = new Set()
): Set<string>
This function first adds permissions directly assigned to the role, then looks up the pre-cached hierarchy and adds permissions from every inherited role. The result is a flat Set with no duplicates.
At check time — O(1) lookup
public hasPermission(permission: string): boolean {
return (
this.context.permissions.includes(permission as Permission) ||
this.hasPermissionThroughRole(permission)
);
}
Explicit permissions are checked first (per-user overrides). If not found, it checks whether any of the user's roles have the permission in their cached Set.
| Method | What it does |
|---|---|
hasPermission(permission) |
True if user has this permission explicitly or via any role |
hasPermissions([p1, p2]) |
True if user has ALL listed permissions |
hasAnyPermission([p1, p2]) |
True if user has ANY listed permission |
hasRole(role) |
True if user has this role directly or via inheritance |
getMaximumRole() |
Returns the most privileged role the user holds |
getAllPermissions() |
Returns the full Set of effective permissions |
RBAC answers static questions about roles. PBAC answers dynamic questions about context. Keeping them separate means you can update policies (add a time restriction, change an ownership rule) without touching the role configuration — and vice versa.
It holds a list of policies and evaluates an AccessRequest against them in priority order, returning the first matching policy's effect.
matchesWildcardPattern — flexible resource and action matching
function matchesWildcardPattern(pattern: string, value: string): boolean {
if (pattern === '*') return true;
if (pattern.endsWith(':*')) return value.startsWith(pattern.slice(0, -1));
return pattern === value;
}
Why wildcards? A policy for "product:*" should cover "product:101", "product:202", and any future product ID without needing a separate policy per resource. Three rules cover all cases: universal wildcard, namespace wildcard, and exact match.
resolveNestedField — dot-path access into the request context
function resolveNestedField(
contextObject: Record<string, unknown>,
fieldPath: string
): unknown
Why dot-paths? Conditions need to reach into nested objects like subject.dept or env.hour. Rather than writing a separate accessor for every possible field, a single general-purpose resolver handles any path. It splits the path on . and walks the object one key at a time, returning undefined safely if any key does not exist.
evaluateSingleCondition — comparing actual vs expected values
function evaluateSingleCondition(
condition: Condition,
context: Record<string, unknown>
): boolean
Resolves the field from the context, then applies the operator. The eight operators cover equality (eq, neq), membership (in, nin), and numeric comparison (gt, lt, gte, lte). If an unknown operator is passed, it returns false — a safe default that denies rather than accidentally allows.
evaluate — sorting and matching policies
evaluate(request: AccessRequest): PolicyResult
Policies are sorted by priority descending before evaluation. On a tie in priority, deny-effect policies are sorted before allow-effect policies. This ensures that a blocking rule always wins over a permissive rule of equal importance. The first policy that fully matches the request is returned immediately — no further evaluation happens.
| Rule | Explanation |
|---|---|
Higher priority = evaluated first |
Use priority 10 for critical security rules, 0 for defaults |
| Deny beats allow on equal priority | An explicit block always wins a tie |
| All conditions in a policy must pass | Conditions within a policy are AND-ed together |
| First full match wins | Order policies by priority intentionally |
"*" in subjects means everyone |
Including unauthenticated users if they reach the engine |
Without a gateway, every controller or service in your application would need to call both the RBAC engine and the PBAC engine manually, in the right order, every time. One missed check means a security hole. The AccessGateway centralises this logic so the rest of your application only ever calls one method: canAccess().
It wraps both engines, enforces the correct evaluation order, and returns a single GatewayResult with a clear allowed boolean, a human-readable reason, and a source telling you which layer made the decision.
Construction
const gateway = new AccessGateway(pbacEngine, {
roles: currentUser.roles,
permissions: currentUser.explicitPermissions ?? [],
});
One gateway instance per user per request. The RBAC context (roles and explicit permissions) is fixed at construction time.
The canAccess method — full evaluation order
gateway.canAccess(request): GatewayResult
Step 1 — PBAC is evaluated first:
const policyResult = this.pbac.evaluate(request);
if (policyResult.decision === 'DENY') {
return { allowed: false, reason: policyResult.reason, source: 'PBAC_DENY' };
}
Why first? PBAC denies are absolute. No role should be able to bypass a time restriction or an ownership rule. Evaluating PBAC before RBAC enforces this guarantee.
Step 2 — RBAC checks the role's permission:
const isAllowedByRole = this.rbac.hasPermission(request.action);
if (!isAllowedByRole) {
return { allowed: false, reason: `...`, source: 'RBAC_DENY' };
}
Why second? Only proceed to allow if the user's role actually grants the requested action. This prevents a user from bypassing RBAC via a PBAC allow policy on an action their role never had access to.
Step 3 — PBAC contextual allow:
if (policyResult.decision === 'ALLOW') {
return { allowed: true, reason: policyResult.reason, source: 'PBAC_ALLOW' };
}
Why third? A PBAC allow policy can grant contextual access (e.g. finance team can read finance reports) even to roles with otherwise minimal permissions.
Step 4 — Default allow by role:
return { allowed: true, reason: `Allowed — role "..." grants "..."`, source: 'RBAC_ALLOW' };
RBAC passed and no PBAC rule matched — the role grants the action and nothing blocked it.
Understanding each piece individually is not enough. The way you wire them together in an actual application determines whether the system is maintainable and correctly enforced.
// 1. Create the PBAC engine once — shared across all requests
const pbacEngine = new PolicyEngine();
pbacEngine.addPolicies([ /* your policies */ ]);
// 2. Create a gateway per request, scoped to the current user
const gateway = new AccessGateway(pbacEngine, {
roles: currentUser.roles,
permissions: currentUser.explicitPermissions ?? [],
});
// 3. Check access at the point of use
const result = gateway.canAccess({
subject: { id: currentUser.id, roles: currentUser.roles, dept: currentUser.dept },
action: 'product:create',
resource: { id: `product:${productId}` },
environment: { hour: new Date().getHours() },
});
if (!result.allowed) {
throw new Error(`Access denied: ${result.reason}`);
}
Always cover these four scenarios for every new policy or role you add:
RBAC_ALLOW or PBAC_ALLOWRBAC_DENYPBAC_DENYPBAC_ALLOWAccessGateway.canAccess(request)
│
▼
PolicyEngine.evaluate(request)
│
┌─────┼───────────────────┐
DENY ALLOW NOT_APPLICABLE
│ │ (no policy matched —
│ │ PBAC has no opinion)
▼ │ │
PBAC_ │ ▼
DENY │ PermissionManager.hasPermission(request.action)
│ │
│ ┌──────┴──────┐
│ NO YES
│ │ │
│ ▼ ▼
│ RBAC_DENY RBAC_ALLOW
│ (PBAC was silent,
│ RBAC is the only voice)
│
▼
PermissionManager.hasPermission(request.action)
│
┌──────┴──────┐
NO YES
│ │
▼ ▼
RBAC_ PBAC_
DENY ALLOW
(PBAC explicitly allowed it
AND the role grants it)
| Scenario | Use |
|---|---|
| "Can editors update products?" | RBAC — hasPermission('product:update') |
| "Block all writes after 6 PM" | PBAC — deny policy with env.hour condition |
| "Finance team reads their reports" | PBAC — allow policy with subject.dept condition |
| "Only the owner can delete" | PBAC — deny policy where resource.owner ≠ subject id |
| "What is this user's highest role?" | RBAC — getMaximumRole() |
| "Does this user have any write permission?" | RBAC — hasAnyPermission([...]) |
| "Temporarily grant one extra permission" | RBAC — add to PermissionContext.permissions array |