Skip to content

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:

  1. Subject Attributes: Properties of the user requesting access: role, department, userId, clearance level. In our domain: session.role, session.userId, session.departmentId.

  2. Resource (Object) Attributes: Properties of the resource being accessed: owner, status, classification. In our domain: document.authorId, document.status, document.project.departmentId.

  3. 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.

  4. 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:

NIST ConceptRBAC (Post 103)ABAC (This Post)
Decision InputRole onlySubject + Resource + Action + Environment
Decision LogicMap lookupCondition evaluation
"Can edit own docs?"Helper function outside can()Condition: subject.userId === resource.authorId
"Only in department"Helper function outside can()Condition: subject.departmentId === resource.departmentId
"Only draft status"Cannot expressCondition: resource.status === 'draft'
Adding a contextual ruleWrite a new helper functionAdd a condition to the policy

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.

typescript
// lib/permissions.ts
// The session type -- subject attributesinterface User {  userId: string;  role: Role;  departmentId?: string;}
// Resource data shapes -- what attributes each resource hasinterface ResourceDataMap {  document: {    authorId: string;    status: 'draft' | 'published' | 'archived';    projectId: string;    departmentId: string;  };  project: {    ownerId: string;    departmentId: string;    isArchived: boolean;  };}
export type Resource = keyof ResourceDataMap;

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:

typescript
// A condition receives the user and resource data,// returns true (allow) or false (deny)type Condition<R extends Resource> = (  user: User,  data: ResourceDataMap[R]) => boolean;

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:

typescript
interface PermissionEntry<R extends Resource> {  resource: R;  actions: Action[];  conditions?: Condition<R>[];}
// The full permission store: role -> array of permission entriestype PermissionStore = {  [R in Role]: PermissionEntry<Resource>[];};

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:

typescript
// Post 103 (RBAC) -- flat permission stringstype Permission = `${Resource}:${Action}`;const ROLE_PERMISSIONS: Record<Role, readonly Permission[]>;function can(role: Role, resource: Resource, action: Action): boolean;
// Post 104 (ABAC) -- conditions on resource datainterface PermissionEntry<R extends Resource> {  resource: R;  actions: Action[];  conditions?: Condition<R>[];}type PermissionStore = { [R in Role]: PermissionEntry<Resource>[] };function can<R extends Resource>(  user: User,  action: Action,  resource: R,  data?: ResourceDataMap[R]): boolean;

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:

typescript
// Without builder -- verbose, hard to readconst store: PermissionStore = {  admin: [    { resource: 'document', actions: ['create', 'read', 'update', 'delete', 'publish'] },    { resource: 'project', actions: ['create', 'read', 'update', 'delete'] },  ],  editor: [    {      resource: 'document',      actions: ['create', 'read', 'update', 'publish'],      conditions: [(user, doc) => user.departmentId === doc.departmentId],    },    { resource: 'project', actions: ['read'] },  ],  // ... more roles};

A builder pattern provides a fluent API that reads like a policy declaration.

The PermissionBuilder Class

typescript
class PermissionBuilder {  private store: PermissionStore = {    admin: [],    editor: [],    author: [],    viewer: [],  };
  // Start defining permissions for a role  role(role: Role): RoleBuilder {    return new RoleBuilder(this.store, role);  }
  build(): PermissionStore {    return this.store;  }}
class RoleBuilder {  constructor(    private store: PermissionStore,    private currentRole: Role  ) {}
  // Grant permissions on a resource, optionally with conditions  can<R extends Resource>(    actions: Action | Action[],    resource: R,    conditions?: Condition<R>[]  ): RoleBuilder {    const actionArray = Array.isArray(actions) ? actions : [actions];    this.store[this.currentRole].push({      resource,      actions: actionArray,      conditions,    } as PermissionEntry<Resource>);    return this;  }
  // Switch to another role  role(role: Role): RoleBuilder {    return new RoleBuilder(this.store, role);  }
  build(): PermissionStore {    return this.store;  }}

Key points about this design:

  • RoleBuilder.can() is generic on R extends Resource. The conditions array is typed to the specific resource, so TypeScript checks that condition functions reference valid attributes.
  • Method chaining via return this enables the fluent API.
  • The builder pattern separates construction from representation. The final PermissionStore is a plain object, but the construction process is guided and type-checked.

Using the Builder

typescript
const permissions = new PermissionBuilder()  // Admin: full access, no conditions  .role('admin')    .can(['create', 'read', 'update', 'delete', 'publish'], 'document')    .can(['create', 'read', 'update', 'delete'], 'project')
  // Editor: department-scoped document access  .role('editor')    .can(['create', 'read', 'update', 'publish'], 'document', [      (user, doc) => user.departmentId === doc.departmentId,    ])    .can('read', 'document') // can read any document (no condition)    .can('read', 'project')
  // Author: own documents only  .role('author')    .can('create', 'document') // no ownership yet -- new document    .can(['read', 'update'], 'document', [      (user, doc) => doc.authorId === user.userId,    ])    .can('read', 'project')
  // Viewer: read-only  .role('viewer')    .can('read', 'document')    .can('read', 'project')
  .build();

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

