Содержание

  1. Бизнес‑требования и доменная модель

  2. Роли, enums и наследование

  3. Unit-тестирование иерархии ролей с наследованием

  4. Определяем JPA-сущности

  5. Создаем кастомную реализацию интерфейса Authentication

  6. Почему метод getAuthorities() возвращает пустой set?

  7. UserId и флаг volatile на поле authenticated

  8. Создаем кастомную реализацию интерфейса AuthenticationProvider

  9. Создаем конфиг Spring Security

  10. Определяем методы REST API

  11. Создаем кастомный сервис проверки ролей

  12. Объединяем аннотацию PreAuthorize и кастомный сервис проверки ролей

  13. Короткие и элегантные ссылки на enum-ы в SpEL-выражениях

  14. Интеграционное тестирование и проверка security

  15. Заключение

  16. Ссылки


Когда речь заходит об авторизации, роли вступают в игру. Если модель плоская, то все очевидно. Пользователь обладает определенным набором привелегий и при запросе достаточно лишь проверить, что нужное право доступа присутствует в коллекции. Но как быть, если у пользователя могут быть разные наборы ролей для разных сущностей? Например, я обладаю ролью EDITOR в посте в социальной сети, но имею только VIEWER в другом. Также могут быть определены правила наследования. Если администратор выдает мне роль EDITOR, то я автоматически приобретаю привилегию VIEWER. При этом, если я EDITOR, роль ADMIN у меня не появляется.

Как увязать все эти детали в коде и при этом не превратить продукт в большую кучу грязи? В рамках этой статьи я расскажу вам:

1. Как реализовать наследование ролей в Java?

2. Как протестировать полученную иерархию?

3. Как применить решение в рамках Spring Security?

Вы можете найти примеры кода и весь нижеописанный проект в этом GitHub-репозитории.

Бизнес-требования и доменная модель

Предположим, что мы разрабатываем социальную сеть. Посмотрите на диаграмму ниже. Здесь я обозначил бизнес-сущности, с которыми мы будет работать в рамках этой статьи.

Речь пойдет о трех ключевых сущностях:

  1. User — это тот, кто читает существующие посты и создает новые

  2. Community — это лента, куда можно публиковать новые посты

  3. Post — это отдельная единица медиаинформации. Некоторые пользователи могут просматривать пост, редактировать его и удалять.

Хочу отметить, что наша ролевая модель немного сложнее, чем обычное присваивание плоских ролей. Например, пользователь может обладать ролью EDITOR для определенного поста, но иметь только VIEWER для другого. Или человек может иметь ADMIN для сообщества ‘Cats and dogs’, но только MODERATOR для ‘Motorcycles and Heavy metal’.

Роли предоставляют следующие привилегии:

  1. CommunityRole.ADMIN дает полный доступ к сообществу и всем постам внутри него.

  2. CommunityRole.MODERATOR предоставляет возможность добавлять новые посты и удалять уже существующие в рамках определенного сообщества.

  3. PostRole.EDITOR дает право менять содержимое определенного поста.

  4. PostRole.REPORTER предоставляет доступ отправлять жалобы на неподобающее поведение в комментариях определенного поста.

  5. PostRole.VIEWER дает возможность просматривать конкретный пост.

Тем не менее, бизнес также желает видеть наследование ролей. Например, если я обладаю корневой ролью, то также имею право выполнять действия, которые открывают доступ children nodes. Посмотрите на схему ниже, чтобы понять принцип, о котором я говорю.

Предположим, что я MODERATOR в каком-то сообществе. Это значит, я что я также EDITOR, REPORTER и VIEWER для всех постов в рамках данного сообщества. С другой стороны, если кто-то дал мне роль REPORTER для конкретного поста, это не значит, что я могу его редактировать (для этого нужна роль EDITOR), или добавлять новые посты (роль MODERATOR предоставляет такое право).

Такой подход удобен, потому что отсутствует необходимость проверять множество ролей для каждой операции. Достаточно лишь узнать присутствие самой нижней привилегии, которая дает право на выполнения действия. Если я ADMIN в сообществе, то я и VIEWER для каждого поста в нем. Так что благодаря наследованию проверить нужно только VIEWER, а не все роли выше по иерархии.

Как бы то ни было, способ выглядит не очень простым для реализации в коде. К тому же, у VIEWER есть два родителя: REPORTER и EDITOR. В Java нет множественного наследования, так что нам понадобится более интересный подход.

Роли, enums и наследование

Роли — идеальный кандидат для enum. Посмотрите на пример кода ниже.

public enum CommunityRoleType {
    ADMIN, MODERATOR
}

