Spring Security and Non-flat Roles Inheritance Architecture

kirekov

Semyon Kirekov

Posted on February 23, 2023

Spring Security and Non-flat Roles Inheritance Architecture

Table of contents

  1. Business requirements and domain model
  2. Roles, enums, and inheritance
  3. Unit testing roles inheritance
  4. Defining JPA entities
  5. Creating custom Authentication implementation
    1. Why does getAuthorities() return empty set?
    2. UserId, and volatile authenticated flag
  6. Creating custom AuthenticationProvider
  7. Defining Spring Security config
  8. Declaring REST API methods
  9. Creating custom role checking service
  10. Combining PreAuthorize and custom role checking service
  11. Short and elegant enum references in SpEL expressions
  12. Integration testing and validating security

Then it comes to authorization, roles always come into play. Flat ones are straightforward. User possess a set of privileges that you simply check for containing the required element. But what if a user can have different roles for specific entities? For example, I can be an editor in the particular social media post but only a viewer in another one. Also, there might be inheritance rules. If an admin grant me the editor role, I automatically become a viewer. But if I'm an editor, I'm not becoming an admin. What's the approach to handle the scenario to remain code clean and maintainable? Don't worry, I'm giving your answers.

In this article, I'm telling you:

  1. What's the best approach to handle roles inheritance in Java?
  2. How to test the stated hierarchy?
  3. How to apply the solution in Spring Security and Spring Data JPA?

You can find the code examples and the entire project setup in this repository.

Meme article cover

Business requirements and domain model

Supposing we’re developing a simple social media platform. Look at the diagram below. Here I described the business entities we’re working with in this article.

Business entities

There are 3 core entities:

  1. User is the one who reads existing posts and creates new ones.
  2. Community is the feed to submit new posts.
  3. Post is an individual part of media. Some users can view it, edit it, and delete it.

Also, we have role a model which is a bit more complicated than the plain privileges assignment. For example, a user can owe an EDITOR role for the particular post but has only a VIEWER for another one. Or user may be an ADMIN for the ‘Cats and dogs’ community but just a MODERATOR for the ‘Motorcycles and Heavy metal’ one.

The roles provide such privileges:

  1. CommunityRole.ADMIN gives ultimate access to the community and the containing posts.
  2. CommunityRole.MODERATOR provides an ability to add new posts and remove old ones.
  3. PostRole.EDITOR allows to edit the content of the particular post.
  4. PostRole.REPORTER gives the credit to send reports about inappropriate attitude in the comments.
  5. PostRole.VIEWER grants an access to view the post and leave comments.

However, business also wants the inheritance model. For example, if I have the root role, it means I also can do actions that any child provides. Look at the schema below to understand the approach.

Roles inheritance model

Suppose that I'm a MODERATOR in some community. That means, I'm EDITOR, REPORTER, and VIEWER as well for any other post in the community. On the contrary, if somebody granted me with the REPORTER role for the post, it doesn't mean I have the rights to edit it (I need the EDITOR for that) or add new posts (the MODERATOR role provides access for it).

That's a convenient approach. You don't have to check the set of many roles for every possible operation. You just have to validate the presence of the lowest authority. If I'm an ADMIN in the community, then I'm also a VIEWER for any post there. So, due to the inheritance model, you have to check the VIEWER and that's it.

Anyway, it doesn't seem like an simple task to implement it in the code. Besides, the VIEWER model has two parents: the REPORTER and the EDITOR. The Java doesn't allow multiple inheritance. Meaning that we need a special approach.

Roles, enums, and inheritance

Roles are the perfect candidates for the enum. Look at the code snippet below.



public enum CommunityRoleType {
    ADMIN, MODERATOR
}

public enum PostRoleType {
    VIEWER, EDITOR, REPORTER
}


Enter fullscreen mode Exit fullscreen mode

How we can build an inheritance model with these simple enums? At first, we need an interface to bind the roles together. Look at the declaration below.



public interface Role {
    boolean includes(Role role);
}


Enter fullscreen mode Exit fullscreen mode

The Role interface will be the root of any CommunityRoleType and PostRoleType value as well. The includes method tells us whether the supplied role equals to the current one or contains it in its children.

