Введение в Spring Security ACL

Access Control List (ACL)  — это список разрешений на объект.  Этот список определяет, какому пользователю (или какой роли) какие операции разрешены над конкретным экземпляром объекта.

Например, есть документы. Можно задать, что

  • пользователь user1 имеет право просматривать объект Document с id=1;
  • user2 имеет право просматривать Document с id=2;
  • а admin может просматривать и редактировать все документы.

Чтобы использовать ACL, важно разобраться с четырьмя служебными таблицами, которые служат адаптером между нашей пользовательской базой и Spring Security ACL. А затем останется только правильно заполнить таблицы и поставить на методы разрешающие аннотации.

Но перед тем, как перейти к схеме, приведем обычную Web Security конфигурацию приложения.

Web Security конфигурация. Пользователи и url

В конфигурации заданы три пользователя (user1, user2 и admin), Basic Auth.  Доступ открыт всем по любому url (ограничения будут потом — на уровне методов):

@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {


    @Bean
    public PasswordEncoder passwordEncoder() {
        return NoOpPasswordEncoder.getInstance();
    }

    @Override
    public void configure(AuthenticationManagerBuilder auth) throws Exception {

        auth.inMemoryAuthentication()
                .withUser("user1")
                .password("user1")
                .authorities("ROLE_USER")
                .and()
                .withUser("user2")
                .password("user2")
                .authorities("ROLE_USER")
                .and()
                .withUser("admin")
                .password("admin")
                .authorities("ROLE_ADMIN");
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.authorizeRequests().antMatchers("/**").permitAll()
                .and().httpBasic()
                .and().csrf().disable();

    }
}

Будет еще RestController с операциями  create, read, update над документами. И JPA-репозиторий для них же. Методы контроллера и репозитория мы будем защищать.

Модель — Document

И еще класс объектов, на которые мы будем давать разрешения:

@Entity
@Data
@NoArgsConstructor
@AllArgsConstructor
public class Document implements IEntity {

    @Id
    private Integer id;

    private String content;

    public Document(String content) {
        this.content=content;
    }
}

Наконец, к перейдем схеме.

Схема и данные для ACL

Spring Security ACL имеет четыре служебные таблицы. Их необходимо создать.

Таблица ACL_SID — кому даем разрешения

В таблице acl_sid перечисляются пользователи и роли (все в одной таблице), которым будут даваться разрешения:

CREATE TABLE IF NOT EXISTS acl_sid (
  id bigint(20) NOT NULL AUTO_INCREMENT,
  principal tinyint(1) NOT NULL,
  sid varchar(100) NOT NULL,
  PRIMARY KEY (id),
  UNIQUE KEY unique_uk_1 (sid,principal)
);

Самое главное в ней — строковый столбец sid , в котором содержится либо имя пользователя (Authentication.principal.username), либо название роли. У нас тут будет имя пользователя:

INSERT INTO acl_sid (id, principal, sid) VALUES
(1, 1, 'user1'),
(2, 1, 'user2'),
(3, 1, 'admin');

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

В столбце principal уточняется: пользователь это или роль. Значение 1 означает пользователя, а 0 — роль. Мы будем давать разрешения пользователям.

Таблицы ACL_OBJECT_IDENTITY и ACL_CLASS — на какие объекты даем разрешения

В таблице acl_object_identity задается, на что (на какие объекты) будут даваться разрешения. Главное тут — object_id_identity — поле идентификатора объекта, но к нему идет уточнение — object_id_class  — класс. (Уточнение нужно, поскольку в таблице acl_object_identity хранятся объекты всех классов — будь то документы, сообщения, животные или любые другие объекты, на которые мы решили сделать ACL).

Классы хранятся в отдельной вспомогательной таблице acl_class:

CREATE TABLE IF NOT EXISTS acl_class (
  id bigint(20) NOT NULL AUTO_INCREMENT,
  class varchar(255) NOT NULL,
  PRIMARY KEY (id),
  UNIQUE KEY unique_uk_2 (class)
);

CREATE TABLE IF NOT EXISTS acl_object_identity (
  id bigint(20) NOT NULL AUTO_INCREMENT,
  object_id_class bigint(20) NOT NULL,
  object_id_identity bigint(20) NOT NULL,
  parent_object bigint(20) DEFAULT NULL,
  owner_sid bigint(20) DEFAULT NULL,
  entries_inheriting tinyint(1) NOT NULL,
  PRIMARY KEY (id),
  UNIQUE KEY unique_uk_3 (object_id_class,object_id_identity)
);
ALTER TABLE acl_object_identity
ADD FOREIGN KEY (parent_object) REFERENCES acl_object_identity (id);