public enum PostRoleType {
    VIEWER, EDITOR, REPORTER
}

Как построить наследование, используя обычные enum? В первую очередь давайте объявим интерфейс, чтобы объединить роли одним типом. Посмотрите на его декларацию ниже.

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

Интерфейс Role будет базовым для любого значения CommunityRoleType и PostRoleType. Метод includes проверяет, равна ли та роль, что мы передали в методе, текущей, или же содержится она в children nodes, или нет.

Посмотрите на измененный код PostRoleType ниже.

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));
    }
}

Мы сохраняем children конкретной node в обычной Java-коллекции HashSet в поле private final. Интересный момент в том, как связи между nodes образуются. По умолчанию коллекция children пустая для каждого значения enum. Но в игру в вступает static initializer block. Вы можете воспринимать его как двухфазный конструктор. В этом блоке нам нужно установить правильные связи между parent и child. Сам же метод includes довольно примитивен:

  1. Если переданная role равна this, возвращаем true.

  2. Иначе вызываем функцию рекурсивно для каждого child.

Реализация для CommunityRoleType очень похожа. Посмотрите на блок кода ниже.

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));
    }
}

Как видите, у MODERATOR два объекта children: PostRoleType.EDITOR и PostRoleType.REPORTER. Из-за того, что и CommunityRoleType, и PostRoleType расширяют один и тот же интерфейс, значения из обоих enum-ов могут быть частью общей иерархии наследования.

Осталась небольшая деталь. Нам нужно знать корень всей иерархии. Самый простой способ — добавление static метода, который вернет нужную node. Посмотрите на исправленный код интерфейса Role ниже.

public interface Role {
    boolean includes(Role role);

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

Я возвращаю Set<Role> вместо Role, потому что теоретически у иерархии может быть несколько корней. В такой ситуации нет смысла ограничивать количество roots до одного на уровне сигнатуры метода.

Кто-то может спросить: «Почему ты не используешь Spring Security Role Hierarchy? Это ведь готовое решение». Данный компонент удобен для плоских ролей, но у нас ситуация иного рода. Я сделаю ссылку на RoleHierarchy дальше по ходу повествования.

Unit-тестирование иерархии ролей с наследованием

Давайте протестируем построенную иерархию ролей. Сначала проверим, что в графе нет циклов, которые могут привести к StackOverflowError. Посмотрите на тест ниже.

@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);
                }
            }
        }
    );
}

Идея заключается в проверке комбинаций всех roots и всех существующих ролей на вызов includes. Ни один из них не должен бросить исключения.

Следующий шаг — валидация наследования. Проверим кейсы:

  1. Роль CommunityRoleType.ADMIN должна включать любую другую роль и в том числе себя.

  2. Роль CommunityRoleType.MODERATOR должна включать роли: PostRoleType.EDITOR, PostRoleType.REPORTER, PostRoleType.VIEWER и CommunityRoleType.MODERATOR.

  3. Роль PostRoleType.VIEWER не должна включать роль PostRoleType.EDITOR.

  4. Роль CommunityRoleType.MODERATOR не должна включать CommunityRoleType.ADMIN.

Посмотрите ниже на тест, который валидирует обозначенные кейсы.

@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
        )
    );
}

Существуют намного больше кейсов, которые следует покрыть тестами. Но для простоты я их не указываю.

Определяем JPA-сущности

Вы можете найти декларацию всех JPA-сущностей проекта здесь, а соответствующие Flyway-миграции — здесь. Тем не менее, я покажу вам главные entity в системе. Посмотрите ниже на декларацию PostRole и CommunityRole.

@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;
}

Как я уже упомянал, CommunityRole привязывается к User и Community. А PostRole — к User и Post. Следовательно, структура ролей не плоская. Это привносит определенные сложности при интеграции со Spring Security, но не беспокойтесь. Я покажу вам, как их преодолеть.

Как раз из-за вертикальной ролевой модели, Spring Security Role Hierarchy не сработает. Нам нужен более глубокий подход. Так что двигаемся дальше.

Посмотрите на SQL для создания таблиц post_role и community_role (я использую PostgreSQL).

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)
);

Создаем кастомную реализацию интерфейса Authentication

Прежде всего нужно создать реализацию интерфейса Authentication, с которой мы будем работать. Посмотрите на пример кода ниже.

@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;
    }
}

Почему метод getAuthorities() возвращает пустой set?

Первое, что бросается в глаза, — метод getAuthorities() всегда возвращает пустую коллекцию. Почему? Если вы хотите в рамках getAuthorities() возвращать список привелегий, которые есть у пользователя для постов и сообществ, это может выглядеть примерно так:

  1. CommunityRole_ADMIN_234

  2. PostRole_VIEWER_896

