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 же молча фильтрует результат выборки.
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.