Domain Object Security: Access Control Lists (ACLs)
What ACLs Solve
Role-based access control answers: “Can this user perform this action?” ACLs answer: “Can this user perform this action on this specific object?”
Consider a document management system:
- Alice owns Document #42 — she can read and edit it
- Bob is a reviewer on Document #42 — he can read it but not edit
- Carol has no permission on Document #42 — she gets a 403
This cannot be expressed with roles alone. Roles apply uniformly across all objects of a type; ACLs attach permissions to individual instances.
The ACL Data Model
Spring Security’s ACL module stores permissions in four tables:
-- The domain object class (e.g. com.example.Document)
CREATE TABLE acl_class (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
class VARCHAR(255) UNIQUE NOT NULL
);
-- The principal or role that receives permissions
CREATE TABLE acl_sid (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
principal BOOLEAN NOT NULL, -- TRUE = username, FALSE = role name
sid VARCHAR(255) NOT NULL,
UNIQUE (sid, principal)
);
-- One row per domain object instance
CREATE TABLE acl_object_identity (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
object_id_class BIGINT NOT NULL REFERENCES acl_class(id),
object_id_identity BIGINT NOT NULL,
parent_object BIGINT REFERENCES acl_object_identity(id),
owner_sid BIGINT NOT NULL REFERENCES acl_sid(id),
entries_inheriting BOOLEAN NOT NULL,
UNIQUE (object_id_class, object_id_identity)
);
-- One row per (object, principal, permission) triple
CREATE TABLE acl_entry (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
acl_object_identity BIGINT NOT NULL REFERENCES acl_object_identity(id),
ace_order INT NOT NULL,
sid BIGINT NOT NULL REFERENCES acl_sid(id),
mask INT NOT NULL, -- bitmask: READ=1, WRITE=2, CREATE=4, DELETE=8, ADMIN=16
granting BOOLEAN NOT NULL,
audit_success BOOLEAN NOT NULL,
audit_failure BOOLEAN NOT NULL,
UNIQUE (acl_object_identity, ace_order)
);
Permissions are stored as integer bitmasks. The built-in BasePermission values:
| Permission | Mask |
|---|---|
READ | 1 |
WRITE | 2 |
CREATE | 4 |
DELETE | 8 |
ADMINISTRATION | 16 |
Adding the Dependency
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-acl</artifactId>
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-aspects</artifactId>
</dependency>
Configuring ACL Beans
@Configuration
@EnableMethodSecurity
public class AclConfig {
@Bean
public AclAuthorizationStrategy aclAuthorizationStrategy() {
// Only ROLE_ADMIN can change ACL ownership or audit settings
return new AclAuthorizationStrategyImpl(new SimpleGrantedAuthority("ROLE_ADMIN"));
}
@Bean
public PermissionGrantingStrategy permissionGrantingStrategy() {
return new DefaultPermissionGrantingStrategy(new ConsoleAuditLogger());
}
@Bean
public EhCacheBasedAclCache aclCache(
EhCacheFactoryBean ehCacheFactoryBean,
PermissionGrantingStrategy grantingStrategy,
AclAuthorizationStrategy authStrategy) {
return new EhCacheBasedAclCache(
ehCacheFactoryBean.getObject(),
grantingStrategy,
authStrategy
);
}
@Bean
public LookupStrategy lookupStrategy(
DataSource dataSource,
AclCache aclCache,
AclAuthorizationStrategy authStrategy,
PermissionGrantingStrategy grantingStrategy) {
return new BasicLookupStrategy(dataSource, aclCache, authStrategy, grantingStrategy);
}
@Bean
public JdbcMutableAclService aclService(
DataSource dataSource,
LookupStrategy lookupStrategy,
AclCache aclCache) {
return new JdbcMutableAclService(dataSource, lookupStrategy, aclCache);
}
@Bean
public AclPermissionEvaluator permissionEvaluator(AclService aclService) {
return new AclPermissionEvaluator(aclService);
}
@Bean
public DefaultMethodSecurityExpressionHandler methodSecurityExpressionHandler(
AclPermissionEvaluator permissionEvaluator) {
DefaultMethodSecurityExpressionHandler handler = new DefaultMethodSecurityExpressionHandler();
handler.setPermissionEvaluator(permissionEvaluator);
return handler;
}
}
Using hasPermission in @PreAuthorize
Once AclPermissionEvaluator is wired in, use hasPermission in SpEL:
@Service
public class DocumentService {
private final DocumentRepository documentRepository;
public DocumentService(DocumentRepository documentRepository) {
this.documentRepository = documentRepository;
}
// Check before loading — uses object identity (class + id)
@PreAuthorize("hasPermission(#id, 'com.example.Document', 'READ')")
public Document getDocument(Long id) {
return documentRepository.findById(id)
.orElseThrow(() -> new ResourceNotFoundException("Document not found"));
}
// Check on the already-loaded object
@PostAuthorize("hasPermission(returnObject, 'READ')")
public Document getDocumentBySlug(String slug) {
return documentRepository.findBySlug(slug)
.orElseThrow(() -> new ResourceNotFoundException("Document not found"));
}
@PreAuthorize("hasPermission(#document, 'WRITE')")
public Document updateDocument(Document document) {
return documentRepository.save(document);
}
@PreAuthorize("hasPermission(#id, 'com.example.Document', 'DELETE')")
public void deleteDocument(Long id) {
documentRepository.deleteById(id);
}
}
hasPermission has two forms:
hasPermission(id, className, permission)— looks up the ACL by class + id; no DB hit for the domain object itselfhasPermission(domainObject, permission)— uses the object’s class and itsgetId()(or serializable identity)
Granting Permissions
Grant permissions programmatically using MutableAclService:
@Service
@Transactional
public class AclService {
private final MutableAclService mutableAclService;
public AclService(MutableAclService mutableAclService) {
this.mutableAclService = mutableAclService;
}
public void grantPermission(Object domainObject, String username, Permission permission) {
ObjectIdentity identity = new ObjectIdentityImpl(domainObject);
Sid sid = new PrincipalSid(username);
MutableAcl acl;
try {
acl = (MutableAcl) mutableAclService.readAclById(identity);
} catch (NotFoundException e) {
acl = mutableAclService.createAcl(identity);
}
acl.insertAce(acl.getEntries().size(), permission, sid, true);
mutableAclService.updateAcl(acl);
}
public void revokePermission(Object domainObject, String username, Permission permission) {
ObjectIdentity identity = new ObjectIdentityImpl(domainObject);
Sid sid = new PrincipalSid(username);
MutableAcl acl = (MutableAcl) mutableAclService.readAclById(identity);
List<AccessControlEntry> entries = acl.getEntries();
for (int i = entries.size() - 1; i >= 0; i--) {
AccessControlEntry entry = entries.get(i);
if (entry.getSid().equals(sid) && entry.getPermission().equals(permission)) {
acl.deleteAce(i);
}
}
mutableAclService.updateAcl(acl);
}
}
Grant permissions when creating a new document:
@Service
@Transactional
public class DocumentService {
private final DocumentRepository documentRepository;
private final AclService aclService;
@PreAuthorize("isAuthenticated()")
public Document createDocument(Document document, Authentication auth) {
Document saved = documentRepository.save(document);
// Grant the creator full permissions
aclService.grantPermission(saved, auth.getName(), BasePermission.READ);
aclService.grantPermission(saved, auth.getName(), BasePermission.WRITE);
aclService.grantPermission(saved, auth.getName(), BasePermission.DELETE);
aclService.grantPermission(saved, auth.getName(), BasePermission.ADMINISTRATION);
return saved;
}
}
Permission Inheritance
ACL entries can inherit from a parent object. When entries_inheriting = true, Spring Security checks the object’s own ACEs first, then walks up the parent chain.
// Grant READ on a folder — all documents in the folder inherit it
public void createDocumentInFolder(Document document, Folder folder, String username) {
Document saved = documentRepository.save(document);
ObjectIdentity docIdentity = new ObjectIdentityImpl(saved);
ObjectIdentity folderIdentity = new ObjectIdentityImpl(folder);
MutableAcl docAcl = mutableAclService.createAcl(docIdentity);
docAcl.setParent(mutableAclService.readAclById(folderIdentity));
docAcl.setEntriesInheriting(true);
mutableAclService.updateAcl(docAcl);
}
Custom Permissions
Extend BasePermission to add domain-specific permissions:
public class DocumentPermission extends BasePermission {
public static final Permission APPROVE = new DocumentPermission(1 << 5, 'V'); // mask 32
public static final Permission PUBLISH = new DocumentPermission(1 << 6, 'P'); // mask 64
protected DocumentPermission(int mask, char code) {
super(mask, code);
}
}
Register the custom permission factory so Spring Security recognizes the string "APPROVE":
@Bean
public AclPermissionEvaluator permissionEvaluator(AclService aclService) {
AclPermissionEvaluator evaluator = new AclPermissionEvaluator(aclService);
evaluator.setPermissionFactory(new DefaultPermissionFactory(DocumentPermission.class));
return evaluator;
}
@PreAuthorize("hasPermission(#id, 'com.example.Document', 'APPROVE')")
public void approveDocument(Long id) { ... }
When Not to Use ACLs
ACLs are powerful but expensive. Consider the trade-offs:
| Scenario | Recommendation |
|---|---|
| Permissions are uniform across all users of a role | Use RBAC — ACLs add unnecessary complexity |
| Object count is small (< 10k) and ownership is simple | A owner_id column with a @PreAuthorize check is sufficient |
| Object count is large and query performance matters | ACL lookups join across 4 tables — benchmark carefully |
| Permissions change frequently and need audit trails | ACLs are a good fit |
| Multiple principals can have different permissions on the same object | ACLs are the right tool |
A common middle ground: store ownerId on the domain object and use a custom @authz bean for the common case, reserving the full ACL system only for objects that genuinely need multi-party, multi-permission access control.
// Lightweight alternative to full ACL for simple ownership checks
@Component("authz")
public class OwnershipAuthz {
private final DocumentRepository repo;
public boolean isOwner(Authentication auth, Long documentId) {
return repo.existsByIdAndOwnerId(documentId, auth.getName());
}
}
@PreAuthorize("hasRole('ADMIN') or @authz.isOwner(authentication, #id)")
public void deleteDocument(Long id) { ... }
Key Takeaways
- ACLs store permissions per (domain object, principal, permission) triple in four database tables
hasPermissionin@PreAuthorize/@PostAuthorizetriggers ACL lookups viaAclPermissionEvaluator- Grant permissions programmatically with
MutableAclServicewhen objects are created or shared - ACL entries can inherit from parent objects to model folder/file-style permission hierarchies
- Custom permissions extend
BasePermissionwith additional bitmask values - Full ACLs are overkill for simple ownership — an
ownerIdcolumn with a custom SpEL bean is often enough
Next: Password Encoding: BCrypt, Argon2, and DelegatingPasswordEncoder — how Spring Security hashes, stores, and upgrades passwords.