Look at the modified PostRoleType code below.



public enum PostRoleType implements Role {
    VIEWER, EDITOR, REPORTER;

    private final Set<Role> children = new HashSet<>();

    static {
        REPORTER.children.add(VIEWER);
        EDITOR.children.add(VIEWER);
    }

    @Override
    public boolean includes(Role role) {
        return this.equals(role) || children.stream().anyMatch(r -> r.includes(role));
    }
}


Enter fullscreen mode Exit fullscreen mode

We store the children of the particular role in the regular Java HashSet as private final field. What intriguing is how these children appear. By default, the set is empty for every enum value. But the static initializer block comes into play. You can treat it as the two-phase constructor. On this block, we just need to assign proper children to the required parents. The includes method is also rather simple. If the passed role equals to the current one, return true. Otherwise, perform the check recursively for every present child node.

An approach for the CommunityRoleType is similar. Look at the code example below.



public enum CommunityRoleType implements Role {
    ADMIN, MODERATOR;

    private final Set<Role> children = new HashSet<>();

    static {
        ADMIN.children.add(MODERATOR);
        MODERATOR.children.addAll(List.of(PostRoleType.EDITOR, PostRoleType.REPORTER));
    }

    @Override
    public boolean includes(Role role) {
        return this.equals(role) || children.stream().anyMatch(r -> r.includes(role));
    }
}


Enter fullscreen mode Exit fullscreen mode

As you can see, the MODERATOR role has two children: the PostRoleType.EDITOR and the PostRoleType.REPORTER. Due to the fact that both CommunityRoleType and the PostRoleType share the same interface, they can all be part of the one inheritance hierarchy.

A slight detail remains. We need to know the root of the hierarchy to perform the access validation. The easiest way is just declaring a static method that returns the required nodes. Look at the updated Role interface definition below.



public interface Role {
    boolean includes(Role role);

    static Set<Role> roots() {
        return Set.of(CommunityRoleType.ADMIN);
    }
}


Enter fullscreen mode Exit fullscreen mode

I return the Set<Role> instead of Role because theoretically there might be several roots. So, it's unnecessary to restrict the roots count to 1 on the method signature.

Some of you may ask why not just using Spring Security Role Hierarchy component? After all, it's an out-of-box solution. It suites well for the plain role model which is not the case in our context. I'll reference this point again later in the article.

Unit testing roles inheritance

Let's test our role hierarchy. Firstly, we need to check that there are no cycles which can cause StackOverflowError. Look at the test below.



@Test
void shouldNotThrowStackOverflowException() {
    final var roots = Role.roots();
    final var existingRoles = Stream.concat(
        stream(PostRoleType.values()),
        stream(CommunityRoleType.values())
    ).toList();

    assertDoesNotThrow(
        () -> {
            for (Role root : roots) {
                for (var roleToCheck : existingRoles) {
                    root.includes(roleToCheck);
                }
            }
        }
    );
}


Enter fullscreen mode Exit fullscreen mode

The idea is checking all roots and all existing roles for includes combinations. None of them should throw any exception.

Next move is validating the inheritance. Here are the test cases:

  1. The CommunityRoleType.ADMIN should include any other role and the CommunityRoleType.ADMIN itself.
  2. The CommunityRoleType.MODERATOR should include the PostRoleType.EDITOR, PostRoleType.REPORTER, PostRoleType.VIEWER and the CommunityRoleType.MODERATOR
  3. The PostRoleType.VIEWER should not include the PostRoleType.REPORTER.
  4. The CommunityRoleType.MODERATOR should not include the CommunityRoleType.ADMIN.

Look at the code example below to see the described test suites.



@ParameterizedTest
@MethodSource("provideArgs")
void shouldIncludeOrNotTheGivenRoles(Role root, Set<Role> rolesToCheck, boolean shouldInclude) {
    for (Role role : rolesToCheck) {
        assertEquals(
            shouldInclude,
            root.includes(role)
        );
    }
}

