I will describe requirements during presentation of domain classes.
Model domain
Firstly. We have bunch of users.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 | @Entity public class User { @Id @GeneratedValue private Long id; @Column(nullable = false) private String login; @Column(nullable = false) private String password; @ManyToOne(targetEntity = UserRole.class, optional = false) @JoinColumn(name = "user_role") private UserRole userRole; } |
Each user has some standard attributes like login and password which are not important to us. What is important is user role. In most simplified solution user role can be either admin or "standard role".
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 | @Entity @Inheritance(strategy = InheritanceType.TABLE_PER_CLASS) public abstract class Role { @Id private String roleName; @ManyToMany @JoinTable( name = "role_permissions" ) private Set<Permission> permissions; } @Entity public class UserRole extends Role { } |
Each role comes with set of permissions. In example admin can block users, create new users, add news to home page etc.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 | @Entity public class Permission { @Id private String permissionName; @Override public boolean equals(Object o) { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; Permission that = (Permission) o; if (!permissionName.equals(that.permissionName)) return false; return true; } @Override public int hashCode() { return 31 * permissionName.hashCode(); } } |
These classes describes what user can and cannot do in our application. But this is not everything we have to check.
Users can be assigned to projects.
1 2 3 4 5 6 7 8 9 10 11 12 | @Entity public class Project { @Id @GeneratedValue private Long id; @Column(nullable = false) private String name; @Column(nullable = true) private String description; } |
If user is assigned to project he plays some role in this project. User can be project manager, developer, client etc.
1 2 3 | @Entity public class ProjectRole extends Role { } |
To model this connection there is created additional entity.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 | @Entity public class UserProjectRole { @Id @GeneratedValue private long id; @ManyToOne(optional = false) private User user; @ManyToOne(optional = false) private Project project; @ManyToOne(optional = false) private ProjectRole projectRole; } |
Working with this domain model we have to provide easy to use permission checker which handles two types of permissions: user permissions and project permissions.
Spring environment
Choosing Spring as our framework give us great possibilities. We are going to use following features:
- Data repositories
- Dependency injection
- Configuration components
- Expression-Based Access Control
Repositories
We have to create following repositories:
- PermissionRepository
- ProjectRepository
- RoleRepository
- UserRepository
- UserProjectRepository
1 2 3 4 5 | @Repository public interface UserProjectRoleRepository extends CrudRepository<UserProjectRole, Long> { UserProjectRole findByUserAndProject(User user, Project project); List<UserProjectRole> findByUser(User user); } |
We also have to create UserService which will provide method to get currently logged user.
Basic permission checker
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 | @Component public class PermissionChecker { private final UserService userService; private final UserProjectRoleRepository userProjectRoleRepository; @Autowired public PermissionChecker(UserService userService, UserProjectRoleRepository userProjectRoleRepository) { this.userService = userService; this.userProjectRoleRepository = userProjectRoleRepository; } public boolean hasProjectPermission(Project project, Permission permission) { User user = userService.getLoggedUser(); UserProjectRole userProjectRole = userProjectRoleRepository.findByUserAndProject(user, project); if(userProjectRole != null) { return hasPermission(userProjectRole.getProjectRole().getPermissions(), permission); } else { return false; } } public boolean hasPermission(Permission permission) { User user = userService.getLoggedUser(); return hasPermission(user.getUserRole().getPermissions(), permission); } private boolean hasPermission(Set<Permission> permissions, Permission permission) { return permissions .stream() .filter(setPermission -> setPermission.equals(permission)) .findFirst().isPresent(); } } |
This component has two public methods (except constructor). One for testing project permissions and one for testing user permissions.
In both methods we have to obtain permissions set and check if it contains our permission.
Annotation-based permission checking
We want to check our permissions in following way:
1 2 3 | @PreAuthorize("hasPermission(#id, 'Project', 'project:delete') || hasPermission(#id, 'Project', 'project:everything')") @RequestMapping(value = "/{id}", method = RequestMethod.DELETE, produces = MediaType.APPLICATION_JSON_VALUE) public void delete(@PathVariable("id") Long id) {} |
Using PermissionChecker everywhere is redundant, time-consuming, error-prone and (most important) looks ugly.
How to achieve our goal?
Custom permission evaluator
Permission evaluator is class which handles invocations of hasPermission methods written inside @PreAuthorize annotation.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 | public class CustomPermissionEvaluator implements PermissionEvaluator { public static final String PROJECT_PERMISSION_TYPE = "Project"; private final ProjectRepository projectRepository; private final PermissionChecker permissionChecker; @Autowired public CustomPermissionEvaluator(ProjectRepository projectRepository, PermissionChecker permissionChecker) { this.projectRepository = projectRepository; this.permissionChecker = permissionChecker; } @Override public boolean hasPermission(Authentication authentication, Object targetDomainObject, Object permissionText) { checkNotNull(permissionText); Permission permission = new Permission(permissionText.toString()); if(targetDomainObject != null) { checkArgument(targetDomainObject instanceof Project, "Permissions are allowed only for Project objects"); return permissionChecker.hasProjectPermission((Project) targetDomainObject, permission); } return permissionChecker.hasPermission(permission); } @Override public boolean hasPermission(Authentication authentication, Serializable targetId, String targetType, Object permissionText) { checkArgument(targetType.equals(PROJECT_PERMISSION_TYPE), "Only project and user specific permission check is allowed"); checkNotNull(permissionText); Project project = projectRepository.findOne(Long.parseLong(targetId.toString())); checkNotNull(project); Permission permission = new Permission(); permission.setPermissionName(permissionText.toString()); return permissionChecker.hasProjectPermission(project, permission); } } |
Each PermissionEvaluator has to implement two hasPermission methods. It is required because we can invoke hasPermission (in @PreAuthorize) in many ways:
1 2 3 | @PreAuthorize("hasPermission(#id, 'Project', 'project:delete')") @PreAuthorize("hasPermission(#project, 'project:delete')") @PreAuthorize("hasPermission(null, 'user:delete')") |
Methods calls have different argument number.
First one check if user has permission to delete project and we are passing only project id.
Second one check if user has permission to delete project when we have project object.
Third one check if user has permission to delete user form database (we are basing here on user, not project, role).
Enabling our permission evaluator
After writing our own permission evaluator we have to enable it in our application. To do this we have to use @Configuration annotation.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 | @Configuration @EnableGlobalMethodSecurity(prePostEnabled = true) public class MethodSecurityConfig extends GlobalMethodSecurityConfiguration { @Autowired private ProjectRepository projectRepository; @Autowired private PermissionChecker permissionChecker; public MethodSecurityConfig() { } @Autowired public MethodSecurityConfig(ProjectRepository projectRepository, PermissionChecker permissionChecker) { this.projectRepository = projectRepository; this.permissionChecker = permissionChecker; } @Override protected MethodSecurityExpressionHandler createExpressionHandler() { CustomPermissionEvaluator permissionEvaluator = new CustomPermissionEvaluator(projectRepository, permissionChecker); DefaultMethodSecurityExpressionHandler expressionHandler = new DefaultMethodSecurityExpressionHandler(); expressionHandler.setPermissionEvaluator(permissionEvaluator); return expressionHandler; } } |
@EnableGlobalMethodSecurity(prePostEnabled = true) enables @PreAuthorize
annotation.