ALTER TABLE acl_object_identity
ADD FOREIGN KEY (object_id_class) REFERENCES acl_class (id);

ALTER TABLE acl_object_identity
ADD FOREIGN KEY (owner_sid) REFERENCES acl_sid (id);

У нас имеются объекты ru.sysout.model.Document — на них будут даваться разрешения. Хранятся они в таблице:

INSERT INTO document(id,content) VALUES
(1,'Document 1'),
(2,'Document 2'),
(3,'Document 3');

Поэтому в таблице acl_class должна быть запись о классе объектов:

INSERT INTO acl_class (id, class) VALUES
(1, 'ru.sysout.model.Document');

А в таблице acl_object_identity наши три документа хранятся с внешними ключами на таблицы document и acl_class:

INSERT INTO acl_object_identity (id, object_id_class, object_id_identity, parent_object, owner_sid, entries_inheriting) VALUES
(1, 1, 1, NULL, 3, 0),
(2, 1, 2, NULL, 3, 0),
(3, 1, 3, NULL, 3, 0);

то есть:

acl_object_identity.object_id_identity=document.id
acl_object_identity.object_id_class=acl_class.id

Поле owner_sid не очень важно, так как это не тот, кому даются разрешения. Кому они даются и какие — будет в следующей таблице.

А пока мы просто перекинули всех пользователей и все объекты в две таблицы-адаптера, годные для Spring Security ACL — acl_sid и acl_object_identity. (Третья — acl_class — вспомогательная).

А вот теперь к главному — будем давать разрешения. Хранятся они в третьей таблице acl_entry.

Таблица ACL_ENTRY — разрешения (какие, кому, на что)

Вот сама таблица:

CREATE TABLE IF NOT EXISTS acl_entry (
                                         id bigint(20) NOT NULL AUTO_INCREMENT,
                                         acl_object_identity bigint(20) NOT NULL,
                                         ace_order int(11) NOT NULL,
                                         sid bigint(20) NOT NULL,
                                         mask int(11) NOT NULL,
                                         granting tinyint(1) NOT NULL,
                                         audit_success tinyint(1) NOT NULL,
                                         audit_failure tinyint(1) NOT NULL,
                                         PRIMARY KEY (id),
                                         UNIQUE KEY unique_uk_4 (acl_object_identity,ace_order)
);

ALTER TABLE acl_entry
ADD FOREIGN KEY (acl_object_identity) REFERENCES acl_object_identity(id);

ALTER TABLE acl_entry
ADD FOREIGN KEY (sid) REFERENCES acl_sid(id);

Выглядит страшно, но тут важны три поля:

  • acl_sid — кому дается разрешение;
  • acl_object_identity — на какой объект оно дается;
  • mask — какое именно разрешение.

Первые два — просто внешние ключи на предыдущие две таблицы.

mask — разрешение. В Spring Security есть ряд предустановленных разрешений. Считается, что 1 —это READ, 2 — WRITE, 4 — CREATE… (см. класс BasePermission). Но они ничего не значат, в том смысле, что действуют только в уме. А использовать их можно как угодно. Но мы воспользуемся по назначению.

Допустим, мы хотим дать на объект ‘Document 1′ пользователю user1 разрешение READ , а пользователю admin — разрешения READ и WRITE.

На каждое разрешение должна быть отдельная строка в таблице.

То есть мы добавляем три строки:

INSERT INTO acl_entry (id, acl_object_identity, ace_order, sid, mask, granting, audit_success, audit_failure) VALUES
(1, 1, 1, 1, 1, 1, 1, 1),
(2, 1, 2, 3, 1, 1, 1, 1),
(3, 1, 3, 3, 2, 1, 1, 1);

Аналогично: дадим на объект ‘Document 2′ пользователю user2 разрешение READ , а пользователю admin — разрешения READ и WRITE:

INSERT INTO acl_entry (id, acl_object_identity, ace_order, sid, mask, granting, audit_success, audit_failure) VALUES
(4, 2, 1, 2, 1, 1, 1, 1),
(5, 2, 2, 3, 1, 1, 1, 1),
(6, 2, 3, 3, 2, 1, 1, 1);