private static Stream<Arguments> provideArgs() {
    return Stream.of(
        arguments(
            CommunityRoleType.ADMIN,
            Stream.concat(
                stream(PostRoleType.values()),
                stream(CommunityRoleType.values())
            ).collect(Collectors.toSet()),
            true
        ),
        arguments(
            CommunityRoleType.MODERATOR,
            Set.of(PostRoleType.EDITOR, PostRoleType.VIEWER, PostRoleType.REPORTER, CommunityRoleType.MODERATOR),
            true
        ),
        arguments(
            PostRoleType.VIEWER,
            Set.of(PostRoleType.REPORTER),
            false
        ),
        arguments(
            CommunityRoleType.MODERATOR,
            Set.of(CommunityRoleType.ADMIN),
            false
        )
    );
}


Enter fullscreen mode Exit fullscreen mode

There are much more cases to cover. But I omit them for brevity.

Defining JPA entities

You can find all the JPA entities declarations here and the corresponding Flyway migrations here.

Anyway, I'm showing you the core artefacts of the system. Look at the PostRole and the CommunityRole JPA entities' declaration below.



@Entity
@Table(name = "community_role")
public class CommunityRole {
    @Id
    @GeneratedValue(strategy = IDENTITY)
    private Long id;

    @ManyToOne(fetch = LAZY)
    @JoinColumn(name = "user_id")
    private User user;

    @ManyToOne(fetch = LAZY)
    @JoinColumn(name = "community_id")
    private Community community;

    @Enumerated(STRING)
    private CommunityRoleType type;
}

@Entity
@Table(name = "post_role")
public class PostRole {
    @Id
    @GeneratedValue(strategy = IDENTITY)
    private Long id;

    @ManyToOne(fetch = LAZY)
    @JoinColumn(name = "user_id")
    private User user;

    @ManyToOne(fetch = LAZY)
    @JoinColumn(name = "post_id")
    private Post post;

    @Enumerated(STRING)
    private PostRoleType type;
}


Enter fullscreen mode Exit fullscreen mode

As I've already pointed out, the CommunityRole binds to the User and the particular Community whilst the PostRole binds to the User as well and the specific Post. Therefore, the role model structure is not flat. It brings us some complexities with Spring Security. But don't worry, I'll show you how to nail them.

Because of the vertical role model the Spring Security Role Hierarchy is not going to work. We need a more complicated approach. So, let’s move forward.

Look at the required SQL migrations' set (I'm using PostgreSQL) below.



CREATE TABLE community_role
(
    id           BIGSERIAL PRIMARY KEY,
    user_id      BIGINT REFERENCES users (id)     NOT NULL,
    community_id BIGINT REFERENCES community (id) NOT NULL,
    type         VARCHAR(50)                      NOT NULL,
    UNIQUE (user_id, community_id, type)
);

CREATE TABLE post_role
(
    id      BIGSERIAL PRIMARY KEY,
    user_id BIGINT REFERENCES users (id) NOT NULL,
    post_id BIGINT REFERENCES post (id)  NOT NULL,
    type    VARCHAR(50)                  NOT NULL,
    UNIQUE (user_id, post_id, type)
);


Enter fullscreen mode Exit fullscreen mode

Creating custom Authentication implementation

To begin with, we need to create custom Authentication interface implementation. Look at the code snippet below.



@RequiredArgsConstructor
public class PlainAuthentication implements Authentication {
    private final Long userId;
    private volatile boolean authenticated = true;

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

    @Override
    public Long getPrincipal() {
        return userId;
    }

    @Override
    public String getName() {
        return "";
    }

    @Override
    public boolean isAuthenticated() {
        return authenticated;
    }

    @Override
    public void setAuthenticated(boolean isAuthenticated) throws IllegalArgumentException {
        authenticated = isAuthenticated;
    }

    @Override
    public Object getCredentials() {
        return "";
    }

    @Override
    public Object getDetails() {
        return null;
    }
}


Enter fullscreen mode Exit fullscreen mode

Why does getAuthorities() return empty set?

The first thing to notice is that the getAuthorities() method always returns empty collection. Why is that? The Spring Security role model is flat, but we deal with a vertical one. If you want to put the community and the post roles belonging to the user, it may look like this:

  1. CommunityRole_ADMIN_234
  2. PostRole_VIEWER_896