typescript
function can<R extends Resource>(  user: User,  action: Action,  resource: R,  data?: ResourceDataMap[R]): boolean {  const entries = permissions[user.role] as PermissionEntry<R>[];
  for (const entry of entries) {    if (entry.resource !== resource) continue;    if (!entry.actions.includes(action)) continue;
    // If no conditions, permission is granted (unconditional)    if (!entry.conditions || entry.conditions.length === 0) {      return true;    }
    // If conditions exist but no data provided, skip this entry    // (conditions cannot be evaluated without resource data)    if (!data) continue;
    // All conditions must pass (AND logic)    const allConditionsMet = entry.conditions.every(      (condition) => condition(user, data)    );
    if (allConditionsMet) return true;  }
  return false; // No matching entry found -- deny by default}

Several design decisions are embedded in this implementation:

  1. Deny by default: If no matching entry is found, the function returns false. This is the fail-closed principle from Post 101.

  2. Unconditional permissions: If conditions is undefined or empty, the permission is granted without evaluating data. This handles admin-style access; same behavior as RBAC.

  3. 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.

  4. 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.

  5. Type inference: The generic R ensures that when resource is 'document', data must be ResourceDataMap['document']. TypeScript validates the data shape at compile time.

Signature Evolution

The can() function signature across the series:

typescript
// Post 103 (RBAC):can(role: Role, resource: Resource, action: Action): boolean
// Post 104 (ABAC):can<R extends Resource>(  user: User,  action: Action,  resource: R,  data?: ResourceDataMap[R]): boolean

What changed:

  • role becomes user: 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.

typescript
// UI: "Should we show the Create button?"// No document exists yet -- no data to passif (can(user, 'create', 'document')) {  // show Create button}
// Service: "Can this user update THIS document?"// Document exists -- pass its data for condition evaluationif (can(user, 'update', 'document', {  authorId: document.authorId,  status: document.status,  projectId: document.projectId,  departmentId: document.project.departmentId,})) {  // proceed with update}

This makes the function backward-compatible with RBAC-style checks while supporting ABAC-style conditional checks.

Policy Definitions for All Roles

Admin Policy

typescript
.role('admin')  .can(['create', 'read', 'update', 'delete', 'publish'], 'document')  .can(['create', 'read', 'update', 'delete'], 'project')

No conditions. Admin has unconditional access to all actions on all resources. Same as RBAC, but now within the unified policy engine.

Editor Policy

typescript
.role('editor')  .can(['create', 'read', 'update', 'publish'], 'document', [    (user, doc) => user.departmentId === doc.departmentId,  ])  .can('read', 'document') // can read any document (no condition)  .can('read', 'project')

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

typescript
.role('author')  .can('create', 'document') // can create (no ownership yet)  .can(['read', 'update'], 'document', [    (user, doc) => doc.authorId === user.userId,  ])  .can('read', 'project')

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

typescript
.role('viewer')  .can('read', 'document')  .can('read', 'project')

No conditions. Read-only access. Identical behavior to RBAC.

Policy Matrix

The RBAC permission table from Post 103 evolves to include conditions:

Resource:Actionadmineditorauthorviewer
document:createyesyes (department)yes--
document:readyesyesyes (own)yes
document:updateyesyes (department)yes (own)--
document:deleteyes------
document:publishyesyes (department)----
project:createyes------
project:readyesyesyesyes
project:updateyes------
project:deleteyes------

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)

typescript
// lib/services/document-service.tsimport 'server-only';import { can, type Role } from '@/lib/permissions';
export async function updateDocument(  documentId: string,  content: string) {  const session = await requireSession();  const document = await db.document.findUnique({    where: { id: documentId },    include: { project: true },  });
  if (!document) throw new NotFoundError();
  // RBAC check  if (!can(session.role as Role, 'document', 'update')) {    // Ownership fallback -- outside RBAC    if (document.authorId !== session.userId) {      throw new ForbiddenError();    }  }
  // Department check -- another helper outside RBAC  if (session.role !== 'admin' &&      session.departmentId !== document.project.departmentId) {    throw new ForbiddenError();  }
  return await db.document.update({    where: { id: documentId },    data: { content },  });}

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)

typescript
// lib/services/document-service.tsimport 'server-only';import { can } from '@/lib/permissions';
export async function updateDocument(  documentId: string,  content: string) {  const session = await requireSession();  const document = await db.document.findUnique({    where: { id: documentId },    include: { project: true },  });
  if (!document) throw new NotFoundError();
  // Single ABAC check -- all conditions evaluated inside  if (!can(session, 'update', 'document', {    authorId: document.authorId,    status: document.status,    projectId: document.projectId,    departmentId: document.project.departmentId,  })) {    throw new ForbiddenError();  }
  return await db.document.update({    where: { id: documentId },    data: { content },  });}

What changed:

  • Three separate checks (RBAC can() + ownership + department) collapsed into one can() 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:

typescript
// Before: RBAC + manual checksexport async function publishDocument(documentId: string) {  const session = await requireSession();  const document = await db.document.findUnique({    where: { id: documentId },    include: { project: true },  });  if (!document) throw new NotFoundError();
  if (!can(session.role as Role, 'document', 'publish')) {    throw new ForbiddenError();  }  // Department check -- manual  if (session.role !== 'admin' &&      session.departmentId !== document.project.departmentId) {    throw new ForbiddenError();  }  // ... publish logic}
// After: single ABAC checkexport async function publishDocument(documentId: string) {  const session = await requireSession();  const document = await db.document.findUnique({    where: { id: documentId },    include: { project: true },  });  if (!document) throw new NotFoundError();
  if (!can(session, 'publish', 'document', {    authorId: document.authorId,    status: document.status,    projectId: document.projectId,    departmentId: document.project.departmentId,  })) {    throw new ForbiddenError();  }  // ... publish logic}

Reading a document: viewer unconditional + author ownership:

typescript
// After: single ABAC checkexport async function getDocument(documentId: string) {  const session = await requireSession();  const document = await db.document.findUnique({    where: { id: documentId },    include: { project: true },  });  if (!document) throw new NotFoundError();
  if (!can(session, 'read', 'document', {    authorId: document.authorId,    status: document.status,    projectId: document.projectId,    departmentId: document.project.departmentId,  })) {    throw new ForbiddenError();  }
  return document;}

The service layer's structure from Post 102 is unchanged. Only the decision engine inside the permission checks evolved.

Common Pitfalls

  1. 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.

  2. AND vs. OR confusion: Multiple conditions on a single PermissionEntry use AND logic (all must pass). For OR logic, create separate entries for the same resource-action pair. The editor policy demonstrates this: unconditional read is a separate entry from conditional create/update/publish.

  3. 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.

  4. Unconditional entry precedence: If a role has both conditional and unconditional entries for the same action-resource pair, the unconditional one matches first (returning true immediately). This is correct for the editor's read access, but can be confusing if entry order is not considered.

  5. 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

AspectRBAC (Post 103)ABAC (Post 104)
Decision functioncan(role, resource, action) + helperscan(user, action, resource, data?)
Ownership checkcanModifyDocument() helperCondition: doc.authorId === user.userId
Department scopingcanEditInDepartment() helperCondition: user.deptId === doc.deptId
Resource status checkNot expressibleCondition: doc.status === 'draft'
Adding a contextual ruleNew helper function + find all call sitesAdd condition to policy builder
Policy visibilityPermission matrix + scattered helpersAll in policy builder
Type safety of conditionsNone (helpers are imperative)Generic conditions typed to resource
Service method changesOne can() + multiple if blocksOne can() call

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-only on 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:

typescript
// Test: author can update own documentexpect(can(  { userId: 'u1', role: 'author' },  'update',  'document',  { authorId: 'u1', status: 'draft', projectId: 'p1', departmentId: 'd1' })).toBe(true);
// Test: author cannot update someone else's documentexpect(can(  { userId: 'u1', role: 'author' },  'update',  'document',  { authorId: 'u2', status: 'draft', projectId: 'p1', departmentId: 'd1' })).toBe(false);
// Test: admin can update any document (no data needed)expect(can(  { userId: 'u1', role: 'admin' },  'update',  'document')).toBe(true);
// Test: editor can read any document (unconditional read)expect(can(  { userId: 'u1', role: 'editor', departmentId: 'd1' },  'read',  'document',  { authorId: 'u2', status: 'published', projectId: 'p1', departmentId: 'd2' })).toBe(true);
// Test: editor cannot update document in different departmentexpect(can(  { userId: 'u1', role: 'editor', departmentId: 'd1' },  'update',  'document',  { authorId: 'u2', status: 'draft', projectId: 'p1', departmentId: 'd2' })).toBe(false);

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

CriterionRBACABAC (App-Level)External Engine
Permissions depend onRole onlyRole + attributesRole + attributes + relationships
Number of contextual rules03-2020+ or cross-service
Where policies liveCode (TypeScript map)Code (builder/conditions)External policy store
Evaluation locationIn-process, syncIn-process, syncNetwork call, async
Type safetyFull (TypeScript)Full (TypeScript generics)Partial (policy language)
Latency per checkMicrosecondsMicrosecondsMilliseconds (network)

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.

typescript
// Preview -- full implementation in Post 105// Field-level: which fields can this user see?const fields = getVisibleFields(session, 'document');// -> ['title', 'content'] for author// -> ['title', 'content', 'internalNotes'] for admin
// Database-level: push conditions into the queryconst documents = await db.document.findMany({  where: toWhereClause(session, 'document', 'read'),  // -> { departmentId: session.departmentId } for editor  // -> { authorId: session.userId } for author  // -> {} for admin});

References

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.

Progress4/6 posts completed

Related Posts