Fine-Grained Entity Authorization with Spring Security: A Strategy-DSL Architecture
The Oma Riista platform manages hunting permits, club memberships, game diary entries, and territory data for roughly 190,000 registered hunters through Finland's state wildlife agency. A person's access to a given record depends not just on their system role but on their current organizational memberships, their role within those organizations, and sometimes the spatial and temporal context of the record itself. This post describes the authorization architecture that handles this cleanly — without putting conditional logic in service methods or letting authorization rules sprawl across the codebase.
The Problem with Coarse-Grained Role Checks
Spring Security's method-level security — @PreAuthorize("hasRole('ADMIN')") — works well when access is purely role-based. Admins can do everything, users can do read-only, moderators have some middle ground. Most simple applications fit this model.
Oma Riista does not. The question is rarely just "does this person have role X?" The questions are:
- Can this person update this specific
Occupationrecord? Only if it is their own occupation, or they hold a coordinating role in the organization that issued it. - Can this person read this hunting club's group structure? Only if they are a member of that club — not just any club.
- Can this person link a game diary entry to a hunting day? Only if they are a member of the group that owns that hunting day, in a role that has hunting leadership permission.
- Can this person delete a hunting club group? Only if the group has no recorded harvests and they hold the club contact person role.
Expressing this logic with Spring's built-in expression evaluators leads quickly to either enormous @PreAuthorize expressions with method calls, or service methods that start with a block of if-statements before getting to the actual business logic. Neither is maintainable. The authorization logic becomes invisible — scattered across method annotations and service implementations, untestable in isolation.
The architecture I'll describe centralises all authorization logic in one place per entity type, makes it declarative where possible, and hooks cleanly into Spring Security without requiring service methods to know anything about permission evaluation.
The Core Abstraction: EntityAuthorizationStrategy
The entry point is a simple interface:
public interface EntityAuthorizationStrategy<T> {
Class<T> getEntityClass();
boolean hasPermission(T target, Enum<?> permission,
Authentication authentication);
}
One implementation per entity type. Each implementation is a Spring component. The EntityPermissionEvaluator collects all implementations at startup through Spring's dependency injection and builds a registry keyed by entity class:
@Component
public class EntityPermissionEvaluator {
private final Map<Class, EntityAuthorizationStrategy> registry;
@Autowired
public EntityPermissionEvaluator(List<EntityAuthorizationStrategy> strategies) {
this.registry = strategies.stream()
.collect(toMap(EntityAuthorizationStrategy::getEntityClass,
Function.identity()));
}
@Transactional(readOnly = true, propagation = Propagation.MANDATORY)
public boolean hasPermission(Authentication auth,
BaseEntity<?> entity,
Enum<?> permission) {
return Optional.ofNullable(registry.get(entity.getClass()))
.map(s -> s.hasPermission(entity, permission, auth))
.orElse(false);
}
}
The key contract: MANDATORY propagation. This means a transaction must already be open when authorization runs. That transaction is opened by the service method being secured. Authorization queries run within the same transaction — no separate connection, no risk of reading stale data due to a different transaction isolation boundary.
AbstractEntityAuthorization: Template Method + DSL
Every concrete authorization class extends AbstractEntityAuthorization<T>, which provides two things: a declarative DSL for static permission assignments, and a two-phase evaluation engine that calls into subclass-provided entity-specific logic.
The DSL is configured in the constructor:
@Component
public class HuntingClubGroupAuthorization
extends AbstractEntityAuthorization<HuntingClubGroup> {
public HuntingClubGroupAuthorization() {
// System administrators and moderators can do everything
allowCRUD(ROLE_ADMIN, ROLE_MODERATOR);
// Club contact persons can manage their own groups
allowCRUD(SEURAN_YHDYSHENKILO);
// Club members and group members can read
allow(EntityPermission.READ,
SEURAN_JASEN, RYHMAN_METSASTYKSENJOHTAJA, RYHMAN_JASEN);
// Only admins, moderators, and club contacts can copy groups
allow(HuntingClubGroupPermission.COPY,
ROLE_ADMIN, ROLE_MODERATOR, SEURAN_YHDYSHENKILO);
}
@Override
protected void authorizeTarget(AuthorizationTokenCollector collector,
HuntingClubGroup group,
UserInfo userInfo) {
userAuthorizationHelper.getPerson(userInfo).ifPresent(person -> {
Organisation club = group.getParentOrganisation();
collector.addAuthorizationRole(SEURAN_YHDYSHENKILO,
() -> userAuthorizationHelper.isClubContact(club, person));
collector.addAuthorizationRole(SEURAN_JASEN,
() -> userAuthorizationHelper.isClubMember(club, person));
if (!group.isNew()) {
userAuthorizationHelper
.findValidRolesInOrganisation(group, person)
.forEach(collector::addAuthorizationRole);
}
});
}
}
The constructor reads like a policy matrix. The authorizeTarget() override answers the runtime question: does this specific person hold any of those roles with respect to this specific entity instance?
The Two-Phase Evaluation Engine
The hasPermission() method in AbstractEntityAuthorization evaluates authorization in two phases:
Phase 1 handles the common case efficiently. When an admin performs any operation, the check terminates at Phase 1 without touching the database. When a regular user operates on their own data, Phase 1 finds no matching global role and delegates to Phase 2.
The role hierarchy is configured in Spring Security and flows automatically. ROLE_ADMIN inherits ROLE_USER, so any permission granted to ROLE_USER is also granted to admins without explicit configuration in each authorization class.
The AuthorizationTokenHelper: Permission-to-Role Mapping
The DSL calls are backed by AuthorizationTokenHelper, which stores the static configuration as a map from permission names to sets of authorized role names:
// Internal structure
Map<String, Set<String>> authorizationData;
// "SEURAN_JASEN may READ HuntingClubGroup" is stored as:
// "fi.riista.security.EntityPermission.READ"
// → {"fi.riista.feature.organization.occupation.OccupationType.SEURAN_JASEN"}
// "ROLE_ADMIN may UPDATE" is stored as:
// "fi.riista.security.EntityPermission.UPDATE"
// → {"ROLE_ADMIN"}
Role names use canonical enum class names to guarantee uniqueness across the entire codebase. SEURAN_JASEN from OccupationType has a different canonical name than a hypothetical SEURAN_JASEN from another enum. This makes it safe to use the same token name across different enum types without collision.
Spring Security roles (those starting with ROLE_) are stored as-is since they come from Spring's authority representation. Domain-specific occupation types get their fully qualified enum canonical name. The permission check is then a simple set intersection:
boolean hasPermission(Enum<?> permission, Set<String> acquiredTokens) {
Set<String> permittedTokens =
authorizationData.getOrDefault(getPermissionName(permission), emptySet());
return !Sets.intersection(permittedTokens, acquiredTokens).isEmpty();
}
The AuthorizationTokenCollector: Lazy Conditional Evaluation
The AuthorizationTokenCollector accumulates tokens during Phase 2 evaluation. Its critical design constraint is lazy evaluation. The overload that takes a ConditionalAuthorization lambda:
public void addAuthorizationRole(Enum<?> role,
ConditionalAuthorization condition) {
// Already granted? Don't even check the condition.
if (hasPermission()) return;
// Is this token even relevant for the requested permission?
// If SEURAN_JASEN doesn't grant DELETE, don't check club membership
// when the request is for DELETE.
if (!authorizationTokenHelper.canAcceptRoleForPermission(permission, role)) {
return;
}
// Only now evaluate the (potentially database-hitting) condition
if (condition.applies()) {
addAuthorizationRole(role);
}
}
The two early-exit conditions are the key optimization. First: if a previous token already granted permission, no further conditions are evaluated. If club contact person access was granted, the subsequent club membership check is skipped entirely. Second: if the token being considered cannot grant the requested permission — SEURAN_JASEN is only allowed to READ, not DELETE — the condition is not evaluated even if we don't yet have permission. This avoids a database query that could not help.
In production this matters. An admin performing a bulk operation on group records never hits the organization membership queries. A user reading their own diary entries doesn't trigger group spatial intersection queries. The permission evaluation cost scales with the authorization complexity actually required — not with the worst case.
Extending the Permission Vocabulary
The base EntityPermission enum covers standard CRUD operations. Domain-specific entities need additional permissions that don't map to CRUD semantics:
// OccupationAuthorization: visibility control is not a CRUD operation
public enum OccupationPermission {
UPDATE_CONTACT_INFO_VISIBILITY
}
// HuntingClubGroupAuthorization: copying a group's configuration
public enum HuntingClubGroupPermission {
COPY,
LINK_DIARY_ENTRY_TO_HUNTING_DAY,
LINK_AUTOMATICALLY_OBSERVATION_TO_HUNTING_DAY
}
// GameDiaryEntryAuthorization: moderator-specific actions
public enum GameDiaryEntryPermission {
MODERATE_CONTENT
}
These custom enums are used identically to the built-in EntityPermission values in allow() calls and @PreAuthorize expressions. The DSL works with any Enum<?>. No registration step, no string literals, full type safety.
At the call site in a service or controller, the expression looks like:
@PreAuthorize("hasPermission(#group, 'COPY')")
public HuntingClubGroup copyGroup(@P("group") HuntingClubGroup group) {
// authorization enforced by AOP before this method runs
}
// Or for programmatic checks:
if (!evaluator.hasPermission(auth, occupation,
OccupationPermission.UPDATE_CONTACT_INFO_VISIBILITY)) {
throw new AccessDeniedException("...");
}
A Complex Case: Game Diary Entry Authorization
The most complex authorization in the system is for hunting diary entries — observations and harvests. The authorization depends on multiple factors simultaneously:
- Is this the author's own entry? (personal ownership)
- Is the entry linked to a hunting day? If so, who are the members of the group that owns that hunting day?
- If not linked, which hunting groups have a permit area that spatially intersects the shooting location, during the hunting year in which the entry was recorded?
- Does the person hold the right role within the relevant group?
The authorization class for this handles the layered decision with short-circuit evaluation at every level:
@Override
protected void authorizeTarget(AuthorizationTokenCollector collector,
T diaryEntry,
UserInfo userInfo) {
userAuthorizationHelper.getPerson(userInfo).ifPresent(person -> {
// Phase A: personal ownership and simple club roles
collectNonClubRoles(person, collector, diaryEntry);
// Short-circuit: if ownership or a simple role already grants permission,
// don't perform the expensive group resolution below
if (collector.hasPermission()) return;
// Phase B: find the relevant hunting group
if (diaryEntry.getHuntingDayOfGroup() != null) {
// Explicitly linked: resolve roles within that specific group
HuntingClubGroup group =
diaryEntry.getHuntingDayOfGroup().getGroup();
getClubAndGroupRoles(person, singleton(group))
.forEach(collector::addAuthorizationRole);
} else {
// Not linked: spatial + temporal group resolution
// findCandidateGroups queries:
// 1. Hunting year boundaries from the entry's point in time
// 2. Species-to-permit matching
// 3. PostGIS spatial intersection of group permit areas
getClubAndGroupRoles(person, findCandidateGroups(diaryEntry))
.stream()
.filter(PRIORITISED_OCCUPATION_TYPES::contains)
.min(Comparator.comparingInt(PRIORITISED_OCCUPATION_TYPES::indexOf))
.ifPresent(collector::addAuthorizationRole);
}
});
}
The PostGIS spatial query — checking which group's permit area contains the shooting location — is expensive. It is only executed when: (a) the entry is not explicitly linked to a hunting day, and (b) no simpler check has already granted permission. For the large majority of requests, the spatial query never runs.
Spring Security Integration
The integration point is Spring's PermissionEvaluator interface, registered through a custom MethodSecurityExpressionHandler:
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class MethodSecurityConfig extends GlobalMethodSecurityConfiguration {
@Override
protected MethodSecurityExpressionHandler createExpressionHandler() {
DefaultMethodSecurityExpressionHandler handler =
new CustomMethodSecurityExpressionHandler();
handler.setPermissionEvaluator(entityPermissionEvaluator);
handler.setRoleHierarchy(roleHierarchy);
return handler;
}
}
@Bean
public RoleHierarchy roleHierarchy() {
RoleHierarchyImpl hierarchy = new RoleHierarchyImpl();
// ADMIN and MODERATOR inherit USER permissions automatically
hierarchy.setHierarchy(
"ROLE_ADMIN > ROLE_USER\nROLE_MODERATOR > ROLE_USER");
return hierarchy;
}
Once wired, the standard Spring Security expression hasPermission(#entity, 'PERMISSION') in @PreAuthorize annotations routes through EntityPermissionEvaluator, which dispatches to the appropriate EntityAuthorizationStrategy. Service methods have no knowledge of how authorization is implemented — only of what permission they require.
Audit Logging
Every authorization decision — grant or deny — is logged by AuthorizationAuditListener in a structured format:
Granted 'UPDATE' permission for user [id=1234, username='jmatikainen']
for target object [type='HuntingClubGroup', id=5678]
Denied 'DELETE' permission for user [id=9876, username='akorhonen']
for target object [type='Occupation', id=3421]
Grants are logged at INFO level. Denials at WARN. This makes security-relevant events easy to surface in log aggregation without needing to parse complex payloads — the entity type, entity ID, permission, and user are all present in every line.
Testing
The architecture makes authorization classes straightforward to test in isolation. Each class is a plain Spring component with injected helpers. Unit tests instantiate the class, inject mock helpers, and call hasPermission() directly with a constructed Authentication and entity instance.
One important behavioral test: verifying that the short-circuit evaluation actually short-circuits. The AuthorizationTokenCollector tests include assertions that a second conditional authorization is never evaluated once permission is already granted:
@Test
public void secondConditionNotEvaluatedWhenAlreadyGranted() {
ConditionalAuthorization c1 = mock(ConditionalAuthorization.class);
ConditionalAuthorization c2 = mock(ConditionalAuthorization.class);
when(c1.applies()).thenReturn(true);
collector.addAuthorizationRole(SEURAN_JASEN, c1);
collector.addAuthorizationRole(SEURAN_JASEN, c2); // should never evaluate
assertTrue(collector.hasPermission());
verify(c1, times(1)).applies();
verify(c2, never()).applies(); // guaranteed short-circuit
}
Integration tests run against a real database (H2 in-memory with Flyway migrations) and exercise the full authorization chain from service method through EntityPermissionEvaluator to the strategy. These tests verify that organizational membership queries return the right results and that spatial intersection queries resolve to the expected groups.
What This Pattern Buys You
After working with this architecture on a codebase that has grown to dozens of entity types and hundreds of distinct permission rules, the benefits are tangible:
- Centralization: Every permission rule for an entity type lives in one class. When a product manager asks "who can delete a hunting club group?", the answer is a single file read, not a grep across service methods.
- Declarative construction-time config: Static role-based rules are visible as constructor calls. No need to trace runtime logic to understand the permission matrix.
- Performance safety: Expensive database queries behind the lazy conditional API are automatically skipped when simpler checks suffice. Optimizing authorization performance means understanding the decision graph, not spraying early returns across service methods.
- Composability: Custom permission enums extend the vocabulary cleanly. Adding a new permission type to an existing entity requires adding one line to the DSL configuration and one entry in the enum.
- Testability: Authorization logic is independently testable without needing to construct a full Spring context or a populated database.
Authorization logic that lives in service methods becomes invisible. Authorization logic that lives in a dedicated class per entity type becomes auditable — and auditable means trustworthy.
The pattern is applicable beyond this specific domain. Anywhere you have entity-level access control that depends on the relationship between the requesting user and the target entity, the strategy-DSL approach separates the "what is allowed" from the "who is this person relative to this entity" question in a way that scales as the permission model grows in complexity.