There are three parts:

  1. The type of the role
  2. The role value
  3. The id of the community or the post that the role references to.

In such setup, the role checking mechanism also become cumbersome. Look at the @PreAuthorize annotation usage example below.



@PreAuthorize("hasAuthority('PostRole_VIEWER_' + #postId)")
@GetMapping("/api/post/{postId}")
public PostResponse getPost(@PathVariable Long postId) { ... }


Enter fullscreen mode Exit fullscreen mode

In my view, this code is awful smells. Not only there are string typings that may cause hard tracking bugs, we lost the inheritance idea. Do you remember that the CommunityRole.ADMIN also includes the PostRole.VIEWER? But here we check only the particular authority (Spring Security just calls the Collection.contains method). Meaning that the Authentication.getAuthorities() method has to contain all the children's roles as well. Therefore, you have to perform these steps:

  1. Select all the community roles and the post roles as well that the user possess
  2. Loop through each role down to its children and put each occurred node to another collection
  3. Eliminate the duplicates (or just use the HashSet) and return the result as the user authorities.

Not to mention the code complexity, there are also performance drawbacks. Every request comes with selecting all the user's roles from the database. But what if the user is an admin of the community? It's unnecessary to query post roles because they already have an ultimate access. But you have to do it to make the @PreAuthorize annotation work as expected.

I thing it's clear now why I return empty collection of authorities. Later I'm explaining how to deal with that properly.

UserId, and volatile authenticated flag

Look at the PlainAuthentication below. I leave only the getPrincipal, and isAuthenticated/setAuthenticated methods to discuss.



@RequiredArgsConstructor
public class PlainAuthentication implements Authentication {
    private final Long userId;
    private volatile boolean authenticated = true;

    @Override
    public Long getPrincipal() {
        return userId;
    }

    @Override
    public boolean isAuthenticated() {
        return authenticated;
    }

    @Override
    public void setAuthenticated(boolean isAuthenticated) throws IllegalArgumentException {
        authenticated = isAuthenticated;
    }

    ...
}


Enter fullscreen mode Exit fullscreen mode

The userId points to the database row containing information about the user. We’ll use it later to retrieve the roles. The isAuthenticated/setAuthenticated methods are part of the Spring Security contract. So, we have to implement them properly. I put volatile marker because the PlainAuthentication object is mutable and multiple threads can access it. Better safe than sorry.

The getPrincipal method inherits from the Authentication interface and returns Object. However, Java allows to return the subclass of the signature definition, if you extend the base class or implement the interface. Therefore, make your code more secure and maintainable.

The Authentication interface dictates to implement 3 other methods I haven't spoken about. Those are getName, getCredentials, and getDetails. We use none of them later. Therefore, it's OK to return default values.

Creating custom AuthenticationProvider

In the beginning, we should provide custom AuthenticationProvider to resolve the PlainAuthentication declared previously from the user input. Look at the code snippet below.



@Component
@RequiredArgsConstructor
class DbAuthenticationProvider implements AuthenticationProvider {
    private final UserRepository userRepository;

    @Override
    public Authentication authenticate(Authentication authentication) throws AuthenticationException {
        final var password = authentication.getCredentials().toString();
        if (!"password".equals(password)) {
            throw new AuthenticationServiceException("Invalid username or password");
        }
        return userRepository.findByName(authentication.getName())
                   .map(user -> new PlainAuthentication(user.getId()))
                   .orElseThrow(() -> new AuthenticationServiceException("Invalid username or password"));
    }

    @Override
    public boolean supports(Class<?> authentication) {
        return UsernamePasswordAuthenticationToken.class.equals(authentication);
    }
}


Enter fullscreen mode Exit fullscreen mode

Of course, it's not a real production implementation. For the sake of simplicity, all users have the same password. If the user is present by the provided name, return its id as a PlainAuthentication wrapper. Otherwise, throw AuthenticationServiceException that will transform to the 401 status code afterwards.

Defining Spring Security config

Finally, time to add the Spring Security config. Here I’m using basic access authentication. Anyway, the role checking patterns I’m describing later remain the same for different authentication mechanisms. Look at the code example below.



