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.