User-Role-Permission security pattern (RBAC) in Spring Security 4

A common access control pattern in enterprise applications is role-based access control (RBAC). Here's how to do it in Spring Security 4 using a custom UserDetailsService.

The SQL/DDL

For the scope of this article I'm assuming a PostgreSQL database.

CREATE TABLE Actor  
(
    actorId serial PRIMARY KEY,
    login character varying(255) UNIQUE NOT NULL,
    password character varying(255) NOT NULL,
    enabled boolean NOT NULL,
    firstName character varying(255),
    lastName character varying(255),
    email character varying(255) NOT NULL,
    created timestamp,
    lastLogin timestamp
);

CREATE TABLE Role  
(
    roleId serial PRIMARY KEY,
    name character varying(255) UNIQUE NOT NULL,
    displayName character varying(255),
    description character varying(255)
);

CREATE TABLE Permission  
(
    permissionId serial PRIMARY KEY,
    name character varying(255) UNIQUE NOT NULL,
    description character varying(255)
);

CREATE TABLE ActorRole  
(
    actorRoleId serial PRIMARY KEY,
    actorId integer REFERENCES Actor,
    roleId integer REFERENCES Role
);

CREATE TABLE RolePermission  
(
    rolePermissionId serial PRIMARY KEY,
    roleId integer REFERENCES Role,
    permissionId integer REFERENCES Permission
);

The custom UserDetails

Expand this class with additional attributes of your Actor entity (e.g. first name, last name, email, address, ...)

public class CustomUserDetails implements UserDetails {

    private static final long serialVersionUID = 9188230014174856593L;

    private String password;
    private final String username;
    private final Set<GrantedAuthority> authorities;
    private final boolean accountNonExpired;
    private final boolean accountNonLocked;
    private final boolean credentialsNonExpired;
    private final boolean enabled;

    public CustomUserDetails (String username, String password,
            Collection<? extends GrantedAuthority> authorities) {
        this(username, password, true, true, true, true, authorities);
    }

    public CustomUserDetails (String username, String password,
            boolean enabled, boolean accountNonExpired,
            boolean credentialsNonExpired, boolean accountNonLocked,
            Collection<? extends GrantedAuthority> authorities) {

        if (((username == null) || "".equals(username)) || (password == null)) {
            throw new IllegalArgumentException(
                    "Cannot pass null or empty values to constructor");
        }

        this.username = username;
        this.password = password;
        this.enabled = enabled;
        this.accountNonExpired = accountNonExpired;
        this.credentialsNonExpired = credentialsNonExpired;
        this.accountNonLocked = accountNonLocked;
        this.authorities = Collections
                .unmodifiableSet(sortAuthorities(authorities));
    }

    private static SortedSet<GrantedAuthority> sortAuthorities(
            Collection<? extends GrantedAuthority> authorities) {
        Assert.notNull(authorities,
                "Cannot pass a null GrantedAuthority collection");
        // Ensure array iteration order is predictable (as per
        // UserDetails.getAuthorities() contract and SEC-717)
        SortedSet<GrantedAuthority> sortedAuthorities = new TreeSet<GrantedAuthority>(
                new AuthorityComparator());

        for (GrantedAuthority grantedAuthority : authorities) {
            Assert.notNull(grantedAuthority,
                    "GrantedAuthority list cannot contain any null elements");
            sortedAuthorities.add(grantedAuthority);
        }

        return sortedAuthorities;
    }

    private static class AuthorityComparator implements
            Comparator<GrantedAuthority>, Serializable {
        private static final long serialVersionUID = SpringSecurityCoreVersion.SERIAL_VERSION_UID;

        public int compare(GrantedAuthority g1, GrantedAuthority g2) {
            // Neither should ever be null as each entry is checked before
            // adding it to
            // the set.
            // If the authority is null, it is a custom authority and should
            // precede
            // others.
            if (g2.getAuthority() == null) {
                return -1;
            }

            if (g1.getAuthority() == null) {
                return 1;
            }

            return g1.getAuthority().compareTo(g2.getAuthority());
        }
    }

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return authorities;
    }

    @Override
    public String getPassword() {
        return password;
    }

    @Override
    public String getUsername() {
        return username;
    }

    @Override
    public boolean isAccountNonExpired() {
        return accountNonExpired;
    }

    @Override
    public boolean isAccountNonLocked() {
        return accountNonLocked;
    }

    @Override
    public boolean isCredentialsNonExpired() {
        return credentialsNonExpired;
    }

    @Override
    public boolean isEnabled() {
        return enabled;
    }
}