@Configuration
@EnableWebSecurity
@EnableMethodSecurity
@RequiredArgsConstructor
public class SecurityConfig {
    @Bean
    @SneakyThrows
    public SecurityFilterChain securityFilterChain(HttpSecurity http) {
        return http
                   .csrf().disable()
                   .cors().disable()
                   .authorizeHttpRequests(customizer -> customizer.anyRequest().authenticated())

                   .httpBasic()
                   .authenticationEntryPoint((request, response, authException) -> response.sendError(401))
                   .and()

                   .build();
    }
}


Enter fullscreen mode Exit fullscreen mode

Declaring REST API methods

The described domain can provide lots of possible operations. Though I’m going to list just 4 of them. That is sufficient to cover the whole role checking idea of mine. Those are:

  1. Create new community.
  2. Create post for the given community.
  3. Update the post by id.
  4. Get post by id.

Look at the code snippet below.



@RestController
@RequestMapping("/api")
public class Controller {
    @PostMapping("/community")
    @PreAuthorize("isAuthenticated()")
    public CommunityResponse createCommunity(@RequestParam String name) { ... }

    @PostMapping("/community/{communityId}/post")
    // Must have CommunityRoleType.MODERATOR role
    public PostResponse createPost(@PathVariable Long communityId, @RequestParam String name) { ... }

    @PutMapping("/post/{postId}")
    // Must have PostRoleType.EDITOR
    public void updatePost(@PathVariable Long postId, @RequestParam String name) { ... }

    @GetMapping("/post/{postId}")
    // Must have PostRoleType.VIEWER role
    public PostResponse getPost(@PathVariable Long postId) { ... }
}


Enter fullscreen mode Exit fullscreen mode

As you can see, every user can create a new community (the creator automatically becomes an admin of the entity as well). However, I haven't implemented the required checks on the endpoints yet. At first we need the role checking service.

Creating custom role checking service

Look at the blueprint of the RoleService below.



@Service("RoleService")
public class RoleService {
    public boolean hasAnyRoleByCommunityId(Long communityId, Role... roles) { ... }

    public boolean hasAnyRoleByPostId(Long postId, Role... roles) { ... }
}


Enter fullscreen mode Exit fullscreen mode

I set the bean name inside the @Service annotation manually. Soon I'll explain the idea to you.

There are only two methods. The first one checks the required role presence by the communityId and the second one by the postId.

Look at the hasAnyRoleByCommunityId implementation below. The other method has the same idea and you can check out the whole class by this link.



@Service("RoleService")
@RequiredArgsConstructor
public class RoleService {
    private final CommunityRoleRepository communityRoleRepository;
    private final PostRoleRepository postRoleRepository;

    @Transactional
    public boolean hasAnyRoleByCommunityId(Long communityId, Role... roles) {
        final Long userId = ((PlainAuthentication) SecurityContextHolder.getContext().getAuthentication()).getPrincipal();
        final Set<CommunityRoleType> communityRoleTypes =
            communityRoleRepository.findRoleTypesByUserIdAndCommunityId(userId, communityId);
        for (Role role : roles) {
            if (communityRoleTypes.stream().anyMatch(communityRoleType -> communityRoleType.includes(role))) {
                return true;
            }
        }
        final Set<PostRoleType> postRoleTypes =
            postRoleRepository.findRoleTypesByUserIdAndCommunityId(userId, communityId);
        for (Role role : roles) {
            if (postRoleTypes.stream().anyMatch(postRoleType -> postRoleType.includes(role))) {
                return true;
            }
        }
        return false;
    }

    ...
}


Enter fullscreen mode Exit fullscreen mode

Here is the algorithm:

  1. Get the current user Authentication by calling SecurityContextHolder.getContext().getAuthentication() and cast it to the PlainAuthentication because that's the only type our application working with.
  2. Find all community roles by the userId and the communityId. If any of the passed roles (according to the role inheritance model) are present in the set of the found community roles, return true. Otherwise, go to the next step.
  3. Find all post roles by the userId and the communityId. If any of the passed roles (according to the role inheritance model) are present in the set of the found post roles, return true. Otherwise, return false.

