Spring Security: ACL: Fine-Grained Object-Level Authorization

Role-based security answers 'can this user access /admin?'. ACL (Access Control List) security answers 'can this user read document #42?'. Spring Security ACL stores per-object permission entries in four database tables and plugs into @PreAuthorize hasPermission() so your existing method-security annotations continue to work.

Contents

Dependency

Add the following to your pom.xml (Maven) or build.gradle (Gradle). Spring Boot manages compatible versions automatically when you use the Spring Boot BOM.

XML
<dependency>
  <groupId>org.springframework.security</groupId>
  <artifactId>spring-security-acl</artifactId>
</dependency>
<!-- ACL uses Spring Cache for performance -->
<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-cache</artifactId>
</dependency>

ACL Database Schema

Spring Security ACL requires four tables. Run these DDL statements (PostgreSQL shown; adjust for MySQL/H2):

SQL
CREATE TABLE acl_sid (
  id        BIGSERIAL PRIMARY KEY,
  principal BOOLEAN NOT NULL,        -- true = username, false = role
  sid       VARCHAR(100) NOT NULL,   -- username or role name
  UNIQUE (sid, principal)
);

CREATE TABLE acl_class (
  id            BIGSERIAL PRIMARY KEY,
  class         VARCHAR(255) NOT NULL UNIQUE,  -- fully-qualified class name
  class_id_type VARCHAR(100)                   -- type of the object identity id
);

CREATE TABLE acl_object_identity (
  id                 BIGSERIAL PRIMARY KEY,
  object_id_class    BIGINT NOT NULL REFERENCES acl_class(id),
  object_id_identity VARCHAR(36) NOT NULL,  -- the domain object's ID
  parent_object      BIGINT REFERENCES acl_object_identity(id),
  owner_sid          BIGINT REFERENCES acl_sid(id),
  entries_inheriting BOOLEAN NOT NULL DEFAULT TRUE,
  UNIQUE (object_id_class, object_id_identity)
);

CREATE TABLE acl_entry (
  id                  BIGSERIAL PRIMARY KEY,
  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 of permissions
  granting            BOOLEAN NOT NULL,   -- true = grant, false = deny
  audit_success       BOOLEAN NOT NULL DEFAULT FALSE,
  audit_failure       BOOLEAN NOT NULL DEFAULT FALSE,
  UNIQUE (acl_object_identity, ace_order)
);

ACL Configuration Beans

The class below shows the implementation. Key points are highlighted in the inline comments.

Java
import org.springframework.cache.CacheManager;
import org.springframework.context.annotation.*;
import org.springframework.security.access.expression.method.DefaultMethodSecurityExpressionHandler;
import org.springframework.security.access.expression.method.MethodSecurityExpressionHandler;
import org.springframework.security.acls.AclPermissionEvaluator;
import org.springframework.security.acls.domain.*;
import org.springframework.security.acls.jdbc.*;
import org.springframework.security.acls.model.AclCache;

import javax.sql.DataSource;

@Configuration
@EnableMethodSecurity
public class AclConfig {

    @Bean
    public AclCache aclCache(CacheManager cacheManager) {
        return new SpringCacheBasedAclCache(
            cacheManager.getCache("aclCache"),
            new DefaultPermissionGrantingStrategy(new ConsoleAuditLogger()),
            new AclAuthorizationStrategyImpl(new SimpleGrantedAuthority("ROLE_ADMIN")));
    }

    @Bean
    public LookupStrategy lookupStrategy(DataSource dataSource, AclCache aclCache) {
        return new BasicLookupStrategy(dataSource, aclCache,
            new AclAuthorizationStrategyImpl(new SimpleGrantedAuthority("ROLE_ADMIN")),
            new ConsoleAuditLogger());
    }

    @Bean
    public JdbcMutableAclService aclService(DataSource dataSource,
                                             LookupStrategy ls, AclCache cache) {
        return new JdbcMutableAclService(dataSource, ls, cache);
    }

    @Bean
    public MethodSecurityExpressionHandler methodSecurityExpressionHandler(
            JdbcMutableAclService aclService) {
        DefaultMethodSecurityExpressionHandler handler = new DefaultMethodSecurityExpressionHandler();
        handler.setPermissionEvaluator(new AclPermissionEvaluator(aclService));
        return handler;
    }
}
Properties
# application.properties — enable caching for ACL performance
spring.cache.type=caffeine
spring.cache.cache-names=aclCache
spring.cache.caffeine.spec=maximumSize=500,expireAfterWrite=60s

Granting Permissions on Domain Objects

Use MutableAclService to create or update an ACL for a domain object when it is created, shared, or transferred. Call this from your service layer after persisting the object.

