Why RBAC Isn't Enough: Building an ABAC Policy Engine with Spring Security

The article explains why RBAC fails for complex resource and context permissions, introduces ABAC with SpEL expressions, shows Spring Security integration via PermissionEvaluator, policy storage using SpEL strings, a deny-first policy engine, real-world scenarios (department orders, document visibility, time-based access), list permissions via SQL filtering, hot reload with Caffeine/Redis, and pitfalls like self-invocation and DB calls in policies.

Xiaolin Talks Programming
Xiaolin Talks Programming
Xiaolin Talks Programming
Why RBAC Isn't Enough: Building an ABAC Policy Engine with Spring Security

ABAC Is Just Boolean Expressions on Attributes

Unlike RBAC, which binds roles to permissions, ABAC breaks an access request into four parts: who is requesting, what resource, what action, and the environment context. Both subject and resource become attribute maps; a policy expression evaluates to true or false. For example,

subject.department == resource.department && action == "read"

expresses "users can read only their department's resources." Adding a time constraint is trivial in ABAC:

subject.department == resource.department && action == "read" && environment.hour >= 9 && environment.hour <= 18

. The core insight: abstract conditions into attribute pairs and execute them in a policy engine.

Spring Security Extension Points and Configuration Pitfalls

Method-level security uses @PreAuthorize("hasPermission(#orderId, 'Order', 'read')"), which delegates to a PermissionEvaluator implementation. The article provides a full AbacPermissionEvaluator that forwards to a PolicyEngine. Key configuration for Spring Security 6.x uses @EnableMethodSecurity and a custom MethodSecurityExpressionHandler bean that injects the AbacPermissionEvaluator. Two pitfalls are highlighted: the targetId parameter in hasPermission(Authentication, Serializable, String, Object) must be Serializable, not Object, otherwise compilation fails; and SpEL parameter references like #orderId require the -parameters compiler flag or explicit @Param annotations.

Policy Storage: Prefer SpEL Strings Over Custom Condition Trees

Many ABAC frameworks model policies as JSON condition trees (e.g.,

{"conditions": {"all": [{"equal": {"subject.department": "resource.department"}}]}}

), but implementing a parser for and, or, not, comparators, collections, and date ranges essentially rebuilds a rule engine. The pragmatic choice is to store SpEL strings directly. A Policy object holds policyId, resourceType, action, effect (ALLOW/DENY), and condition (a SpEL expression). Example stored policy:

{"policyId": "order-read-dept-only", "resourceType": "Order", "action": "read", "effect": "ALLOW", "condition": "#subject.deptId == #resource.deptId"}

. At evaluation time, a StandardEvaluationContext receives three variables: #subject, #resource, #environment (each a Map). Because Spring's default property accessor doesn't handle Maps well, a MapAccessor is added so expressions can use dot notation ( resource.deptId) instead of bracket notation.

PolicyEngine Decision Flow

The engine uses a simple deny-first algorithm: (1) collect all policies matching the resource type and action; (2) evaluate DENY policies first — if any condition is true, deny immediately; (3) evaluate ALLOW policies — if any condition is true, allow; (4) default deny. This provides a clear escape hatch: a global DENY can override all dynamic policies. The core evaluate method resolves subject attributes from the authentication principal, resource attributes via a ResourceAttributeResolver registry, and environment attributes from an EnvironmentContext. Performance note: ResourceAttributeResolver must not hit the database on every decision. For single-resource operations, a short-lived cache in the resolver is acceptable; for list queries, the engine is bypassed entirely in favor of SQL filtering (see below).

Three Real-World Business Scenarios

Department Order Isolation

Policy: #subject.deptId == #resource.deptId (ALLOW). The OrderResourceResolver loads the order's deptId into resource attributes. Business method stays clean with @PreAuthorize("hasPermission(#orderId, 'Order', 'read')"). Caveat: passing only the orderId forces a DB lookup before the service call; for high-frequency endpoints, pass the full order object or ensure cache hits at the controller layer.

Document Visibility Control

Documents have a visibility field (PUBLIC, DEPARTMENT, PRIVATE). Three ALLOW policies cover each case:

#resource.visibility == 'PUBLIC'
#resource.visibility == 'DEPARTMENT' && #resource.deptId == #subject.deptId
#resource.visibility == 'PRIVATE' && #resource.ownerId == #subject.userId

In RBAC this would explode into document:read:public, document:read:department, document:read:private permissions, and adding a classification dimension would be unmanageable.

External Collaborator Time Restriction

Policy:

#environment.dayOfWeek <= 5 && #environment.currentTime >= '09:00' && #environment.currentTime <= '18:00'

(ALLOW). EnvironmentContext pre-computes dayOfWeek and currentTime to avoid SpEL calls like T(java.time.LocalDateTime).now(). The article warns against letting business users write raw SpEL; a thin admin UI maps form fields to SpEL underneath.

List Data Permissions: Separate SQL Filtering

Evaluating ABAC per row for a 1,000-row list is infeasible. The solution splits "list-type" permissions from single-resource permissions. A list policy stores a SQL fragment:

{"policyId": "order-list-dept", "resourceType": "Order", "action": "list", "effect": "ALLOW", "sql": "dept_id = {deptId}"}

. At the MyBatis layer, an interceptor on Executor.query detects list-permission policies and injects the WHERE clause. The author recommends using a SQL parser (e.g., MyBatis-Plus's DataPermissionHandler) to modify the AST rather than string concatenation, which breaks with subqueries and aliases.

Policy Hot Reload: Good-Enough Approach

Policies live in a database and are cached locally with Caffeine. On admin updates, a Redis Pub/Sub policy:refresh event triggers reload and increments a local version number. A fallback 5-minute polling compares version numbers to catch missed messages. Decision caches are tied to the policy version and use a short TTL (default 8 seconds). This isn't real-time, but policy changes are infrequent and 8-second propagation is acceptable for operations.

Hard-Won Pitfall Avoidance

Spring Security method security doesn't apply to internal self-invocation (e.g., this.getDoc(id)). Must inject a self-proxy or move the method to another bean.

Never query the database inside a policy expression. SpEL can call Spring beans, but @someBean.loadResource(#id) hides severe performance problems. All required attributes must be pre-loaded into the evaluation context; the engine should only compute.

DENY must be able to override everything. A global DENY acts as a kill switch for misconfigured policies, avoiding emergency code changes and restarts.

Log every decision. Record policyId, result, caller, resource, and matched condition. Without logs, debugging "why can this user see this data?" becomes a manual ordeal.

Closing Thoughts

ABAC is not a silver bullet; RBAC remains excellent for functional permissions. ABAC shines where resource attributes and context judgments are complex. Adoption advice: pick your most painful resource (orders, documents), run a standalone ABAC engine alongside RBAC, and avoid big-bang migration. The hardest part isn't the engine code — it's abstracting your business into a clean, understandable attribute model, which often takes longer than the permission refactor itself.

Original Source

Signed-in readers can open the original source through BestHub's protected redirect.

Sign in to view source
Republication Notice

This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactadmin@besthub.devand we will review it promptly.

SpELRBACData PermissionsABACSpring SecurityPolicy EnginePermissionEvaluatorAttribute-Based Access Control
Xiaolin Talks Programming
Written by

Xiaolin Talks Programming

Focuses on sharing original technical insights. Senior architect at a top tech company with years of experience in technical architecture and management, and extensive interview experience. Offers one-on-one technical coaching, guiding you from beginner to architecture design to technical management. Follow for free learning resources. Free one-on-one interview coaching to help you land offers quickly.

0 followers
Reader feedback

How this landed with the community

Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.