Роль разделяется на три части:

  1. Тип роли

  2. Значение роли

  3. ID сообщества или поста, на который эта роль ссылается

При такой настройке проверка ролей станет неудобной. Посмотрите на пример использования @PreAuthorize ниже.

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

На мой взгляд, этот код ужасен попахивает. Во-первых, применение string typings может привести к трудноуловимым багам (например, можно случайно неправильно составить название роли). Во-вторых, мы теряем идею наследования. Помните, что роль CommunityRoleType.ADMIN также включает PostRoleType.VIEWER? Но здесь мы проверяем лишь конкретную authority (Spring Security просто вызывает метод Collection.contains). Это значит, что метод Authentication.getAuthorities() должен возвращать все children роли в том числе. Следовательно, придется провернуть такие шаги:

  1. Выбираем роли всех сообществ и постов, которые назначены пользователю

  2. Проходимся по каждой вплоть до последнего child node и складываем результаты в отдельную коллекцию

  3. Удаляем дубликаты (или просто используем HashSet)

  4. Строим название ролей по паттерну выше и возвращаем в качестве authorities

Помимо того, что код становится сложнее и запутанее, добавляются и проблемы с производительностью. Каждый запрос от клиента будет сопровождаться выбором всех ролей, которые есть у пользователя из БД. Но что если пользователь уже админ в каком-то сообществе? Нет смысла запрашивать роли на уровне поста, потому что у пользователя уже и так гарантировано есть доступ на выполнение операции. Но это придется сделать, чтобы аннотация @PreAuthorize работала ожидаемым образом во всех случаях.

Думаю, теперь понятно, почему я возвращаю пустую коллекцию в качестве вызова метода getAuthorities(). Позже мы рассмотрим, как правильно с этим обращаться.

UserId и флаг volatile на поле authenticated

Взгляните еще раз на объявление PlainAuthentication ниже. Я оставил только методы getPrincipal и isAuthenticated/setAuthenticated для обсуждения.

@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;
    }

    ...
}

Значение userId указывает на строку в БД, где хранится информация о пользователе. Позже мы будем использовать этот ID, чтобы получить роли. Методы isAuthenticated/setAuthenticated являются частью контракта Spring Security. Так что нужно реализовать их правильно. Я добавил маркер volatile, потому что класс PlainAuthentication мутабелен и теоретически его экземпляр может использоваться разными потоками. Так что лучше перестраховаться, чтобы не иметь дело с неожиданными багами.

Метод getPrincipal объявлен в интерфейсе Authentication и его возвращаемое значение — Object. Но Java позволяет возвращать наследников в методах, которые реализуют интерфейс, или наследуют суперкласс (в данном случае, мы возвращаем Long). Это позволяет сделать код более типизированным и безопасным, если мы будем работать с реализацией напрямую.

Интерфейс Authentication также обязуют реализовать еще три метода, о которых я не говорил: getName, getCredentials и getDetails. Ни один из них мы не будем использовать далее по коду, так что я просто возвращаю значения по умолчанию.

Создаем кастомную реализацию интерфейса AuthenticationProvider

Чтобы корректно привязывать объект PlainAuthentication к текущему Security Context, нам понадобится своя реализация AuthenticationProvider. Посмотрите на блок кода ниже.

@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);
    }
}

Конечно, это не production-ready решение. Для простоты у всех пользователей одинаковый пароль — password. Если мы нашли пользователя в БД по указанному имени, возвращаем его ID в виде обертки PlainAuthentication. Иначе — бросаем AuthenticationServiceException, которое будет преобразовано в код ошибки 401.

Создаем конфиг Spring Security

Пришло время добавить конфиг Spring Security. В данном примере я использую basic access authentication. Тем не менее, паттерны проверки ролей останутся такими же для любого иного механизма аутентификации. Посмотрите на пример кода ниже.

@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();
    }
}

Определяем методы REST API

Доменная модель дает много вариантов для возможных операций. Но я приведу всего 4 из них. Этого будет достаточно, чтобы понять идею наследования и проверки ролей, которую я хочу до вас донести. У нас будут следующие endpoints:

  1. Создать новое сообщество

  2. Создать пост в указанном сообществе

  3. Поменять название поста по id

  4. Получить пост по id

Посмотрите на пример кода ниже.

@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) { ... }
}

Как видите, любой пользователь может создать новое сообщество (в нем он автоматически становится админом). Однако я еще не реализовал проверки ролей на остальных endpoints, а только оставил комментарии. Этим мы сейчас и займемся.