The custom UserDetailsService

Adapt the loadUserByUsername method to feed the additional custom actor entity attributes to the CustomUserDetails object.

@Service
@Transactional(readOnly = true)
public class CustomUserDetailsService implements UserDetailsService {

    @Autowired
    private ActorRepository actorRepository;

    @Override
    public UserDetails loadUserByUsername(String username)
            throws UsernameNotFoundException {

        try {
            Actor actor = actorRepository.findByLogin(username);

            boolean enabled = true;
            boolean accountNonExpired = true;
            boolean credentialsNonExpired = true;
            boolean accountNonLocked = true;

            // adapt as needed
            return new CustomUserDetails(actor.getLogin(),
                    actor.getPassword(), enabled, accountNonExpired,
                    credentialsNonExpired, accountNonLocked,
                    getAuthorities(actor.getRoles()));

        } catch (Exception e) {
            throw new RuntimeException(e);
        }
    }

    private static class SimpleGrantedAuthorityComparator implements
            Comparator<SimpleGrantedAuthority> {

        @Override
        public int compare(SimpleGrantedAuthority o1, SimpleGrantedAuthority o2) {
            return o1.equals(o2) ? 0 : -1;
        }
    }

    /**
     * Retrieves a collection of {@link GrantedAuthority} based on a list of
     * roles
     * 
     * @param roles
     *            the assigned roles of the user
     * @return a collection of {@link GrantedAuthority}
     */
    public Collection<? extends GrantedAuthority> getAuthorities(Set<Role> roles) {

        Set<SimpleGrantedAuthority> authList = new TreeSet<SimpleGrantedAuthority>(
                new SimpleGrantedAuthorityComparator());

        for (Role role : roles) {
            authList.addAll(getGrantedAuthorities(role));
        }

        return authList;
    }

    /**
     * Wraps a {@link Role} role to {@link SimpleGrantedAuthority} objects
     * 
     * @param roles
     *            {@link String} of roles
     * @return list of granted authorities
     */
    public static Set<SimpleGrantedAuthority> getGrantedAuthorities(Role role) {

        Set<SimpleGrantedAuthority> authorities = new HashSet<SimpleGrantedAuthority>();

        Set<Permission> rolePermissions = role.getPermissions();
        for (Permission permission : rolePermissions) {
            authorities.add(new SimpleGrantedAuthority(permission.getName()));
        }

        return authorities;
    }
}

The Spring configuration

In this example I'm using XML to configure Spring Security. Naturally this can also be done using annotations.

<beans:beans xmlns="http://www.springframework.org/schema/security"  
    xmlns:beans="http://www.springframework.org/schema/beans" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:schemaLocation="http://www.springframework.org/schema/beans
    http://www.springframework.org/schema/beans/spring-beans-3.0.xsd
    http://www.springframework.org/schema/security
    http://www.springframework.org/schema/security/spring-security.xsd">

    <http auto-config="true" authentication-manager-ref="adminAuthMgr">
        <intercept-url pattern="/admin/**"
            access="hasAuthority('PERM_ACCESS_ADMIN_AREA')" />

        <form-login login-page="/login" default-target-url="/admin/dashboard"
            authentication-failure-url="/login?error"
            username-parameter="username" password-parameter="password"
            login-processing-url="/j_spring_security_check" />
        <logout logout-url="/j_spring_security_logout"
            logout-success-url="/login?logout" />
        <csrf />
    </http>

    <authentication-manager alias="adminAuthMgr">
        <authentication-provider
            user-service-ref="customUserDetailsService">
            <password-encoder hash="bcrypt" />
        </authentication-provider>
    </authentication-manager>
</beans:beans>  

Remarks

Note how you can use the hasAuthority(PERM_XY) or hasAuthority(ROLE_ZY) expression to elegantly handle that both permissions from the Permission table and roles from the Role table are stored in the permissions attribute of Spring UserDetails.