Spring Security ACL provides instance-based security for domain objects through a comprehensive Access Control List implementation
—
Spring Security ACL integrates seamlessly with Spring Security's expression-based access control through the AclPermissionEvaluator. This modern annotation-based approach provides clean, declarative security that's easy to understand and maintain.
Permission evaluation in Spring Security ACL works through:
@PreAuthorize - Check permissions before method execution@PostAuthorize - Check permissions after method execution@PostFilter - Filter collections based on permissions@PreFilter - Filter input collections based on permissionsAclPermissionEvaluator - Core integration point with ACL systemAclPermissionCacheOptimizer - Batch loading for performanceThe AclPermissionEvaluator implements Spring Security's PermissionEvaluator interface to bridge ACL data with expression evaluation:
package org.springframework.security.acls;
public class AclPermissionEvaluator implements PermissionEvaluator {
private final AclService aclService;
public AclPermissionEvaluator(AclService aclService) {
this.aclService = aclService;
}
// Check permission on domain object instance
@Override
public boolean hasPermission(Authentication authentication, Object domainObject, Object permission) {
// Implementation details...
}
// Check permission by object ID and type
@Override
public boolean hasPermission(Authentication authentication, Serializable targetId, String targetType, Object permission) {
// Implementation details...
}
// Configuration methods
public void setObjectIdentityRetrievalStrategy(ObjectIdentityRetrievalStrategy strategy);
public void setObjectIdentityGenerator(ObjectIdentityGenerator generator);
public void setSidRetrievalStrategy(SidRetrievalStrategy strategy);
public void setPermissionFactory(PermissionFactory factory);
}@Configuration
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class MethodSecurityConfig {
@Bean
public MethodSecurityExpressionHandler methodSecurityExpressionHandler(AclService aclService) {
DefaultMethodSecurityExpressionHandler handler = new DefaultMethodSecurityExpressionHandler();
handler.setPermissionEvaluator(new AclPermissionEvaluator(aclService));
return handler;
}
}@Configuration
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class AdvancedMethodSecurityConfig {
@Bean
public AclPermissionEvaluator aclPermissionEvaluator(AclService aclService) {
AclPermissionEvaluator evaluator = new AclPermissionEvaluator(aclService);
// Custom strategies
evaluator.setObjectIdentityRetrievalStrategy(objectIdentityRetrievalStrategy());
evaluator.setSidRetrievalStrategy(sidRetrievalStrategy());
evaluator.setPermissionFactory(permissionFactory());
return evaluator;
}
@Bean
public MethodSecurityExpressionHandler methodSecurityExpressionHandler(
AclPermissionEvaluator permissionEvaluator,
AclPermissionCacheOptimizer cacheOptimizer) {
DefaultMethodSecurityExpressionHandler handler = new DefaultMethodSecurityExpressionHandler();
handler.setPermissionEvaluator(permissionEvaluator);
handler.setPermissionCacheOptimizer(cacheOptimizer);
return handler;
}
@Bean
public AclPermissionCacheOptimizer permissionCacheOptimizer(AclService aclService) {
return new AclPermissionCacheOptimizer(aclService);
}
}Use @PreAuthorize to check permissions before method execution:
@Service
public class DocumentService {
// Check READ permission on document object
@PreAuthorize("hasPermission(#document, 'READ')")
public Document viewDocument(Document document) {
return document;
}
// Check WRITE permission using object parameter
@PreAuthorize("hasPermission(#document, 'WRITE')")
public Document updateDocument(Document document, String content) {
document.setContent(content);
return documentRepository.save(document);
}
// Check permission by ID and type
@PreAuthorize("hasPermission(#documentId, 'com.example.Document', 'DELETE')")
public void deleteDocument(Long documentId) {
documentRepository.deleteById(documentId);
}
// Multiple permission checks
@PreAuthorize("hasPermission(#document, 'READ') and hasPermission(#document, 'WRITE')")
public Document editDocument(Document document) {
// User needs both READ and WRITE permissions
return document;
}
}@Service
public class FolderService {
// Check permission with custom logic
@PreAuthorize("hasPermission(#folder, 'ADMINISTRATION') or " +
"(hasPermission(#folder, 'WRITE') and #folder.owner == authentication.name)")
public void changeFolderSettings(Folder folder, Map<String, Object> settings) {
// Only folder admins or owners can change settings
}
// Permission with role fallback
@PreAuthorize("hasRole('ADMIN') or hasPermission(#folder, 'CREATE')")
public Document createDocumentInFolder(Folder folder, Document document) {
document.setFolder(folder);
return documentRepository.save(document);
}
// Complex permission logic
@PreAuthorize("@folderSecurityService.canAccess(#folderId, authentication)")
public List<Document> getFolderContents(Long folderId) {
return documentRepository.findByFolderId(folderId);
}
}
@Service
public class FolderSecurityService {
@Autowired
private AclPermissionEvaluator permissionEvaluator;
public boolean canAccess(Long folderId, Authentication authentication) {
return permissionEvaluator.hasPermission(
authentication, folderId, "com.example.Folder", "READ"
) || hasInheritedAccess(folderId, authentication);
}
}Use @PostAuthorize to check permissions on method return values:
@Service
public class DocumentService {
// Check permission on returned object
@PostAuthorize("hasPermission(returnObject, 'READ')")
public Document findDocument(Long id) {
return documentRepository.findById(id).orElse(null);
}
// Multiple checks on return value
@PostAuthorize("returnObject != null and hasPermission(returnObject, 'READ')")
public Document getDocumentByTitle(String title) {
return documentRepository.findByTitle(title);
}
// Check permissions on nested objects
@PostAuthorize("returnObject.folder == null or hasPermission(returnObject.folder, 'READ')")
public Document getDocumentWithFolder(Long id) {
return documentRepository.findByIdWithFolder(id);
}
// Custom permission validation
@PostAuthorize("@documentSecurityService.canView(returnObject, authentication)")
public Document getDocument(Long id) {
return documentRepository.findById(id).orElse(null);
}
}Filter returned collections based on permissions:
@Service
public class DocumentService {
// Filter list - only return documents user can read
@PostFilter("hasPermission(filterObject, 'READ')")
public List<Document> getAllDocuments() {
return documentRepository.findAll();
}
// Filter with additional criteria
@PostFilter("hasPermission(filterObject, 'READ') and filterObject.published == true")
public List<Document> getPublishedDocuments() {
return documentRepository.findAll();
}
// Filter complex objects
@PostFilter("hasPermission(filterObject.document, 'READ')")
public List<DocumentWithMetadata> getDocumentsWithMetadata() {
return documentRepository.findAllWithMetadata();
}
// Filter based on different permission levels
@PostFilter("hasPermission(filterObject, 'READ') or " +
"hasPermission(filterObject, 'WRITE') or " +
"hasPermission(filterObject, 'ADMINISTRATION')")
public List<Document> getAccessibleDocuments() {
return documentRepository.findAll();
}
}Filter input collections before method execution:
@Service
public class DocumentService {
// Filter input list - only process documents user can write to
@PreFilter("hasPermission(filterObject, 'WRITE')")
public List<Document> updateMultipleDocuments(List<Document> documents) {
// Only documents with WRITE permission will be processed
return documents.stream()
.map(doc -> {
doc.setLastModified(new Date());
return documentRepository.save(doc);
})
.collect(Collectors.toList());
}
// Filter with permission and business logic
@PreFilter("hasPermission(filterObject, 'DELETE') and filterObject.status != 'PUBLISHED'")
public void deleteMultipleDocuments(List<Document> documents) {
documents.forEach(doc -> documentRepository.delete(doc));
}
// Filter by ID list
@PreFilter("hasPermission(filterObject, 'com.example.Document', 'READ')")
public List<Document> getDocumentsByIds(List<Long> documentIds) {
return documentRepository.findAllById(documentIds);
}
}The ACL system supports multiple ways to specify permissions:
@PreAuthorize("hasPermission(#document, 'READ')")
@PreAuthorize("hasPermission(#document, 'WRITE')")
@PreAuthorize("hasPermission(#document, 'DELETE')")
@PreAuthorize("hasPermission(#document, 'ADMINISTRATION')")
// Custom permissions (must be registered in PermissionFactory)
@PreAuthorize("hasPermission(#document, 'APPROVE')")
@PreAuthorize("hasPermission(#document, 'PUBLISH')")// Using BasePermission mask values
@PreAuthorize("hasPermission(#document, 1)") // READ
@PreAuthorize("hasPermission(#document, 2)") // WRITE
@PreAuthorize("hasPermission(#document, 4)") // CREATE
@PreAuthorize("hasPermission(#document, 8)") // DELETE
@PreAuthorize("hasPermission(#document, 16)") // ADMINISTRATION
// Combined permissions using bitwise OR
@PreAuthorize("hasPermission(#document, 3)") // READ + WRITE (1 | 2)@Service
public class DocumentService {
// Inject permission constants
@PreAuthorize("hasPermission(#document, T(org.springframework.security.acls.domain.BasePermission).READ)")
public Document viewDocument(Document document) {
return document;
}
// Custom permission objects
@PreAuthorize("hasPermission(#document, T(com.example.CustomPermission).APPROVE)")
public void approveDocument(Document document) {
document.setApproved(true);
documentRepository.save(document);
}
}The cache optimizer pre-loads ACL data for collections to avoid N+1 queries:
@Configuration
public class PerformanceConfig {
@Bean
public AclPermissionCacheOptimizer aclPermissionCacheOptimizer(AclService aclService) {
AclPermissionCacheOptimizer optimizer = new AclPermissionCacheOptimizer(aclService);
// Configure custom strategies if needed
optimizer.setObjectIdentityRetrievalStrategy(objectIdentityRetrievalStrategy());
optimizer.setSidRetrievalStrategy(sidRetrievalStrategy());
return optimizer;
}
}The optimizer automatically detects collection parameters and return values:
@Service
public class OptimizedDocumentService {
// Optimizer will batch-load ACLs for all documents before filtering
@PostFilter("hasPermission(filterObject, 'READ')")
public List<Document> getAllDocuments() {
return documentRepository.findAll(); // Single query + batch ACL load
}
// Optimizer detects collection parameter
@PreFilter("hasPermission(filterObject, 'WRITE')")
public void updateDocuments(List<Document> documents) {
// ACLs loaded in batch before filtering
documents.forEach(documentRepository::save);
}
}Implement custom caching for complex permission logic:
@Service
@CacheConfig(cacheNames = "permissions")
public class CachedPermissionService {
@Cacheable(key = "#objectId + ':' + #targetType + ':' + #permission + ':' + #authentication.name")
public boolean hasPermission(Long objectId, String targetType, String permission, Authentication authentication) {
return aclPermissionEvaluator.hasPermission(authentication, objectId, targetType, permission);
}
@CacheEvict(key = "#objectId + ':*'", allEntries = true)
public void clearPermissionCache(Long objectId) {
// Called when object permissions change
}
}Handle permission evaluation errors gracefully:
@Service
public class RobustDocumentService {
@PreAuthorize("@permissionChecker.canRead(#documentId, authentication)")
public Document getDocument(Long documentId) {
return documentRepository.findById(documentId).orElse(null);
}
}
@Component
public class PermissionChecker {
@Autowired
private AclPermissionEvaluator permissionEvaluator;
public boolean canRead(Long documentId, Authentication authentication) {
try {
return permissionEvaluator.hasPermission(
authentication, documentId, "com.example.Document", "READ"
);
} catch (Exception e) {
log.warn("Permission check failed for document {}: {}", documentId, e.getMessage());
return false; // Fail securely
}
}
}@ControllerAdvice
public class SecurityExceptionHandler {
@ExceptionHandler(AccessDeniedException.class)
public ResponseEntity<ErrorResponse> handleAccessDenied(AccessDeniedException e) {
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body(new ErrorResponse("Access denied: " + e.getMessage()));
}
@ExceptionHandler(NotFoundException.class)
public ResponseEntity<ErrorResponse> handleAclNotFound(NotFoundException e) {
// ACL not found - might be legitimate (new object) or security issue
log.debug("ACL not found: {}", e.getMessage());
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(new ErrorResponse("Resource not found"));
}
}@ExtendWith(MockitoExtension.class)
class DocumentServiceTest {
@Mock
private AclPermissionEvaluator permissionEvaluator;
@Mock
private DocumentRepository documentRepository;
@InjectMocks
private DocumentService documentService;
@Test
void testPreAuthorizeWithPermission() {
// Given
Document document = new Document(1L, "Test");
Authentication auth = createAuthentication("user1");
when(permissionEvaluator.hasPermission(auth, document, "READ"))
.thenReturn(true);
// When/Then - method should execute without exception
assertThat(documentService.viewDocument(document)).isEqualTo(document);
}
@Test
void testPreAuthorizeWithoutPermission() {
// Given
Document document = new Document(1L, "Test");
Authentication auth = createAuthentication("user1");
when(permissionEvaluator.hasPermission(auth, document, "READ"))
.thenReturn(false);
// When/Then - should throw AccessDeniedException
assertThatThrownBy(() -> documentService.viewDocument(document))
.isInstanceOf(AccessDeniedException.class);
}
}@SpringBootTest
@Transactional
class DocumentServiceIntegrationTest {
@Autowired
private DocumentService documentService;
@Autowired
private MutableAclService aclService;
@Test
@WithMockUser(username = "user1", roles = "USER")
void testDocumentAccessWithRealAcl() {
// Given - create document with ACL
Document document = documentRepository.save(new Document("Test"));
ObjectIdentity identity = new ObjectIdentityImpl(Document.class, document.getId());
MutableAcl acl = aclService.createAcl(identity);
Sid userSid = new PrincipalSid("user1");
acl.insertAce(0, BasePermission.READ, userSid, true);
aclService.updateAcl(acl);
// When/Then - user should be able to access document
Document result = documentService.viewDocument(document);
assertThat(result).isEqualTo(document);
}
}@TestConfiguration
static class TestSecurityConfig {
@Bean
@Primary
public AclPermissionEvaluator testPermissionEvaluator() {
return new AclPermissionEvaluator(aclService()) {
@Override
public boolean hasPermission(Authentication auth, Object domainObject, Object permission) {
// Mock implementation for testing
return "user1".equals(auth.getName()) && "READ".equals(permission);
}
};
}
}// Good - consistent permission checking
@Service
public class DocumentService {
@PreAuthorize("hasPermission(#document, 'READ')")
public Document getDocument(Document document) { /* */ }
@PreAuthorize("hasPermission(#document, 'WRITE')")
public Document updateDocument(Document document) { /* */ }
@PreAuthorize("hasPermission(#documentId, 'com.example.Document', 'DELETE')")
public void deleteDocument(Long documentId) { /* */ }
}
// Avoid - mixing security approaches
@Service
public class MixedSecurityService {
@PreAuthorize("hasPermission(#document, 'READ')")
public Document getDocument(Document document) { /* */ }
// Inconsistent - manual permission checking
public Document updateDocument(Document document) {
if (!hasWritePermission(document)) {
throw new AccessDeniedException("Access denied");
}
// Update logic...
}
}@Service
public class SafeDocumentService {
// Good - null check built into expression
@PostAuthorize("returnObject == null or hasPermission(returnObject, 'READ')")
public Document findDocument(Long id) {
return documentRepository.findById(id).orElse(null);
}
// Alternative - separate null handling
public Document getDocument(Long id) {
Document doc = documentRepository.findById(id).orElse(null);
if (doc != null && !hasReadPermission(doc)) {
throw new AccessDeniedException("Access denied");
}
return doc;
}
}// Good - use @PostFilter for automatic optimization
@PostFilter("hasPermission(filterObject, 'READ')")
public List<Document> getUserDocuments() {
return documentRepository.findAll();
}
// Better - pre-filter at database level when possible
public List<Document> getUserDocuments(Authentication authentication) {
List<Sid> sids = sidRetrievalStrategy.getSids(authentication);
return documentRepository.findDocumentsAccessibleToSids(sids);
}// Good - clear business intent
@PreAuthorize("hasPermission(#document, 'APPROVE')")
@PreAuthorize("hasPermission(#document, 'PUBLISH')")
@PreAuthorize("hasPermission(#document, 'ARCHIVE')")
// Avoid - technical permission names without business context
@PreAuthorize("hasPermission(#document, 'PERM_001')")
@PreAuthorize("hasPermission(#document, 'FLAG_A')")The permission evaluation system provides a powerful, declarative way to implement fine-grained security. Combined with proper configuration and setup, it enables maintainable, high-performance access control for complex applications.
Install with Tessl CLI
npx tessl i tessl/maven-org-springframework-security--spring-security-acl