Создаем кастомный сервис проверки ролей

Посмотрите на шаблон сервиса проверки ролей ниже.

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

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

Я вручную задал название bean в аннотации @Service. Позже я объясню, зачем это нужно.

Здесь всего два метода. Первый проверяет наличие одной из переданных ролей по communityId, а второй — по postId.

Посмотрите на реализациюhasAnyRoleByCommunityId ниже. Второй метод написан практически так же и вы можете посмотреть код всего класса по этой ссылке.

@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;
    }

    ...
}

Алгоритм такой:

  1. Получаем Authentication пользователя путем вызова SecurityContextHolder.getContext().getAuthentication() и «‎кастуем» результат к PlainAuthentication, потому что это единственный тип, с которым работает наше приложение.

  2. Находим все роли на уровне сообщества по userId и communityId. Если среди переданных ролей есть хотя бы одна в полученном списке из БД (согласно вышеописанной модели наследования), возвращаем true. Иначе — переходим к следующему шагу.

  3. Находим все роли на уровне поста по userId и communityId. Если среди переданных ролей есть хотя бы одна в полученном списке из БД (согласно вышеописанной модели наследования), возвращаем true. Иначе — возвращаем false.‎

Я также хочу обратить ваше внимание на преимущества в производительности при использовании подхода, который я вам предлагаю. В классическом решении в выборке сразу всех существующих ролей и последующим вызовом Authentication.getAuthorities нам придется вытащить все CommunityRole и все PostRole, которые есть у пользователя. А ведь человек может состоять в десятке сообществ и иметь отдельные права на сотни постов. Но в рамках этой статьи мы выполняем нужные действия более эффективно:

  1. Вытаскиваем CommunityRole только для переданной комбинации (userId, communityId).

  2. Вытаскиваем PostRole только для переданной комбинации (userId, communityId).

Если первый шаг сработал, возвращаем true и не выполняем лишних запросов в БД.

Объединяем аннотацию PreAuthorize и кастомный сервис проверки ролей

Наконец-то мы почти готовы к применению механизма проверки ролей на уровне endpoints. Почему почти? Посмотрите на пример кода ниже и обратите внимание на то, что в нем можно улучшить.

@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) { ... }
}

@RoleService ссылается на соответствующий Spring bean RoleService по его названию. Затем я вызываю необходимый метод для проверки доступа. Параметры postId и communityId являются аргументами методов в классе Controller, так что при их использовании в SpEL нужно добавлять префикс с решеткой. Последние параметры — это varargs от ролей, который требуется проверить. Так как Role является базовым интерфейсом для всех enum-реализаций, мы можем подставлять конкретное значение через fully qualified name.

Как вы уже могли догадаться, такое выражение T(com.example.demo.domain.CommunityRoleType).MODERATOR имеет несколько проблем:

  1. Код становится сложнее читать, а также возникает провокация на copy-paste development.

  2. Если поменять название package, где расположен enum с ролью, также придется редактировать все API методы, где он использовался.

Короткие и элегантные ссылки на enum-ы в SpEL-выражениях

Благо есть решение лучше. Посмотрите на исправленный вариант CommunityRoleType ниже.

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;
    }
}

Нужно всего лишь добавить Spring bean, который будет инкапсулировать все значения enum в качестве полей. Теперь мы можем обращаться к ним так же, как и к RoleService.

Изменения PostRoleType будут схожи. Вы можете посмотреть код по этой ссылке.

Давайте немного отрефакторим декларацию API методов. Посмотрите на финальный вариант Controller ниже.

@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) { ... }
}

Намного более красивое решение, как думаете? Проверка ролей декларативна и даже не технари смогут понять логику, которая здесь реализуется (возможно вы захотите сгененировать документацию по правилам разграничения доступа на endpoints).

Интеграционное тестирование и проверка security

Если у вас нет тестов, то нельзя быть уверенным, что код хоть как-то работает. Так что давайте напишем несколько. Я буду проверять следующие кейсы:

  1. Если пользователь не аутентифицирован, запрос на создание нового сообщества возврщает 401.

  2. Если пользователь аутентифицирован, он должен успешно создать новое сообщество и пост внутри него.

  3. Если пользователь аутентифицирован, но у него нет прав на просмотр поста, запрос должен возвращать 403.

Я использую Testcontainers, чтобы запустить PostgreSQL во время тестов. Объяснение настройки окружения выходит за рамки статьи. Но как бы то ни было, вы можете посмотреть весь тестовый класс целиком по этой ссылке.

Посмотрите на пример теста с проверкой запроса от неаутентифицированного пользователя.

@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());
}

