Attribute-Based Access Control: Building a Policy Engine
Build an ABAC policy engine in TypeScript with the builder pattern, conditional permissions, and type-safe policy evaluation that replaces RBAC's limitations.
Abstract
Post 103 built type-safe RBAC with a can(role, resource, action) function and a declarative permission matrix. Adding a role is a one-line change. Server and client share a single source of truth.
But contextual decisions (ownership, department scoping, document status) remain outside RBAC as custom helper functions. canModifyDocument, canEditInDepartment, canPublishInReviewStatus proliferate alongside can(). Each is a custom if/else chain that the type system cannot verify.
ABAC (Attribute-Based Access Control), formalized by NIST SP 800-162, evaluates attributes of four entities: the subject (user), the resource (object), the action (operation), and the environment (context). Instead of can(role, 'document', 'update') plus a separate ownership check, ABAC evaluates everything in one policy decision.
This post builds a type-safe policy engine with a builder pattern. Ownership, department scoping, and resource state become declarative policy rules. The service layer architecture from Post 102 does not change. The can() function signature evolves.
The ABAC Model
From Roles to Attributes
RBAC's core question: "Does this role have this permission?"
ABAC's core question: "Given the subject's attributes, the resource's attributes, this action, and the environment, does the policy allow access?"
The shift: RBAC is a lookup (role -> permission set -> yes/no). ABAC is an evaluation (attributes -> policy engine -> decision).
NIST SP 800-162: The Four Attribute Categories
NIST Special Publication 800-162 defines ABAC as "a logical access control methodology where authorization to perform a set of operations is determined by evaluating attributes associated with the subject, object, requested operations, and, in some cases, environment conditions."
The four categories:
-
Subject Attributes: Properties of the user requesting access: role, department, userId, clearance level. In our domain:
session.role,session.userId,session.departmentId. -
Resource (Object) Attributes: Properties of the resource being accessed: owner, status, classification. In our domain:
document.authorId,document.status,document.project.departmentId. -
Action Attributes: The operation being requested: create, read, update, delete, publish. Same as RBAC's actions, but ABAC can attach conditions to specific action-resource combinations.
-
Environment Attributes: Contextual conditions external to the subject and resource: current time, IP address, feature flags. In our domain: business hours, feature flags.
How ABAC Evaluates a Request
In enterprise ABAC (XACML), the architecture includes PEP, PDP, PIP (Policy Information Point), and PAP (Policy Administration Point) as separate services. For application-level TypeScript, the service layer is the PEP and the can() function is the PDP. Attributes come from the already-loaded session and database objects; no separate PIP/PAP needed.
ABAC in Our Domain
Mapping the NIST model to the running example from Posts 101-103:
TypeScript Type System for ABAC
Resource Types with Data Shapes
In RBAC, resources were just string literals ('document' | 'project'). ABAC needs to know the shape of each resource's data so conditions can reference attributes type-safely.
This is the fundamental type-level shift from Post 103. Resource is still a string key, but it maps to a data type. When a policy says "the document's authorId must equal the user's userId," TypeScript can verify that authorId actually exists on the document type.
Conditions as Types
Conditions are functions that evaluate subject and resource attributes. The type system ensures conditions reference valid attributes:
The generic parameter R extends Resource links the condition to a specific resource type. A condition for 'document' receives ResourceDataMap['document']. TypeScript knows the shape includes authorId, status, projectId, departmentId. Referencing data.nonexistent is a compile-time error.
The Permission Store Type
The permission store replaces ROLE_PERMISSIONS from Post 103. Instead of a flat map from roles to permission strings, it maps roles to resource-action pairs with optional conditions:
Each PermissionEntry says: "For this resource, allow these actions, optionally only if these conditions are met." If conditions is undefined or empty, the permission is unconditional (same as RBAC). If conditions are present, ALL must evaluate to true (AND logic).
RBAC Types vs. ABAC Types
A side-by-side comparison makes the evolution visible:
The can() function now receives the full user object (not just role) and optionally the resource data. If a permission has conditions, the data is needed for evaluation. If no conditions exist (like admin's unconditional access), data can be omitted.
Permission Builder Pattern
Why a Builder?
Directly constructing the PermissionStore object is verbose and error-prone:
A builder pattern provides a fluent API that reads like a policy declaration.
The PermissionBuilder Class
Key points about this design:
RoleBuilder.can()is generic onR extends Resource. The conditions array is typed to the specific resource, so TypeScript checks that condition functions reference valid attributes.- Method chaining via
return thisenables the fluent API. - The builder pattern separates construction from representation. The final
PermissionStoreis a plain object, but the construction process is guided and type-checked.
Using the Builder
This reads like English: "An author can create documents. An author can read and update documents if the document's author is the user." The conditions that were scattered across helper functions in Post 103 are now inline with the permission declaration.
The can() Function Rewritten
Implementation
Several design decisions are embedded in this implementation:
-
Deny by default: If no matching entry is found, the function returns
false. This is the fail-closed principle from Post 101. -
Unconditional permissions: If
conditionsis undefined or empty, the permission is granted without evaluating data. This handles admin-style access; same behavior as RBAC. -
Data-dependent permissions: If conditions exist, the resource data must be provided. If data is missing, that entry is skipped (not denied globally). Another entry for the same resource-action might be unconditional.
-
AND logic for conditions: All conditions in an entry must pass. "Editor can update document IF same department AND document is draft" requires both conditions to be true. OR logic can be achieved by creating separate entries for the same resource-action pair.
-
Type inference: The generic
Rensures that whenresourceis'document',datamust beResourceDataMap['document']. TypeScript validates the data shape at compile time.
Signature Evolution
The can() function signature across the series:
What changed:
rolebecomesuser: The full user object provides all subject attributes (role, userId, departmentId), not just the role.data?: ResourceDataMap[R]: Optional resource data for condition evaluation. Optional because unconditional permissions (admin) do not need it.- Generic
R extends Resource: Links the resource string to its data type for type-safe conditions.
The Optional data Parameter
Why is data optional instead of required?
Consider checking permissions before loading a resource. When rendering a "Create" button, no document exists yet, so there is no data to pass. The can() function should still work for unconditional checks.
This makes the function backward-compatible with RBAC-style checks while supporting ABAC-style conditional checks.
Policy Definitions for All Roles
Admin Policy
No conditions. Admin has unconditional access to all actions on all resources. Same as RBAC, but now within the unified policy engine.
Editor Policy
The editor has TWO entries for document. The read action appears both conditionally (in the first entry) and unconditionally (in the second entry). Since the can() function returns true on the first match, the unconditional read entry matches for any document. The conditional entry applies to create, update, and publish.
In Post 103, this required a separate canEditInDepartment() helper. Now it is a declarative condition.
Author Policy
Authors can create documents unconditionally (a new document has no author yet). They can only read and update their own documents. The ownership check that was a helper function in Post 103 (document.authorId === session.userId) is now a condition.
Viewer Policy
No conditions. Read-only access. Identical behavior to RBAC.
Policy Matrix
The RBAC permission table from Post 103 evolves to include conditions:
The "(department)" and "(own)" annotations are conditions. They narrow the permission to a subset of resources. This table is still inspectable and auditable, but now captures the contextual rules that were hidden in Post 103's helper functions.
Service Layer ABAC Integration
Before: RBAC + Helper Functions (Post 103)
Multiple permission checks. The RBAC can() check handles the role. The ownership check is manual. The department check is manual. Adding a new contextual rule means adding another if block.
After: Unified ABAC can() (Post 104)
What changed:
- Three separate checks (RBAC
can()+ ownership + department) collapsed into onecan()call. - The resource data is passed explicitly. TypeScript ensures the shape matches
ResourceDataMap['document']. - Adding a new condition (e.g., "only draft documents can be updated") means adding a condition to the policy builder, not modifying this service method.
More Service Method Examples
Publishing a document: editor department check + author denial become a single check:
Reading a document: viewer unconditional + author ownership:
The service layer's structure from Post 102 is unchanged. Only the decision engine inside the permission checks evolved.
Common Pitfalls
-
Passing incomplete resource data: If a permission has conditions but data is omitted, the conditional entry is skipped. This may cause unexpected denials. When a role's permission has conditions, always pass the resource data.
-
AND vs. OR confusion: Multiple conditions on a single
PermissionEntryuse AND logic (all must pass). For OR logic, create separate entries for the same resource-action pair. The editor policy demonstrates this: unconditionalreadis a separate entry from conditionalcreate/update/publish. -
Conditions with side effects: Conditions should be pure functions. No database calls, no mutations, no logging. They receive pre-loaded data and return a boolean. Side effects in conditions make the system unpredictable and untestable.
-
Unconditional entry precedence: If a role has both conditional and unconditional entries for the same action-resource pair, the unconditional one matches first (returning
trueimmediately). This is correct for the editor'sreadaccess, but can be confusing if entry order is not considered. -
Conflating application-level and enterprise ABAC: Enterprise ABAC (XACML, OPA/Rego) involves separate PDP servers and network requests. Application-level ABAC (this post) is a local function call with in-memory conditions. The policy engine is a TypeScript module, not a distributed service.
RBAC to ABAC Migration Benefits
What Changed
What Stayed the Same
- The service layer architecture (Post 102) is unchanged
verifySession()still provides the user object- The service layer is still the security boundary
- UI permission checks are still for UX, not security
- The shared module pattern (no
server-onlyon permissions.ts) still applies - The fail-closed principle (Post 101) still applies
Testing with Truth Tables
Policies can be tested as truth tables. Each test is a self-contained attribute combination:
No mocking, no database, no side effects. The policy engine is a pure function. Each test provides a set of attributes and asserts the expected boolean result.
Decision Framework: RBAC vs. ABAC vs. External Engine
Trade-offs
Declarativeness vs. debuggability: Declarative policies are easier to read than imperative helpers, but when a permission is unexpectedly denied, tracing which condition failed requires stepping through the can() function. A canWithReason() variant that returns the failing condition can help during development.
Type safety vs. learning curve: TypeScript generics enforce that conditions reference valid resource attributes. This catches bugs at compile time but makes the type definitions more complex. Teams unfamiliar with generics may find the types initially challenging.
Centralized policy vs. performance: All policies in one builder is great for visibility. But the can() function iterates through all entries on every call. For simple applications (4 roles, 5-10 entries per role), this is microseconds. For complex policies with many entries, consider optimization (Post 105 addresses this).
Application-level vs. external engine: This post builds ABAC in TypeScript: fast, type-safe, zero network overhead. But policies are coupled to the application code and require deployment to change. External engines (OPA, Cedar) decouple policies from code but add latency and operational complexity (Post 106 covers this decision).
What's Next
The ABAC policy engine handles subject/resource/action/environment evaluation through a single can() function. Ownership, department scoping, and resource status are now declarative policy rules. The scattered helper functions from Post 103 are eliminated.
But all conditions evaluate in application memory against already-loaded objects. What about controlling which fields a user can see or modify? What about pushing ABAC conditions into the database so queries only return permitted data?
In Post 105, the ABAC engine extends to field-level permissions and database integration. Conditions get converted into ORM-compatible where clauses so the database enforces permissions at the query level, not just in application logic.
References
- NIST SP 800-162: Guide to Attribute Based Access Control (ABAC) - The foundational NIST standard defining the four attribute categories, functional components, and considerations for ABAC deployment
- NIST SP 800-162 Full Text (PDF) - Complete text of the ABAC guide with formal definitions of subject, object, action, and environment attributes
- RBAC vs ABAC vs ReBAC (Web Dev Simplified) - Practical TypeScript comparison of three permission models with code examples for ABAC condition evaluation
- OWASP Authorization Cheat Sheet - Best practices including centralized authorization, deny by default, and ABAC recommendations for fine-grained access control
- Attribute-Based Access Control (Wikipedia) - Overview of ABAC history, XACML standard, and PEP/PDP/PIP/PAP architecture
- XACML - eXtensible Access Control Markup Language (Wikipedia) - Reference for the XACML architecture components mapped to application-level equivalents in this post
- CASL - Isomorphic Authorization JavaScript Library - The library that inspired the
can()function pattern and builder-like permission definition API - RBAC vs ABAC: Differences and When to Use (Oso) - Detailed comparison covering when each model is appropriate and hybrid approaches
- How to Implement ABAC Authorization (Permit.io) - Step-by-step ABAC implementation guide covering attribute categories and PEP/PDP evaluation flow
- Using ABAC to Solve Role Explosion (Thoughtworks) - Analysis of how ABAC solves the role explosion problem with migration patterns
- TypeScript Generics Documentation - Official reference for the generic constraint patterns used in
Condition<R extends Resource> - Cedar Policy Language (StrongDM) - Overview of AWS Cedar as context for external policy engine alternatives discussed in the trade-offs
Permission Systems that Scale
A comprehensive guide to building scalable permission systems in TypeScript and Next.js, progressing from naive checks through RBAC and ABAC to production-grade multi-tenant authorization.