Java
import org.springframework.security.acls.domain.*;
import org.springframework.security.acls.model.*;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class DocumentAclService {

    private final MutableAclService mutableAclService;

    public DocumentAclService(MutableAclService mutableAclService) {
        this.mutableAclService = mutableAclService;
    }

    @Transactional
    public void grantPermission(Long documentId, String username, Permission permission) {
        ObjectIdentity oi = new ObjectIdentityImpl(Document.class, documentId);

        MutableAcl acl;
        try {
            acl = (MutableAcl) mutableAclService.readAclById(oi);
        } catch (NotFoundException e) {
            acl = mutableAclService.createAcl(oi);
        }

        Sid sid = new PrincipalSid(username);
        acl.insertAce(acl.getEntries().size(), permission, sid, true /* granting */);
        mutableAclService.updateAcl(acl);
    }

    @Transactional
    public void revokePermission(Long documentId, String username, Permission permission) {
        ObjectIdentity oi = new ObjectIdentityImpl(Document.class, documentId);
        MutableAcl acl = (MutableAcl) mutableAclService.readAclById(oi);

        acl.getEntries().stream()
            .filter(ace -> ace.getSid().equals(new PrincipalSid(username))
                        && ace.getPermission().equals(permission))
            .map(ace -> acl.getEntries().indexOf(ace))
            .findFirst()
            .ifPresent(acl::deleteAce);

        mutableAclService.updateAcl(acl);
    }
}
Java
// In your document creation service:
@Transactional
public Document createDocument(CreateDocumentRequest req) {
    Document doc = documentRepository.save(new Document(req.title(), req.content()));

    // Grant the creator full permissions
    String owner = SecurityContextHolder.getContext().getAuthentication().getName();
    aclService.grantPermission(doc.getId(), owner, BasePermission.READ);
    aclService.grantPermission(doc.getId(), owner, BasePermission.WRITE);
    aclService.grantPermission(doc.getId(), owner, BasePermission.DELETE);

    return doc;
}

Checking Permissions with hasPermission()

Once ACLs are stored, use hasPermission() in @PreAuthorize or @PostAuthorize. Spring Security looks up the ACL for the object and evaluates whether the current principal has the required permission.

Java
import org.springframework.security.access.prepost.*;
import org.springframework.security.acls.domain.BasePermission;
import org.springframework.stereotype.Service;

@Service
public class DocumentService {

    // Check by object ID and type name — ACL lookup happens before method runs
    @PreAuthorize("hasPermission(#id, 'com.example.Document', 'read')")
    public Document getDocument(Long id) {
        return documentRepository.findById(id).orElseThrow();
    }

    // Check on the returned object — ACL lookup after method runs
    @PostAuthorize("hasPermission(returnObject, 'read')")
    public Document findDocument(Long id) {
        return documentRepository.findById(id).orElseThrow();
    }

    @PreAuthorize("hasPermission(#id, 'com.example.Document', 'write')")
    public Document updateDocument(Long id, UpdateRequest req) {
        Document doc = documentRepository.findById(id).orElseThrow();
        doc.setContent(req.content());
        return documentRepository.save(doc);
    }

    @PreAuthorize("hasPermission(#id, 'com.example.Document', 'delete')")
    public void deleteDocument(Long id) {
        documentRepository.deleteById(id);
    }
}

Built-in Permission Masks

Spring Security ACL uses integer bitmasks. BasePermission provides the standard set:

ConstantMask (int)Typical Use
BasePermission.READ1View / fetch an object.
BasePermission.WRITE2Edit / update an object.
BasePermission.CREATE4Create child objects.
BasePermission.DELETE8Delete an object.
BasePermission.ADMINISTRATION16Manage the ACL itself.

Extend BasePermission to add custom masks:

Java
import org.springframework.security.acls.domain.BasePermission;
import org.springframework.security.acls.model.Permission;

public class DocumentPermission extends BasePermission {
    public static final Permission SHARE   = new DocumentPermission(1 << 5, 'S'); // mask 32
    public static final Permission PUBLISH = new DocumentPermission(1 << 6, 'P'); // mask 64

    protected DocumentPermission(int mask, char code) {
        super(mask, code);
    }
}

When to Use ACL vs Custom PermissionEvaluator

CriterionSpring ACLCustom PermissionEvaluator
Permission changes at runtime✅ Yes: stored in DB⚠️ Requires redeploy or extra logic
Simple ownership check⚠️ Overkill✅ Simpler, faster
Per-object, per-user permissions✅ Built-in🔧 Must implement manually
Permission inheritance (parent → child)✅ Built-in🔧 Must implement manually
Setup complexity❌ 4 tables + config✅ One class + @Bean
Performance✅ Cached (Caffeine/Redis)✅ Depends on your impl

Use Spring ACL when permissions are dynamic and user-controlled (document sharing, team membership). Use a custom PermissionEvaluator when ownership rules are fixed in code (e.g., "users can only see their own records").