Attribute-Based Access Control (ABAC)
When roles are not enough: the PDP/PEP model, Open Policy Agent and Rego, integrating OPA with Spring, and deciding between ABAC and a richer RBAC.
On this page
RBAC answers "what is this user". ABAC answers "should this user do this action on this resource right now" — using attributes of all four. It is more expressive and more machinery, so the first question is whether you actually need it.
Key Takeaways
- The decision inputs are subject, resource, action and environment attributes.
- PDP decides, PEP enforces, PIP supplies data, PAP manages policy.
- OPA as a sidecar keeps evaluation local and fast.
- Policy becomes versioned, testable, reviewable code — that is the real win.
- Many "we need ABAC" problems are solved by modelling permissions rather than job titles.
The model
The separation is the point. Authorisation rules stop being scattered if statements across a
codebase and become one reviewable artefact that security can audit and that changes without a
service deploy.
A Rego policy
package acme.expenses
import rego.v1
default allow := false
# Own expenses are always readable.
allow if {
input.action == "read"
input.resource.owner_id == input.subject.id
}
# Managers read anything in their own department.
allow if {
input.action == "read"
"MANAGER" in input.subject.roles
input.resource.department_id == input.subject.department_id
}
# Approval needs department match, a value limit, and business hours.
allow if {
input.action == "approve"
"MANAGER" in input.subject.roles
input.resource.department_id == input.subject.department_id
input.resource.amount_minor_units <= approval_limit
business_hours
}
approval_limit := 500000 if "SENIOR_MANAGER" in input.subject.roles
approval_limit := 100000 if not "SENIOR_MANAGER" in input.subject.roles
business_hours if {
hour := time.clock(time.now_ns())[0]
hour >= 8
hour < 20
}
# Never approve your own expense, whatever the role.
deny contains msg if {
input.action == "approve"
input.resource.owner_id == input.subject.id
msg := "cannot approve your own expense"
}Read that against the equivalent Java: nested conditionals across a service, a limit constant, a time check, and a self-approval guard someone will eventually forget. The Rego version is the whole rule in one place, in a language a compliance reviewer can follow.
Testing policy
package acme.expenses
test_manager_can_approve_within_limit if {
allow with input as {
"action": "approve",
"subject": {"id": "u1", "roles": ["MANAGER"], "department_id": "d1"},
"resource": {"owner_id": "u2", "department_id": "d1", "amount_minor_units": 50000}
}
}
test_manager_cannot_approve_own_expense if {
count(deny) > 0 with input as {
"action": "approve",
"subject": {"id": "u1", "roles": ["MANAGER"], "department_id": "d1"},
"resource": {"owner_id": "u1", "department_id": "d1", "amount_minor_units": 50000}
}
}
test_manager_cannot_approve_other_department if {
not allow with input as {
"action": "approve",
"subject": {"id": "u1", "roles": ["MANAGER"], "department_id": "d1"},
"resource": {"owner_id": "u2", "department_id": "d2", "amount_minor_units": 50000}
}
}opa test policies/ -vRun this in CI. Policy is code with security consequences, and an untested policy change is exactly as risky as an untested authorisation change in Java — with the added hazard that it deploys separately and may not go through the same review.
Integration
@Service
public class OpaAuthorizationService {
private final RestClient opa; // http://localhost:8181 — the sidecar
public boolean isAllowed(Authentication auth, String action, Object resource) {
var input = Map.of("input", Map.of(
"action", action,
"subject", Map.of(
"id", auth.getName(),
"roles", auth.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.map(a -> a.replace("ROLE_", "")).toList(),
"department_id", departmentOf(auth)),
"resource", resource));
try {
var response = opa.post()
.uri("/v1/data/acme/expenses")
.body(input)
.retrieve()
.body(OpaResponse.class);
return response != null
&& Boolean.TRUE.equals(response.result().allow())
&& response.result().deny().isEmpty();
} catch (RestClientException ex) {
// Fail CLOSED. An authorisation service that cannot decide must
// not default to permitting — unlike a rate limiter, where the
// opposite is usually right.
log.error("policy evaluation failed", ex);
return false;
}
}
}@PreAuthorize("@opa.isAllowed(authentication, 'approve', #expense)")
public void approve(Expense expense) { }The fail-closed decision is important and differs from most availability trade-offs. A rate limiter that cannot reach Redis should usually allow the request; an authorisation service that cannot decide must deny, because the alternative is granting access nobody authorised.
What this integration does not solve is the list endpoint. Calling isAllowed per row turns a page of
fifty expenses into fifty evaluations, and doing it after the query means loading rows the caller was
never allowed to see. OPA's partial evaluation answers the question from the other end — given this
subject and this action, what must a resource satisfy? — and hands back conditions you can compile into
a WHERE clause. It is more work to wire up, and it is the difference between ABAC that survives list
views and ABAC that gets quietly bypassed on precisely the endpoints where leaks are widest.
Budget for the call either way. Evaluation in a sidecar is usually well under a millisecond, but the loopback round trip and the JSON serialisation on both sides are not free, and they land on every guarded request. Decisions that depend only on token claims are cheaper left in Spring; send OPA the ones that genuinely need resource attributes.
Policy distribution
services:
registry:
url: https://policies.acme.com
bundles:
authz:
resource: bundles/authz.tar.gz
polling:
min_delay_seconds: 30
max_delay_seconds: 60
decision_logs:
console: true # every decision, for auditThe bundle mechanism is what makes this operationally sensible. Policies live in Git, CI builds and publishes a bundle, and every OPA sidecar polls and hot-swaps it — no service restart, no coordinated deploy, and a Git history of every authorisation change with an author and a review.
Decision logs are the audit trail. Every allow and deny with its full input, which is exactly what an auditor asks for and what is otherwise very hard to reconstruct.
Do you need it?
Before adopting ABAC, check whether the problem is RBAC done badly. Roles named after job titles,
permissions hard-coded into role checks, and no resource-level concept are all fixable within RBAC by
modelling capabilities and adding a PermissionEvaluator.
ABAC earns its complexity when rules genuinely depend on relationships (this manager, this department), on resource attributes (classification, value, age), on environment (time, location, device posture), or when policy changes frequently and must not require a deploy. Regulated domains where an auditor needs to read the rules are another strong case.
If none of those apply, a well-modelled RBAC with permissions and an ownership check is simpler, faster and easier to reason about — and you can adopt OPA later for the specific decisions that outgrow it.
What to take away
Separate the decision from the enforcement, run OPA as a sidecar so evaluation stays local, and fail closed. Keep policies in Git with tests running in CI, distribute them as bundles, and turn on decision logs for audit. Then be honest about whether your RBAC was the problem before adding a second authorisation system.
Frequently Asked Questions
Do I need ABAC or is my RBAC just badly designed?
Should OPA run as a sidecar or a central service?
How do I test policies?
Related tutorials
- Role-Based Access Control (RBAC)Authorisation with roles: HTTP versus method security, role hierarchies, @PreAuthorize and @PostAuthorize, custom PermissionEvaluator, and where RBAC stops being enough.
- Cryptography & Encryption in SpringApplied cryptography without inventing anything: choosing AES-GCM, envelope encryption, encrypting database columns with an AttributeConverter, and Vault Transit for key management.
- LDAP & Active Directory IntegrationAuthenticating against a corporate directory: LDAP structure, bind versus password comparison, ActiveDirectoryLdapAuthenticationProvider, group-to-role mapping and LDAPS.
- SAML 2.0 AuthenticationEnterprise SSO with SAML: the SP-initiated flow step by step, RelyingPartyRegistration, the assertion checks that matter, metadata exchange and single logout.