I also want to point out the performance benefits of the solution I'm proposing to you. The classic approach with retrieving all user authorities and calling further the Authentication.getAuthorities method requires us to pull every CommunityRole and each PostRole the user has from the database at once (note that the user might be a member of dozens of communities and posses hundreds of roles). But we do it much more efficiently:

  1. Pull the community roles only for the provided combination of (userId, communityId).
  2. Pull the post roles only for the provided combination of (userId, communityId).

If the first step succeeds, the method returns true and doesn't perform another database query.

Combining PreAuthorize and custom role checking service

Finally, we set almost everything and we’re ready to apply the role checking mechanism at the endpoints. Why almost? Look at the code snippet below and try to notice an improvement slot.



@RestController
@RequestMapping("/api")
public class Controller {
    @PostMapping("/community")
    @PreAuthorize("isAuthenticated()")
    public CommunityResponse createCommunity(@RequestParam String name) { ... }

    @PostMapping("/community/{communityId}/post")
    @PreAuthorize("@RoleService.hasAnyRoleByCommunityId(#communityId, T(com.example.demo.domain.CommunityRoleType).MODERATOR)")
    public PostResponse createPost(@PathVariable Long communityId, @RequestParam String name) { ... }

    @PutMapping("/post/{postId}")
    @PreAuthorize("@RoleService.hasAnyRoleByPostId(#postId, T(com.example.demo.domain.PostRoleType).EDITOR)")
    public void updatePost(@PathVariable Long postId, @RequestParam String name) { ... }

    @GetMapping("/post/{postId}")
    @PreAuthorize("@RoleService.hasAnyRoleByPostId(#postId, T(com.example.demo.domain.PostRoleType).VIEWER)")
    public PostResponse getPost(@PathVariable Long postId) { ... }
}


Enter fullscreen mode Exit fullscreen mode

The @RoleService is the reference to the RoleService Spring bean by its name. Then I call the specific role checking method. The postId and the communityId are the method arguments and we have to prefix their usage with hash. The last parameters are varargs of the required roles. Because the Role interface implementations are enums, we can reference the values by their fully qualified names.

As you have already guessed, this statement T(com.example.demo.domain.CommunityRoleType).MODERATOR has 2 problems:

  1. It makes code harder to read and provokes copy-paste development.
  2. If you change the package name of any enum role, then you have to update the dependent API methods accordingly.

Short and elegant enum references in SpEL expressions

Thankfully, there is more concise solution. Look at the fixed CommunityRoleType definition below.



public enum CommunityRoleType implements Role {
    ADMIN, MODERATOR;

    ...

    @Component("CommunityRole")
    @Getter
    static class SpringComponent {
        private final CommunityRoleType ADMIN = CommunityRoleType.ADMIN;
        private final CommunityRoleType MODERATOR = CommunityRoleType.MODERATOR;
    }
}


Enter fullscreen mode Exit fullscreen mode

You just need to create another Spring bean that encapsulates the enum values as fields. Then you can reference them the same way we did with the RoleService.

The PostRoleType enhancing is similar. You can check out the code by this link.

Let's refactor the API methods a bit and see the final result. Look at the fixed Controller below.



@RestController
@RequestMapping("/api")
public class Controller {
    @PostMapping("/community")
    @PreAuthorize("isAuthenticated()")
    public CommunityResponse createCommunity(@RequestParam String name) { ... }

    @PostMapping("/community/{communityId}/post")
    @PreAuthorize("@RoleService.hasAnyRoleByCommunityId(#communityId, @CommunityRole.ADMIN)")
    public PostResponse createPost(@PathVariable Long communityId, @RequestParam String name) { ... }

    @PutMapping("/post/{postId}")
    @PreAuthorize("@RoleService.hasAnyRoleByPostId(#postId, @PostRole.EDITOR)")
    public void updatePost(@PathVariable Long postId, @RequestParam String name) { ... }

    @GetMapping("/post/{postId}")
    @PreAuthorize("@RoleService.hasAnyRoleByPostId(#postId, @PostRole.VIEWER)")
    public PostResponse getPost(@PathVariable Long postId) { ... }
}


Enter fullscreen mode Exit fullscreen mode

