Skip to content
JavaAgentic

Type at least two characters. Try “RAG”, “pgvector” or “tool calling”.

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.

Advanced6 min readUpdated
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 enforcement point is in your application; the decision logic lives in policy that ships and versions separately.

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

policies/expenses.rego
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

policies/expenses_test.rego
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}
    }
}
terminal
opa test policies/ -v

Run 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

OpaAuthorizationService.java
@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;
        }
    }
}
Usage.java
@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

opa-config.yaml
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 audit

The 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?
Ask whether the rule depends on the relationship between the user and the specific resource, or on context like time and location. "Managers approve expenses" is RBAC. "Managers approve expenses in their own department under a limit that varies by region" is ABAC. Many teams reach for ABAC when the real fix was modelling permissions instead of job titles.
Should OPA run as a sidecar or a central service?
Sidecar. Policy evaluation is on the request path, so a network hop to a central service adds latency and a shared failure domain. A sidecar evaluates in microseconds against a locally cached bundle, and policy updates are distributed by the bundle mechanism.
How do I test policies?
Rego has a built-in test framework — write test_ rules asserting allow and deny for specific inputs and run opa test in CI. Policy is code with security consequences, so it deserves the same coverage discipline as the application.

Related tutorials