И дадим на ‘Document 3′ пользователю admin разрешения READ и WRITE:

INSERT INTO acl_entry (id, acl_object_identity, ace_order, sid, mask, granting, audit_success, audit_failure) VALUES
(7, 3, 1, 3, 1, 1, 1, 1),
(8, 3, 2, 3, 2, 1, 1, 1);

Теперь, когда разрешения установлены, можно их использовать.

Пример использования — SpEL-выражение hasPermission

@PostFilter и @PostAuthorize

У нас есть репозиторий для документов:

public interface DocumentRepository extends JpaRepository<Document, Integer> {

    @PostFilter("hasPermission(filterObject, 'READ')")
    List<Document> findAll();

    @PostAuthorize("hasPermission(returnObject, 'READ')")
    Document getById(Integer id);

    Document save(@Param("document") Document document);

}

Метод findAll() помечен аннотацией @PostFilter, внутри которой встроенное SpEL-выражение hasPermission — оно как раз и используется с ACL. Метод выдаст только те объекты, на которые у запрашивающего пользователя есть разрешение READ. То есть admin получит все три документа, user1 — ‘Document 1‘, а user2 — ‘Document 2‘.

Если бы вместо @PostFilter стояла аннотация @PostAuthorize, то вернулась бы ошибка 403. @PostFilter же молча фильтрует результат выборки.

Стоит отметить, что @PostFilter используется только с массивами и коллекциями.

getById() выдает документ по id. Метод помечен @PostAuthorize. Мы уже имели дело с аннотацией @PreAuthorize, тогда речь шла о защите методов и внутри было SpEL-выражение hasAuthority. Теперь внутри SpEL-выражение hasPermission — оно предназначено для работы с ACL.

@PreAuthorize("hasPermission(#document, 'WRITE')")

Метод save() репозитория не аннотирован, поскольку он используется в контроллере в разных методах PUT и POST с разными аннотациями. Вместо него аннотированы как раз те методы контроллера, из которых он вызывается:

@RestController
@AllArgsConstructor
public class DocumentController {

    private final DocumentRepository repository;
    private final PermissionService permissionService;

    @GetMapping("/document")
    public List<Document> getAll() {
        return repository.findAll();
    }

    @GetMapping("/document/{id}")
    public Document getById(@PathVariable("id") Integer id) {
        return repository.getById(id);
    }

    @PreAuthorize("hasPermission(#document, 'WRITE')")
    @PutMapping("/document/{id}")
    public Document edit(@PathVariable("id") Integer id, @RequestBody Document document) {
        document.setId(id);
        return repository.save(document);
    }

    @PreAuthorize("hasAuthority('ROLE_ADMIN')")
    @PostMapping("/document")
    public Document post(@RequestBody Document document, Authentication authentication) {
        permissionService.addPermissionForUser(document, BasePermission.WRITE, authentication.getName());
        permissionService.addPermissionForUser(document, BasePermission.READ, authentication.getName());
        return repository.save(document);
    }
}

В коде выше сказано, что редактировать документ может только пользователь с разрешением WRITE (то есть admin).

А в методе создания документа использованы не ACL-разрешения,  а просто общая защита метода, о которой говорилось в другой статье.

Настройка и зависимости

Вернемся к началу. Чтобы начать работать с Spring Security ACL, необходимо добавить в POM зависимости:

<dependency>
     <groupId>org.springframework.security</groupId>
     <artifactId>spring-security-acl</artifactId>
</dependency>

<dependency>
     <groupId>net.sf.ehcache</groupId>
     <artifactId>ehcache-core</artifactId>
     <version>2.6.11</version>
</dependency>

<dependency>
    <groupId>org.springframework</groupId>
    <artifactId>spring-context-support</artifactId>
</dependency>

Далее расширяем класс GlobalMethodSecurityConfiguration и включаем @EnableGlobalMethodSecurity. Внутри задаем бин JdbcMutableAclService, который повлечет другие бины:

@Configuration
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class AclMethodSecurityConfiguration extends GlobalMethodSecurityConfiguration {

    @Autowired
    private DataSource dataSource;

    @Bean
    public JdbcMutableAclService aclService() {
        return new JdbcMutableAclService(dataSource, lookupStrategy(), aclCache());
    }

    @Bean
    public LookupStrategy lookupStrategy() {
        return new BasicLookupStrategy(dataSource, aclCache(), aclAuthorizationStrategy(), new ConsoleAuditLogger());
    }


    @Override
    protected MethodSecurityExpressionHandler createExpressionHandler() {
        DefaultMethodSecurityExpressionHandler expressionHandler = new DefaultMethodSecurityExpressionHandler();
        AclPermissionEvaluator permissionEvaluator = new AclPermissionEvaluator(aclService());
        expressionHandler.setPermissionEvaluator(permissionEvaluator);
        expressionHandler.setPermissionCacheOptimizer(new AclPermissionCacheOptimizer(aclService()));
        return expressionHandler;
    }


    @Bean
    public EhCacheBasedAclCache aclCache() {
        return new EhCacheBasedAclCache(
                Objects.requireNonNull(aclEhCacheFactoryBean().getObject()),
                permissionGrantingStrategy(),
                aclAuthorizationStrategy()
        );
    }

    @Bean
    public EhCacheFactoryBean aclEhCacheFactoryBean() {
        EhCacheFactoryBean ehCacheFactoryBean = new EhCacheFactoryBean();
        ehCacheFactoryBean.setCacheManager(aclCacheManager().getObject());
        ehCacheFactoryBean.setCacheName("aclCache");
        return ehCacheFactoryBean;
    }

    @Bean
    public EhCacheManagerFactoryBean aclCacheManager() {
        return new EhCacheManagerFactoryBean();
    }

    @Bean
    public PermissionGrantingStrategy permissionGrantingStrategy() {
        return new DefaultPermissionGrantingStrategy(new ConsoleAuditLogger());
    }

    @Bean
    public AclAuthorizationStrategy aclAuthorizationStrategy() {
        return new AclAuthorizationStrategyImpl(new SimpleGrantedAuthority("ROLE_EDITOR"));
    }

}

 

Как добавлять Permission программно

Все разрешения в нашем примере добавлены с помощью SQL-скрипта. Рассмотрим, как их добавлять программно —  создать документ в коде и добавить на него права.

Создадим сервис PermissionService:

@Service
@Transactional
public class PermissionService {

    @Autowired
    private MutableAclService aclService;

    @Autowired
    private PlatformTransactionManager transactionManager;

    public void addPermissionForUser(IEntity targetObj, Permission permission, String username) {
        final Sid sid = new PrincipalSid(username);
        addPermissionForSid(targetObj, permission, sid);
    }

    public void addPermissionForAuthority(IEntity targetObj, Permission permission, String authority) {
        final Sid sid = new GrantedAuthoritySid(authority);
        addPermissionForSid(targetObj, permission, sid);
    }

    private void addPermissionForSid(IEntity targetObj, Permission permission, Sid sid) {
        final TransactionTemplate tt = new TransactionTemplate(transactionManager);

        tt.execute(new TransactionCallbackWithoutResult() {
            @Override
            protected void doInTransactionWithoutResult(TransactionStatus status) {
                final ObjectIdentity oi = new ObjectIdentityImpl(targetObj.getClass(), targetObj.getId());

                MutableAcl acl = null;
                try {
                    acl = (MutableAcl) aclService.readAclById(oi);
                } catch (final NotFoundException nfe) {
                    acl = aclService.createAcl(oi);
                }

                acl.insertAce(acl.getEntries()
                    .size(), permission, sid, true);
                aclService.updateAcl(acl);
            }
        });
    }
}

Метод addPermissionForUser() добавляет разрешение для пользователя (например, для admin), а addPermissionForAuthority() — для GrantedAuthority (например, для ROLE_ADMIN).

Мы будем использовать первый метод — добавлять разрешения для пользователя. В контроллере в методе создания нового документа сразу добавляем для него разрешения READ и WRITE и даем им тому, кто создает документ:

@RestController
@AllArgsConstructor
public class DocumentController {

    ...
    @PreAuthorize("hasAuthority('ROLE_ADMIN')")
    @PostMapping("/document")
    public Document post(@RequestBody Document document, Authentication authentication) {
        permissionService.addPermissionForUser(document, BasePermission.WRITE, authentication.getName());
        permissionService.addPermissionForUser(document, BasePermission.READ, authentication.getName());
        return repository.save(document);
    }
}

Обратите внимание, что на самом методе контроллера стоит аннотация с hasAuthority:

@PreAuthorize("hasAuthority('ROLE_ADMIN')")

и это не имеет отношение к ACL. Вот hasPermission было бы про ACL.

@PreAuthorize(«hasAuthority(‘ROLE_ADMIN’)») разрешает создавать документы только пользователям с ROLE_ADMIN (это admin в нашем примере). Тут нет уточнения, какие именно объекты разрешено создавать, а какие нет.

А вот в @PreAuthorize метода редактирования документа уже есть такое уточнение, используем  ACL:

@PreAuthorize("hasPermission(#document, 'WRITE')")
@PutMapping("/document/{id}")
public Document edit(@PathVariable("id") Integer id, @RequestBody Document document) {
    document.setId(id);
    return repository.save(document);
}

#document означает параметр метода, то есть документ, приходящий в теле запроса. И аннотация разрешает попасть в метод только тому, у кого есть разрешение WRITE на данный конкретный документ. У нас это опять же  admin.

Проверка

Тестировать доступ можно как из Postman, так и с помощью тестов. Ниже тесты:

@SpringBootTest
@AutoConfigureMockMvc
public class DocumentControllerTest {

    @Autowired
    private ObjectMapper objectMapper;

    @Autowired
    private MockMvc mockMvc;


    @Test
    @WithMockUser(username = "user1")
    public void user1ShouldGetDocument1() throws Exception {

        Document d1=new Document(1, "Document 1");
        mockMvc.perform(
                get("/document"))
                .andExpect(status().isOk())
                .andExpect(content().json(objectMapper.writeValueAsString(Arrays.asList(d1))));

    }

    @Test
    @WithMockUser(username = "user2")
    public void user2ShouldGetDocument2() throws Exception {

        Document d2=new Document(2, "Document 2");
        mockMvc.perform(
                get("/document"))
                .andExpect(status().isOk())
                .andExpect(content().json(objectMapper.writeValueAsString(Arrays.asList(d2))));
        ;
    }

    @Test
    @WithMockUser(username = "admin")
    public void adminShouldGetDocuments123() throws Exception {

        Document d1=new Document(1, "Document 1");
        Document d2=new Document(2, "Document 2");
        Document d3=new Document(3, "Document 3");

        mockMvc.perform(
                get("/document"))
                .andExpect(status().isOk())
                .andExpect(content().json(objectMapper.writeValueAsString(Arrays.asList(d1,d2,d3))));
        ;
    }

    @Test
    @WithMockUser(username = "user1")
    public void user1ShouldGetDocument1WithPathVariable() throws Exception {

        Document d1=new Document(1, "Document 1");
        mockMvc.perform(
                get("/document/1"))
                .andExpect(status().isOk())
                .andExpect(content().json(objectMapper.writeValueAsString(d1)));

    }

    @Test
    @WithMockUser(username = "user1")
    public void user1ShouldNotGetDocument2() throws Exception {

        mockMvc.perform(
                get("/document/2"))
                .andExpect(status().isForbidden());

    }

    @Test
    @WithMockUser(username = "admin")
    public void adminShouldEditDocument() throws Exception {

        Document d1=new Document( 1,"Document 1 Edited");
        mockMvc.perform(
                put("/document/1")
                        .content(objectMapper.writeValueAsString(d1))
                        .contentType(MediaType.APPLICATION_JSON))
                .andExpect(status().isOk())
                .andExpect(content().json(objectMapper.writeValueAsString(d1)));

    }

    @Test
    @WithMockUser(username = "user1")
    public void user1ShouldNotEditDocument() throws Exception {

        Document d1=new Document( 1,"Document 1 Edited");
        mockMvc.perform(
                put("/document/1")
                        .content(objectMapper.writeValueAsString(d1))
                        .contentType(MediaType.APPLICATION_JSON))
                .andExpect(status().isForbidden());

    }


    @Test
    @WithMockUser(authorities = { "ROLE_ADMIN" })
    public void adminShouldSetPermissionAndPostDocument() throws Exception {

        Document d4=new Document(4, "Document 4");
        mockMvc.perform(
                post("/document")
                .content(objectMapper.writeValueAsString(d4))
                .contentType(MediaType.APPLICATION_JSON))
                .andExpect(status().isOk())
                .andExpect(content().json(objectMapper.writeValueAsString(d4)));

    }
}

 

Итоги

Мы рассмотрели Spring Security ACL. Код примера доступен на GitHub.

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *