Advanced ABAC: Field-Level Permissions and DB Integration
Extend ABAC with environment-based rules, field-level read and write permissions, and automatic database query filtering that eliminates duplicate permission logic.
Abstract
Post 104 built a type-safe ABAC policy engine with a builder pattern. The can(user, action, resource, data?) function evaluates subject, resource, and action attributes through declarative conditions. Ownership, department scoping, and resource status are policy rules, not scattered helper functions.
Two gaps remain. First, can() returns a boolean; the user either sees the entire resource or nothing. There is no way to control which fields a user can read or write. An admin sees internalNotes; an author should not. An editor can update content but not publishedAt. Second, all conditions evaluate in application memory against already-loaded objects. For list views, the application loads all records, calls can() on each, and discards the ones that fail. The database should do that filtering.
The NIST SP 800-162 model also defines a fourth attribute category, environment, that Post 104 introduced but left as a forward reference. Time-based access, IP restrictions, and feature flags belong inside the policy engine, not as separate middleware checks.
This post closes all three gaps: environment conditions enter the type system, field-level permissions control read and write visibility per role, and ABAC conditions convert into database where clauses for query-level enforcement.
Environment-Based Rules
Extending the Type System
Post 104's Condition<R> type receives (user: User, data: ResourceDataMap[R]) => boolean. Environment attributes (time, IP address, locale, feature flags) are external to both subject and resource. They need their own type.
The can() signature has evolved across the series:
The env parameter is optional. Existing conditions from Post 104 continue working unchanged. Making environment optional preserves backward compatibility while completing the NIST four-attribute model.
Practical Examples
Time-based restriction: billing operations only during business hours:
IP-based restriction: admin operations restricted to office network:
Feature flag gating: new functionality behind feature flags:
Note: Environment conditions follow the same AND logic as other conditions in the builder. All conditions in an array must pass for the permission to be granted.
Service Layer Integration
The service layer constructs the Environment object from the request context. It builds it once per request, then passes it through:
A key design decision: environment data comes from the request, not from the database. It should not be part of ResourceDataMap. Mixing request context with resource data breaks the NIST model and makes conditions harder to reason about.
Field-Level Read Permissions
The Problem
Consider the document resource with these fields: id, title, content, status, authorId, departmentId, internalNotes, reviewComments, publishedAt.
Different roles need different field visibility:
Without field-level permissions, the service returns the full object and trusts the frontend to hide fields. This is security by obscurity. The API response contains sensitive data regardless of what the UI renders.
Type System Extension
The field permission system maps resources to their field names with conditions:
Add field permissions to the builder:
Convention: absence of canReadFields entries means all fields are visible for that role, provided the role has read access via can().
The getVisibleFields() Function
The filterFields() utility takes a resource object and a list of visible fields, returning a new object with only those fields:
Service Layer Integration
UI Field Hiding
On the client side, use getVisibleFields() to conditionally render fields:
UI hiding is for UX, not security. The service layer already filtered the fields in the API response. The client cannot render what it did not receive. As established in Post 102: the server is the security boundary, the client is the UX convenience.
Field-Level Write Permissions
Builder Extension
Write permissions are distinct from read permissions. An editor might read internalNotes but not write to it. A moderator might write status but not content.
The write permission matrix:
The pickPermittedFields() Function
Silent Drop vs. Error
Two approaches when a user submits a forbidden field:
- Silent drop: Strip the field and proceed. The user does not know the field was ignored. Simpler for the client, but hides errors.
- Error: Reject the entire submission with a 403. More explicit, but requires the client to know which fields are allowed before submitting.
In my experience, silent drop for APIs, error for admin contexts works well. The service layer chooses which to use based on the operation's sensitivity.
Create and Update Application
Create Flow
On create, there is no existing resource data. Conditions that reference resource attributes (ownership, department) cannot evaluate. Field permissions for create should use unconditional entries:
authorId and status are system-managed fields, never from user input. Even if the client sends authorId, pickPermittedFields() strips it because it is not in the author's write fields.
Update Flow
On update, existing resource data is available for conditions:
Conditional Form Rendering
The UI can use write field permissions to conditionally render form fields:
Form rendering is UX. The server-side pickPermittedFields() is the security boundary. Even if a malicious client adds hidden fields to the form submission, pickPermittedFields() strips them.
Automatic DB Query Filtering
The ConditionDescriptor Approach
Post 104's conditions are opaque functions. They evaluate in memory but cannot be translated to SQL. For list views ("show me all documents I can read"), loading all records and filtering with can() in a loop is wasteful.
The idea: provide a declarative descriptor alongside each condition function that describes what it does in database terms:
This syntax is similar to MongoDB's query format and CASL's conditions. Prisma, Drizzle, and other ORMs can consume it with a simple adapter.
Updated builder with descriptors:
The toWhereClause() Function
Warning: An empty object
{}andnullhave different semantics.{}means "no filter; return all records" (admin/viewer case).nullmeans "no matching permission; deny access." Returning{}instead ofnullfor a denied role would return all records. This distinction is critical for security.
ORM Adapter Layer
The WhereClause<R> is ORM-agnostic. Adapters convert it to ORM-specific syntax:
Complete service layer integration for list views:
What Cannot Be Pushed to the Database
Not all conditions are translatable. Complex logic, cross-resource conditions, and environment-based checks generally stay in memory:
(user, doc, env) => env.currentTime.getHours() >= 9: involves runtime context, not a resource attribute(user, doc) => doc.tags.some(t => user.expertise.includes(t)): array intersection logic(user, doc) => doc.wordCount > 1000 && user.role === 'senior_editor': compound logic mixing subject and resource
The toFilter field is optional. If omitted, the condition falls back to in-memory evaluation. The system degrades gracefully: translatable conditions become WHERE clauses, untranslatable ones require post-fetch filtering.
This is the same pattern used by production authorization systems. OPA's Compile API calls it "partial evaluation." Cerbos's PlanResources API returns three outcomes: ALWAYS_ALLOWED, ALWAYS_DENIED, or CONDITIONAL with an AST. The lightweight TypeScript version here follows the same principle with less infrastructure.
The Unified System
The complete system makes the policy builder a single source of truth. Changing a condition in the builder automatically propagates to every layer:
can(): record-level access controlgetVisibleFields(): field-level read permissionspickPermittedFields(): field-level write permissionstoWhereClause(): database query filtering
No service method, React component, or database query needs to change.
Example: "Editors can now also see reviewComments if the document is in review status." One change in the builder:
getVisibleFields()now returnsreviewCommentsfor editors whenstatus === 'review'- The React component already has
{fields.includes('reviewComments') && ...}; it renders automatically - The API response already uses
filterFields(); it includes the field automatically - No service method changes. No component changes. No database query changes.
ABAC Pros and Cons
When ABAC Excels
- 3+ contextual rules per resource: If permissions depend on ownership, department, status, time, and other attributes, ABAC eliminates the helper function proliferation from Post 103.
- Field-level visibility requirements: When different roles see different fields of the same resource, field permissions are cleaner than ad-hoc field stripping.
- Database-level enforcement needed: When list views must be efficient, the
toWhereClause()pattern eliminates in-memory filtering. - Audit requirements: A centralized policy builder is easier to audit than scattered helper functions. "What can an editor do?" is answerable by reading one section of the builder.
- Policy changes are frequent: When business rules change often, modifying one condition in the builder is faster and safer than updating all service methods and components.
When ABAC Is Overkill
- Simple role-based access: If permissions depend only on role with no contextual conditions, RBAC from Post 103 is simpler and equally correct.
- Small team, few resources: With 2-3 resources and 3-4 roles, the ABAC type system overhead (generics, builders, condition descriptors) may exceed the complexity it saves.
- No field-level requirements: If all users see all fields of a resource, the field permission layer adds complexity without benefit.
- Prototyping stage: ABAC's type system makes refactoring harder. During rapid prototyping where resource shapes change frequently, simpler checks are more practical.
Tip: Start with RBAC (Post 103). Add ABAC conditions only when helper functions start proliferating. Add field-level permissions only when different roles need different field visibility. Add DB query filtering only when list views load too many records.
Decision Framework
Common Pitfalls
- Forgetting field filtering on nested resources: If a document has a
projectrelation, loadingdocument.projectbypasses project field permissions. ApplyfilterFields()to nested resources as well. - Condition descriptor drift: The
evaluatefunction andtoFilterdescriptor must produce equivalent results. Test both against the same truth table to catch drift. - Overusing field-level permissions: Not every resource needs field-level control. The default (no field entries = all fields visible) keeps things simple for resources without restrictions.
- Confusing
nulland{}: IntoWhereClause(),{}means "no filter" (all records) andnullmeans "no access" (deny). Getting this wrong is a security vulnerability. - Missing
selectin DB queries:toWhereClause()filters rows, not columns. For true DB-level field enforcement, combine it with column selection. In practice, application-levelfilterFields()is often sufficient.
What's Next
The permission system now covers record-level access (can()), field-level visibility (getVisibleFields(), pickPermittedFields()), and database-level enforcement (toWhereClause()). Environment conditions complete the NIST four-attribute model.
Post 106 addresses the remaining production concerns: multi-tenancy (tenant isolation as a first-class permission concept), permission library evaluation (CASL, Oso, Cerbos, Cedar; when to use a library vs. custom code), and the final architectural decision framework for choosing the right authorization approach based on team size, regulatory requirements, and system complexity.
References
- NIST SP 800-162: Guide to Attribute Based Access Control (ABAC) - The foundational NIST standard defining environment attributes as the fourth ABAC category alongside subject, resource, and action
- CASL v6 - Restricting Fields Access - Official CASL documentation on field-level restrictions using
permittedFieldsOf()and field arrays in rule definitions - CASL - Isomorphic Authorization JavaScript Library - The library that pioneered the
can()+ field restriction pattern in JavaScript/TypeScript with MongoDB-style conditions - Cerbos - Filtering Database Results with Query Plans - How Cerbos PlanResources API returns an AST that adapters convert into Prisma, MongoDB, or SQL queries
- Cerbos Prisma Integration V2.0 - Converting authorization query plans into Prisma
whereclauses with nested field support - OPA - Write Policy in OPA, Enforce Policy in SQL - The OPA partial evaluation pattern that compiles Rego policies into SQL WHERE clauses
- Permit.io - Why Data Filtering Matters for Database Authorization - Analysis of source-level vs. gateway-level data filtering patterns across OPA, Cerbos, and SpiceDB
- Drizzle ORM - Dynamic Query Building - Official Drizzle documentation on
$dynamic()for composable query building - RBAC vs ABAC: Differences and When to Use (Oso) - Decision criteria for when ABAC complexity is justified vs. when RBAC is sufficient
- ZenStack - Three Ways to Secure Database APIs - Comparison of database-level (RLS), ORM-level, and application-level authorization strategies
- TypeScript Generics Documentation - Official reference for the generic constraint patterns used throughout the ABAC type system
- OWASP Authorization Cheat Sheet - Best practices including centralized authorization, deny by default, and fine-grained access control recommendations
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.