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.
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.userIdIn 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.
Signed-in readers can open the original source through BestHub's protected redirect.
This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactand we will review it promptly.
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.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.