Much more elegant solution, don’t you think? The role checking is declarative now and even non-technical folks can understand it (maybe you wish to generate documentation about endpoints restrictions' rules).

Integration testing and validating security

If there are no tests, you cannot be sure that your code is working at all. So, let's write some. I'm going to verify these cases:

  1. If a user is not authenticated, creating a new community request should return 401.
  2. If a user is authenticated, they should create a new community and new post inside it successfully.
  3. If a user is authenticated but he has no rights to view the post, the request should return 403

I'm using Testcontainers to start PostgreSQL during tests. The explaining of its setup is out of the scope of this article. Anyway, you can check out the whole test suite by this link.

Look at the unauthorized request checking below.



@Test
void shouldReturn401IfUnauthorizedUserTryingToCreateCommunity() {
    userRepository.save(User.newUser("john"));

    final var communityCreatedResponse =
        rest.postForEntity(
            "/api/community?name={name}",
            null,
            CommunityResponse.class,
            Map.of("name", "community_name")
        );

    assertEquals(UNAUTHORIZED, communityCreatedResponse.getStatusCode());
}


Enter fullscreen mode Exit fullscreen mode

The test works as expected.

Unauthorized user check

Nothing complicated here. Let’s move forward to checking community and post successful creation. Look at the code snippet down below.



@Test
void shouldCreateCommunityAndPostSuccessfully() {
    userRepository.save(User.newUser("john"));

    final var communityCreatedResponse =
        rest.withBasicAuth("john", "password")
            .postForEntity(
                "/api/community?name={name}",
                null,
                CommunityResponse.class,
                Map.of("name", "community_name")
            );
    assertTrue(communityCreatedResponse.getStatusCode().is2xxSuccessful());
    final var communityId = communityCreatedResponse.getBody().id();

    final var postCreatedResponse =
        rest.withBasicAuth("john", "password")
            .postForEntity(
                "/api/community/{communityId}/post?name={name}",
                null,
                PostResponse.class,
                Map.of("communityId", communityId, "name", "post_name")
            );
    assertTrue(postCreatedResponse.getStatusCode().is2xxSuccessful());
}


Enter fullscreen mode Exit fullscreen mode

The john user creates a new community and then adds a new post for it. Again, everything works smoothly.

Create new community and post

Let's get to the final case. If user has no PostRoleType.VIEWER for the particular post, the request of getting it should return 403. Look at the code block below.



@Test
void shouldReturn403IfUserHasNoAccessToViewThePost() {
    userRepository.save(User.newUser("john"));
    userRepository.save(User.newUser("bob"));

    // john creates new community and post inside it
    ...    

    final var postViewResponse =
        rest.withBasicAuth("bob", "password")
            .getForEntity(
                "/api/post/{postId}",
                PostResponse.class,
                Map.of("postId", postId)
            );
    assertEquals(FORBIDDEN, postViewResponse.getStatusCode());
}


Enter fullscreen mode Exit fullscreen mode

The creation of community and post is the same as in the previous test. So, I'm omitting these parts to focus on important details.

There are two pre-configured users: john and bob. The john user creates a new community and post whilst bob tries to get the post by its id. As long as he doesn’t possess the required privileges, the server returns 403 code. Look at the result of the test running below.

403 test result

And the final check comes. Let’s run all tests at once to validate that their behavior is deterministic.

All tests results

Everything works like a charm. Splendid!

Conclusion

As you can see, Spring Security plays well with a complex role hierarchy and inheritance model. Just a few patterns and your code shines.

That's all I wanted to tell you about applying role model with Spring Security. If you have any questions or suggestions, please leave your comments down below. If the content of the article was useful, press the like button and share the link with you friends and colleagues.

Thank you very much for reading this long piece!

Resources

  1. The entire project setup
  2. Java static initializer block
  3. Spring Security Role Hierarchy interface
  4. Testcontainers
  5. Basic access authentication
  6. Authorization vs authentication
  7. The polytree data structure
  8. StackOverflowError
  9. Guide to the Volatile Keyword in Java
  10. Flyway migration tool
💖 💪 🙅 🚩
kirekov
Semyon Kirekov

Posted on February 23, 2023

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related