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:

PermissionMask
READ1
WRITE2
CREATE4
DELETE8
ADMINISTRATION16

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 itself
  • hasPermission(domainObject, permission) — uses the object’s class and its getId() (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:

ScenarioRecommendation
Permissions are uniform across all users of a roleUse RBAC — ACLs add unnecessary complexity
Object count is small (< 10k) and ownership is simpleA owner_id column with a @PreAuthorize check is sufficient
Object count is large and query performance mattersACL lookups join across 4 tables — benchmark carefully
Permissions change frequently and need audit trailsACLs are a good fit
Multiple principals can have different permissions on the same objectACLs 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
  • hasPermission in @PreAuthorize / @PostAuthorize triggers ACL lookups via AclPermissionEvaluator
  • Grant permissions programmatically with MutableAclService when objects are created or shared
  • ACL entries can inherit from parent objects to model folder/file-style permission hierarchies
  • Custom permissions extend BasePermission with additional bitmask values
  • Full ACLs are overkill for simple ownership — an ownerId column with a custom SpEL bean is often enough

Next: Password Encoding: BCrypt, Argon2, and DelegatingPasswordEncoder — how Spring Security hashes, stores, and upgrades passwords.