Результат теста ожидаемый.

Здесь ничего сложного. Давайте пойдем дальше и проверим успешного создание сообщества и поста внутри него. Посмотрите на пример кода ниже.

@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());
}

Пользователь john создает новое сообщество, а затем добавляет пост внутри него. Опять же, тест проходит успешно.

Перейдем к последнему кейсу. Если у пользователя нет роли PostRoleType.VIEWER (или любого родителя) для определенного поста, то запрос на его получение должен возвращать 403. Посмотрите на пример кода ниже.

@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());
}

Создание нового сообщества и поста такое же, как и в предыдущем тесте. Так что я пропускаю эту часть, чтобы сконцентрировать внимание на важных деталях

Есть два пользователя: john и bob. John создаем сообщество и пост для него, а bob пытается получить этот пост по id. Так как у bob нет требуемых привилегий, сервер должен вернуть 403. Посмотрите на результат выполнения ниже.

Осталась последняя проверка. Запустим все тесты разом, чтобы убедиться, что они детерминированны и не зависят друг от друга.

Все работает как часы. Превосходно!

Заключение

Spring Security прекрасно ладит со сложными иерархиями ролевой модели и правилами наследования. Чуть-чуть паттернов и ваш код сияет.

Это все, что я хотел рассказать о применении ролевой модели в рамках Spring Security. Если у вас есть вопросы, предложения, то пожалуйста оставляйте комментарии ниже. Если материал статьи был для вас полезен, нажмите лайк и поделитесь ссылкой с друзьями и коллегами.

Спасибо вам большое, что прочитали этот длинный материал до конца!

Ссылки

  1. Ссылка на GitHub-репозиторий

  2. Java static initializer block

  3. Spring Security RoleHierarchy интерфейс

  4. Testcontainers

  5. Basic access authentication

  6. Authorization vs authentication

  7. Структура данных polytree

  8. StackOverflowError

  9. Guide to the Volatile Keyword in Java

  10. Инструмент миграций Flyway

Комментарии (5)


  1. maiorBoltach
    00.00.0000 00:00
    +1

    Спасибо за статью.
    Сам буквально месяца полтора назад прошёл путь имплементации полновесного ABAC по примерно такому же пути, только через AuthorizationManagerBeforeMethodInterceptor.

    1. Есть ли какие-то проверки "на износ" под нагрузкой? В какой момент имеет смысл приделать кэширование или достаточно просто просто ходить на каждый запрос в БД?
    2. С практической зрения какое решение будет более эффективное: кастомное через PreAuthorize или Spring ACL?


    1. kirekov Автор
      00.00.0000 00:00

      Спасибо за фидбек.

      1. Целесообразность кэширования нужно определять по результатам load testing. Если система и так справляется, то не вижу в этом смысла, так как кэширование неизбежно усложняет код и может добавить баги в плане консистентности данных. А последнее для ролей крайне важно. Например, вы забрали роль у человека, а в кэше она осталась. То есть он продолжает получать доступ, как и раньше. Вообще, как я вижу, гораздо лучше разбираться в причинах, почему запрос на выборку ролей тормозит. А их может быть много: проблема n + 1, отсутствие нужных индексов, лишние запросы, которые можно устранить, и так далее.

      2. Не применял Spring ACL, так что здесь не могу что-то посоветовать. Но исходя из инфы, которую нашел на Baeldung, как я понимаю, Spring ACL также следует концепции плоской модели (если я ошибаюсь, поправьте меня). А в таком случае проблемы будут все те же самые, как и при классическом вызове метода Authentication.getAuthorities().


  1. dididididi
    00.00.0000 00:00
    +2

    Что по мне, вы уже затащили бизнес логику в магическую спринговую секюрность. Прекрасно, что смогли, но делать так наверно не стоит, потому что, никто кроме вас это починить не сможет.


    1. dimkus
      00.00.0000 00:00

      Поддерживаю.
      У автора видимо подразумевается небольшое приложение с зашитыми в код возможными ролями.
      Но сама изначальная идея про роли подразумевает назначение любых ролей на отдельные сегменты приложения, для доступа к которым пользователь должен содержать указанные роли исходя от бизнес задачи, т.е. должна быть разделена бизнес логика и техническая реализация, а в статье оно смешано.


  1. OlegZH
    00.00.0000 00:00

    Многое не понятно. Не хватает некоторых разъяснений и связок между фрагментами текста для новичка, желающего разобраться в теме. Немного даже обидно, потому что подозреваю глубокую работу, он оценить в полном объёме статью пока не могу.

    Придётся задавать много дополнительных вопросов. Не возражаете?