As an architect, how do you decide between roles, granular authorities, SpEL expressions, and custom AuthorizationManagers — and what are the maintainability trade-offs?
answer
- roles = coarse, authorities = fine
- avoid roles-as-permissions sprawl
- SpEL for small static combos only
- custom AuthorizationManager = typed + testable
- map external scopes at one boundary
basics
~20 sUse coarse roles for broad access tiers and granular authorities for fine permissions. Use SpEL for simple declarative combinations, but move complex, testable, or data-dependent logic into a custom AuthorizationManager. Avoid embedding business rules in stringly-typed SpEL.
solid answer
~50 sI model authorization on two axes. Roles (ROLE_-prefixed authorities) are coarse tiers; granular authorities like 'invoice:approve' are permissions. I prefer permission-based checks for anything the business tunes often, because roles-as-permissions leads to check sprawl and re-deploys when policy changes. For enforcement: dedicated methods (hasAuthority/authenticated) for simple rules; WebExpressionAuthorizationManager SpEL only for small declarative combinations (role AND ip). The moment logic references domain data (owner-of-resource), needs unit tests, or calls services, I write a custom AuthorizationManager — it's typed, injectable, and testable, unlike stringly-typed SpEL that only fails at request time. I also standardize the authority prefix convention globally (GrantedAuthorityDefaults), map external identities (JWT scopes → authorities) at a single boundary, and keep IP/network rules out of business logic where a proxy can silently break getRemoteAddr. The goal is policy that's auditable, testable, and changeable without recompiling.
code
java · 20 lines// Domain-aware rule -> custom AuthorizationManager, not SpEL
@Component
class DocumentOwnerAuthorizationManager
implements AuthorizationManager<RequestAuthorizationContext> {
private final DocumentService docs;
DocumentOwnerAuthorizationManager(DocumentService docs) { this.docs = docs; }
@Override
public AuthorizationDecision check(Supplier<Authentication> auth,
RequestAuthorizationContext ctx) {
String id = ctx.getVariables().get("id");
boolean owner = docs.isOwnedBy(id, auth.get().getName());
return new AuthorizationDecision(owner);
}
}
// wiring
http.authorizeHttpRequests(a -> a
.requestMatchers("/docs/{id}").access(ownerManager) // typed, testable
.requestMatchers("/admin/**").hasRole("ADMIN")); // simple -> methodgo deeper
Not expected; may only know roles exist.
Can pick roles vs authorities but not articulate manager-vs-SpEL trade-offs deeply.
Chooses mechanisms appropriately and knows custom AuthorizationManager exists for domain logic.
Frames authorization as an evolvable policy architecture: permissions-as-data, single identity-mapping boundary, typed testable managers, network rules at the edge, and auditability.
## The design axes **1. Roles vs granular authorities.** Both are just `GrantedAuthority` strings; the difference is modeling intent. - **Roles** (`ROLE_ADMIN`) — coarse, stable tiers. Few of them. Good for broad gates. - **Granular authorities/permissions** (`invoice:approve`, `user:read`) — fine-grained, may be many, often data-driven. Good when policy changes frequently. A classic anti-pattern is **roles-as-permissions**: exploding roles (`ROLE_CAN_EDIT_INVOICE`) that hardcode policy into code and require redeploys to change. Prefer assigning permissions to roles in data, and checking permissions in code (`hasAuthority('invoice:approve')`). ## The enforcement mechanisms, from simplest to most powerful 1. **Dedicated builder methods** — `permitAll`, `authenticated`, `hasRole`, `hasAuthority`, `hasAnyAuthority`. Clearest, least error-prone. Use for the majority of rules. 2. **SpEL via WebExpressionAuthorizationManager** — for small declarative combinations: `hasRole('ADMIN') and hasIpAddress('10.0.0.0/8')`. Cost: stringly-typed, validated only at evaluation, hard to unit-test, easy to typo. 3. **Custom `AuthorizationManager<RequestAuthorizationContext>`** (or method-security `AuthorizationManager`) — a typed bean implementing `check(...)` returning `AuthorizationDecision`. Injectable, testable, can consult the domain (e.g. is the principal the owner of the requested resource?). Combine with `AuthorizationManagers.anyOf/allOf`. ## Decision heuristics - Rule expressible in one built-in method → **use the method**. - Rule is a small static combination of authority/ip conditions → **SpEL manager**, but keep it short. - Rule references **domain data, services, or needs tests** → **custom AuthorizationManager**. Never bury owner-checks or tenant-scoping in a SpEL string. - Rule crosses many endpoints identically → centralize it (shared manager bean or method-security annotation) rather than copy-pasting SpEL. ## Cross-cutting architectural concerns - **Prefix convention** — decide once (default `ROLE_` or custom via a static `GrantedAuthorityDefaults` bean) and apply uniformly; inconsistent prefixes cause silent 403s. - **Identity mapping boundary** — external tokens (OIDC/JWT) carry scopes/claims. Map them to your internal authority vocabulary at a single converter (`JwtAuthenticationConverter` / `GrantedAuthoritiesMapper`) so the rest of the app speaks one authority language (note the default `SCOPE_` prefix for JWT scopes). - **Network rules** — `hasIpAddress` depends on `getRemoteAddr`, which breaks behind proxies unless forwarded headers are trusted; treat IP allow-listing as infrastructure/edge concern where possible, not scattered business SpEL. - **Auditability & testability** — typed managers can be unit-tested and logged; SpEL strings can't easily. For compliance-heavy systems, prefer managers/annotations you can assert on. - **Method vs web security** — coarse URL gates at the filter chain; fine, domain-aware checks with `@PreAuthorize`/`@PostAuthorize` (or `@PostFilter`) near the service, where you have the entity to reason about ownership. ## Summary trade-off Declarative SpEL is fast to write but stringly-typed and runtime-validated; custom AuthorizationManagers cost more code but are typed, testable, and safe to evolve. Push volatile policy into **data + permissions**, keep **code** checking permissions, and reserve SpEL for small, stable, declarative combinations.
- Why is 'roles-as-permissions' considered an anti-pattern?It hardcodes policy into role names and code, so every policy change means new roles, code changes, and redeploys. Modeling permissions as data assigned to roles, and checking permissions in code, lets policy evolve without recompiling.
- When would @PostAuthorize or @PostFilter be the right tool over a filter-chain rule?When the decision depends on the returned entity — e.g. only return a document if the loaded document's owner matches the principal. The object must be fetched first, so the check happens after method execution, which URL-level rules can't do.
- How do you keep authority vocabulary consistent when integrating OIDC/JWT?Map external scopes/claims to your internal authorities at a single boundary (JwtAuthenticationConverter or GrantedAuthoritiesMapper), normalizing prefixes, so the rest of the codebase checks one consistent set of authorities.