Roles and Privileges in Spring Security
In this post, we will take a look at Role Based Access Control (RBAC) with Spring boot.
Understanding RBAC
In an RBAC model there are three key entities. They are,
- User or Subject – The actors of the system who perform operations. It can represent a physical person, an automated account, or even another application.
- Role – Authority level defined by A job Title, Department or functional hierarchy.
- Privilege – An approval or permission to perform operations
With that being said, The following is an illustration of how these entities map to each other.
Basically, Users can perform operations. To perform operations they need to have certain permission or privileges. This is why privileges are assigned to roles and roles are assigned to users. Let’s see how we can implement these.
RBAC Entities
Let’s create the above objects to represented as database entities.
User Entity
@Data
@Entity
public class UserAccount {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Integer id;
@Column(unique = true)
private String username;
private String password;
private boolean active;
@OneToMany(mappedBy = "user")
private List<UserToRole> userToRoles;
}
Code language: PHP (php)
UseRole entity
@Data
@Entity
public class UserRole {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Integer id;
private String roleName;
@OneToMany(mappedBy = "role")
private List<UserRoleToPrivilege> userRoleToPrivileges;
}
Code language: PHP (php)
UserPrivileges entity
@Data
@Entity
public class UserPrivilege {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Integer id;
private String privilegeName;
}
Code language: PHP (php)
UserRoleToPrivilege Entity
@Data
@Entity
public class UserRoleToPrivilege {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Integer id;
@ManyToOne
private UserRole role;
@ManyToOne
private UserPrivilege privilege;
}
Code language: CSS (css)
UserToRole Entity
@Data
@Entity
public class UserToRole {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Integer id;
@ManyToOne
private UserAccount user;
@ManyToOne
private UserRole role;
}
Code language: CSS (css)
Populate the database entries
With the above entities in place, let’s populate the database with the appropriate roles and privileges. For this test, I made the entries straight forward using a data.sql
file.
insert into user_account(id, username, password, active) values (1, 'user1', '{noop}user1', 1);
insert into user_account(id, username, password, active) values (2, 'user2', '{noop}user2', 1);
insert into user_account(id, username, password, active) values (3, 'admin', '{noop}admin', 1);
insert into user_role(id, role_name) values (1, 'USER');
insert into user_role(id, role_name) values (2, 'ADMIN');
insert into user_to_role(id, user_id, role_id) values (1, 1, 1);
insert into user_to_role(id, user_id, role_id) values (2, 2, 1);
insert into user_to_role(id, user_id, role_id) values (3, 3, 2);
insert into user_privilege(id, privilege_name) values (1, 'canReadUser');
insert into user_privilege(id, privilege_name) values (2, 'canReadAdmin');
insert into user_role_to_privilege(id, role_id, privilege_id) values (1, 1, 1);
insert into user_role_to_privilege(id, role_id, privilege_id) values (2, 2, 1);
insert into user_role_to_privilege(id, role_id, privilege_id) values (3, 2, 2);
Code language: JavaScript (javascript)
Note that I’m using a
NoOpPasswordEncoder
because the passwords are prepended with{noop}
.
Spring Security userDetailsService
In our previous posts, We always used a single role called USER
for all the users in the system. However, We need to make changes to pick these roles and privileges from the database. Here is a crude example of how to do just that.
@Component
public class DatabaseUserDetailsService implements UserDetailsService {
private final
UserAccountRepository userAccountRepository;
public DatabaseUserDetailsService(UserAccountRepository userAccountRepository) {
this.userAccountRepository = userAccountRepository;
}
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
UserAccount userAccount = userAccountRepository.findByUsername(username);
if (userAccount == null) {
throw new UsernameNotFoundException("User with username [" + username + "] not found in the system");
}
Set<GrantedAuthority> authorities = new HashSet<>();
for (UserToRole userToRole : userAccount.getUserToRoles()) {
authorities.add(new SimpleGrantedAuthority("ROLE_" + userToRole.getRole().getRoleName()));
for (UserRoleToPrivilege userRoleToPrivilege : userToRole.getRole().getUserRoleToPrivileges()) {
authorities.add(new SimpleGrantedAuthority(userRoleToPrivilege.getPrivilege().getPrivilegeName()));
}
}
return new CustomUserDetails(userAccount.getUsername(), userAccount.getPassword(), userAccount.isActive(), authorities);
}
}
Code language: PHP (php)
One interesting thing to note here is that we added roles as well as privileges as authorities. However, all roles are prepended with
ROLE_
. This specific way is due to how security expressions likehasRole
andhasAuthority
work.This way developers can use expressions to set role level and privilege level setup for url mappings which you will see below.
Securing API endpoints
With WebSecurityConfigurerAdapter
, you can customize which URL is accessible by whom. Take a look at this configuration snippet.
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
.antMatchers("/user").access("hasAuthority('canReadUser')")
.antMatchers("/admin").access("hasAuthority('canReadAdmin')")
.anyRequest().authenticated()
.and().httpBasic()
.and().formLogin();
}
}
Code language: PHP (php)
Here, the admin
user can access both /user
and /admin
because ADMIN role has both canReadUser
and canReadAdmin
privileges. However, user1
or user2
cannot access /admin
as they would get a 403 Forbidden
response.
With all the above in place, Let’s test the results.
$ curl -i -u "user1:user1" http://localhost:8080/user
HTTP/1.1 200
Set-Cookie: JSESSIONID=9BEC44655277BBDF6832817AFF4CAAA1; Path=/; HttpOnly
X-Content-Type-Options: nosniff
X-XSS-Protection: 1; mode=block
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Pragma: no-cache
Expires: 0
X-Frame-Options: DENY
Content-Type: text/plain;charset=UTF-8
Content-Length: 11
Date: Tue, 29 Dec 2020 15:16:57 GMT
Hello user!
Code language: PHP (php)
$ curl -i -u "user1:user1" http://localhost:8080/admin
HTTP/1.1 403
Set-Cookie: JSESSIONID=0910F6115CB28A9DF914D22052396448; Path=/; HttpOnly
X-Content-Type-Options: nosniff
X-XSS-Protection: 1; mode=block
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Pragma: no-cache
Expires: 0
X-Frame-Options: DENY
Content-Type: application/json
Transfer-Encoding: chunked
Date: Tue, 29 Dec 2020 15:17:28 GMT
{
"timestamp" : "2020-12-29T15:17:28.537+00:00",
"status" : 403,
"error" : "Forbidden",
"message" : "",
"path" : "/admin"
}
Code language: PHP (php)
As you see, when user1
tries to access /admin
endpoint they get 403 - Forbidden
message.
This is the GitHub repository for you to follow this tutorial.
Excellent article, thank you for sharing such useful information.
I think better to use @PreAuthorize(“hasPermission(#id, ‘Foo’, ‘read’)”) at controller would increase the readability.
There is no golden way of doing RBAC. In my opinion, keeping the @PreAuthorize at the service level would greatly reduce complexity. If your service is going to be accessed by multiple controllers and controller methods, then all of them will still expect the same privileges. Wherever this deviates, you could very well annotate the controllers. Also, if you are sharing these service beans across multiple modules, you would want consistency across the whole platform of applications.