In previous posts, we discussed how to use Spring Security to authenticate user and authorizate user’s requests. But all of those only can manage the permissions on API level. In many scenarios, we also need make sure user only can access the data which they owned or permitted. In this post, I will show how to use Spring Security ACL to make it.

Story

Suppose we will provide a notes service on cloud to make users can access them at anywhere. And there are some basic requirements which we need implement.

  • All users can read, write, update and delete their owned notes
  • All users can create group(s) for family, friends and others
  • All users can share special note(s) which their owned to public, group(s) and speical user(s)
  • Users can manage the permissions of their shared note for each target. Such as, some targets only can read it, and some others can read and update it. And also, users can stop sharing.

Analysis

With above requirements, we know

  • All users should can request creating (POST), updating (PUT), getting (GET), deleting (DELETE) APIs. It means, users need access all REST APIs of note
  • Owner of notes has full permissions to manage them
  • Besides their owned, users can access the notes with special permission(s) which are shared to them by other users

Therefore, we need separate the authorization to 2 levels - API level and data level.

Implementation

Let’s start to make them to true.

The demo of this post will use JWT token between frontend and backend. API level is same as previous posts. Related things you can read Spring Security: Authentication and Authorization with JWT for separated backend. We only discuss how to control the permissions on data level.

Now, create project with dependences.

build.gradle

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
plugins {
id 'org.springframework.boot' version '2.2.7.RELEASE'
id 'io.spring.dependency-management' version '1.0.9.RELEASE'
id 'java'
}

group = 'com.simplejourney'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = '1.8'

repositories {
mavenCentral()
}

dependencies {
implementation 'org.postgresql:postgresql:42.2.12'
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-jdbc'
implementation 'org.springframework.security:spring-security-acl'
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springframework.boot:spring-boot-starter-web'

implementation 'io.jsonwebtoken:jjwt:0.2'
implementation 'com.google.code.gson:gson:2.8.6'
implementation 'net.sf.ehcache:ehcache-core:2.6.11'
implementation 'org.springframework:spring-context-support:5.2.6.RELEASE'

implementation 'io.springfox:springfox-swagger2:2.4.0'
implementation 'io.springfox:springfox-swagger-ui:2.4.0'

compileOnly 'org.projectlombok:lombok:1.18.12'
annotationProcessor 'org.projectlombok:lombok:1.18.12'

testImplementation('org.springframework.boot:spring-boot-starter-test') {
exclude group: 'org.junit.vintage', module: 'junit-vintage-engine'
}
testImplementation 'org.springframework.security:spring-security-test'
}

test {
useJUnitPlatform()
}

To add ACL support, following packages are needed

  • spring-security-acl:

    Spring ACL package

  • ehcache-core:

    Spring ACL requires a cache to store Object Identity and ACL entries.

  • DB support packages. The JPA and PostgreSQL are used in this demo

    • spring-boot-starter-data-jpa
    • spring-boot-starter-jdbc
    • postgresql

application.yml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
jwt:
secret: securityjwt

spring:
datasource:
url: jdbc:postgresql://127.0.0.1:5432/demo
username: demo
password: 123456
driverClassName: org.postgresql.Driver
jdbc-url: jdbc:postgresql://127.0.0.1:5432/demo
jpa:
hibernate:
naming:
implicit-strategy: org.hibernate.boot.model.naming.ImplicitNamingStrategyLegacyJpaImpl
physical-strategy: org.hibernate.boot.model.naming.PhysicalNamingStrategyStandardImpl
dialect: org.hibernate.dialect.PostgreSQL10Dialect

Why we defined db connection url 2 times?

There are 2 different models use different field name under ‘spring.datasource’.

  • spring.datasource.url : used by JPA
  • spring.datasource.jdbc-url : used by ACL DataSource

Database Definition

db.sql

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
/*
* ACL Tables
*/

-- ACL_SID table, allows us to universally identify any principle or authority in the system
CREATE TABLE acl_sid (
id BIGSERIAL NOT NULL PRIMARY KEY,
sid VARCHAR(100) NOT NULL,
principal BOOLEAN NOT NULL,
CONSTRAINT unique_uk_1 UNIQUE(sid, principal)
);
COMMENT ON COLUMN acl_sid.principal IS 'if true, sid is instance of PrincipalSid which indicates username; otherwise, it is instance of GrantedAuthoritySid which indicates the role';
COMMENT ON COLUMN acl_sid.sid IS 'PrincipalSid (user) or GrantedAuthoritySid (role)';


-- ACL_CLASS table, store class name of the domain object
CREATE TABLE acl_class (
id BIGSERIAL NOT NULL PRIMARY KEY,
class VARCHAR(100) NOT NULL,
CONSTRAINT unique_uk_2 UNIQUE(class)
);
COMMENT ON COLUMN acl_class.class IS 'the class name of secured domain objects. for example: com.simplejourney.securityacl.entities.User';


-- ACL_OBJECT_IDENTITY table, which stores information for each unique domain object
CREATE TABLE acl_object_identity (
id BIGSERIAL NOT NULL PRIMARY KEY,
object_id_class BIGINT NOT NULL,
object_id_identity BIGINT NOT NULL,
parent_object BIGINT,
owner_sid BIGINT NOT NULL,
entries_inheriting BOOLEAN NOT NULL,
CONSTRAINT unique_uk_3 UNIQUE(object_id_class, object_id_identity),
CONSTRAINT foreign_fk_1 FOREIGN KEY(parent_object) REFERENCES acl_object_identity(id),
CONSTRAINT foreign_fk_2 FOREIGN KEY(object_id_class) REFERENCES acl_class(id),
CONSTRAINT foreign_fk_3 FOREIGN KEY(owner_sid) REFERENCES acl_sid(id)
);
COMMENT ON COLUMN acl_object_identity.object_id_class IS 'define the domain object class, links to ACL_CLASS table';
COMMENT ON COLUMN acl_object_identity.object_id_identity IS 'domain objects can be stored in many tables depending on the class. Hence, this field store the target object primary key';
COMMENT ON COLUMN acl_object_identity.parent_object IS 'specify parent of this Object Identity within this table';
COMMENT ON COLUMN acl_object_identity.owner_sid IS 'ID of the object owner, links to ACL_SID table';
COMMENT ON COLUMN acl_object_identity.entries_inheriting IS 'whether ACL Entities of this object inherits from the parent object (ACL Entries are defined in ACL_ENTRY table';


-- the ACL_ENTRY store individual permission assigns to each SID on an Object Identity
CREATE TABLE acl_entry (
id BIGSERIAL NOT NULL PRIMARY KEY,
acl_object_identity BIGINT NOT NULL,
ace_order INTEGER NOT NULL,
sid BIGINT NOT NULL,
mask INTEGER NOT NULL,
granting BOOLEAN NOT NULL,
audit_success BOOLEAN NOT NULL,
audit_failure BOOLEAN NOT NULL,
CONSTRAINT unique_uk_4 UNIQUE(acl_object_identity, ace_order),
CONSTRAINT foreign_fk_4 FOREIGN KEY(acl_object_identity) REFERENCES acl_object_identity(id),
CONSTRAINT foreign_fk_5 FOREIGN KEY(sid) REFERENCES acl_sid(id)
);
COMMENT ON COLUMN acl_entry.acl_object_identity IS 'specify the object identity, links to ACL_OBJECT_IDENTITY table';
COMMENT ON COLUMN acl_entry.ace_order IS 'the order of current entry in the ACL entries list of corresponding Object Identity';
COMMENT ON COLUMN acl_entry.sid IS 'the target SID which the permission is granted to or denied from, links to ACL_SID table';
COMMENT ON COLUMN acl_entry.mask IS 'the integer bit mask that represents the actual permission being granted or denied';
COMMENT ON COLUMN acl_entry.audit_success IS 'for auditing purpose';
COMMENT ON COLUMN acl_entry.audit_failure IS 'for auditing purpose';

/*
* User, Group, Role, Permission
*/

CREATE TABLE users (
id BIGSERIAL NOT NULL PRIMARY KEY,
name VARCHAR(36) NOT NULL,
password VARCHAR(128) NOT NULL,
CONSTRAINT unique_uk_name UNIQUE(name)
);
INSERT INTO users (name, password) VALUES ('tom', '$2a$10$slYQmyNdGzTn7ZLBXBChFOCHQhUkTikWVg2V95lHK7HRj/LPjaZIa');
INSERT INTO users (name, password) VALUES ('jerry', '$2a$10$slYQmyNdGzTn7ZLBXBChFOCHQhUkTikWVg2V95lHK7HRj/LPjaZIa');
INSERT INTO users (name, password) VALUES ('nibbles', '$2a$10$slYQmyNdGzTn7ZLBXBChFOCHQhUkTikWVg2V95lHK7HRj/LPjaZIa');
INSERT INTO users (name, password) VALUES ('spike', '$2a$10$slYQmyNdGzTn7ZLBXBChFOCHQhUkTikWVg2V95lHK7HRj/LPjaZIa');

CREATE TABLE groups (
id BIGSERIAL NOT NULL PRIMARY KEY,
name VARCHAR(36) NOT NULL
);
INSERT INTO groups (name) VALUES ('family');

CREATE TABLE user_group (
id BIGSERIAL NOT NULL PRIMARY KEY,
user_id BIGINT NOT NULL,
group_id BIGINT NOT NULL,
CONSTRAINT foreign_fk_user_id FOREIGN KEY(user_id) REFERENCES users(id),
CONSTRAINT foreign_fk_group_id FOREIGN KEY(group_id) REFERENCES groups(id)
);
INSERT INTO user_group (user_id, group_id) VALUES (1, 1); -- add Tom to 'family' group
INSERT INTO user_group (user_id, group_id) VALUES (2, 1); -- add Jerry to 'family' group


/*
* Business Data
*/

CREATE TABLE note (
id BIGSERIAL PRIMARY KEY NOT NULL,
title VARCHAR(64) NOT NULL,
content TEXT NOT NULL,
create_date BIGINT NOT NULL,
author_id BIGINT NOT NULL,
CONSTRAINT foreign_fk_author_id FOREIGN KEY(author_id) REFERENCES users(id)
);

The details of ‘ACL Tables’ see Spring Security Reference: 22.3 ACL Schema.

There is a different point in ‘acl_entry’ table, the ‘acl_object_identity’ field is BIGINT here but it is ‘VARCHAR(36)’ in document. This change is used to solve the issue of JdbcMutableAclService and BasicLookupStrategy. We will discuss it when we talk about ‘AclConfiguration’ class at below.

In our story, users can share their owned notes to other user(s), group(s) and public. So, we need create tables to manage them.

The ‘users’ table only contains the username and password, and the ‘name’ column is unique since the UserDetailService use username to load details during authentication and authorization. In real world, you also can use email or phone number as query parameter for UserDetailService.

Then ‘groups’ table only need name in our demo. And associated with user by ‘user_group’ table.

There is not Role and API related tables, we don’t need them. In our story, all APIs are opened for all registered users.

ACL Support

AclConfiguration.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
package com.simplejourney.securityacl.config;

import net.sf.ehcache.CacheManager;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.jdbc.DataSourceBuilder;
import org.springframework.cache.ehcache.EhCacheFactoryBean;
import org.springframework.cache.ehcache.EhCacheManagerFactoryBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.access.expression.method.DefaultMethodSecurityExpressionHandler;
import org.springframework.security.access.expression.method.MethodSecurityExpressionHandler;
import org.springframework.security.acls.domain.*;
import org.springframework.security.acls.jdbc.BasicLookupStrategy;
import org.springframework.security.acls.jdbc.JdbcMutableAclService;
import org.springframework.security.acls.jdbc.LookupStrategy;
import org.springframework.security.acls.model.AclCache;
import org.springframework.security.acls.model.AclService;
import org.springframework.security.acls.model.PermissionGrantingStrategy;
import org.springframework.security.core.authority.SimpleGrantedAuthority;

import javax.sql.DataSource;

@Configuration
public class AclConfiguration {
@Autowired
private AclService aclService;

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

aclService.setClassIdentityQuery("select currval(pg_get_serial_sequence('acl_class', 'id'))");
aclService.setSidIdentityQuery("select currval(pg_get_serial_sequence('acl_sid', 'id'))");
aclService.setObjectIdentityPrimaryKeyQuery("select acl_object_identity.id from acl_object_identity, acl_class where acl_object_identity.object_id_class = acl_class.id and acl_class.class=? and acl_object_identity.object_id_identity = cast(? as bigint)");
aclService.setFindChildrenQuery("select obj.object_id_identity as obj_id, class.class as class from acl_object_identity obj, acl_object_identity parent, acl_class class where obj.parent_object = parent.id and obj.object_id_class = class.id and parent.object_id_identity = cast(? as bigint) and parent.object_id_class = (select id FROM acl_class where acl_class.class = ?)");
aclService.setInsertObjectIdentitySql("insert into acl_object_identity (object_id_class, object_id_identity, owner_sid, entries_inheriting) values (?, cast(? as bigint), ?, ?)");

return aclService;
}

@Bean
@ConfigurationProperties(prefix = "spring.datasource")
public DataSource dataSource() {
return DataSourceBuilder.create().build();
}

@Bean
public AclCache aclCache() {
EhCacheFactoryBean factoryBean = new EhCacheFactoryBean();
EhCacheManagerFactoryBean cacheManagerFactoryBean = new EhCacheManagerFactoryBean();

cacheManagerFactoryBean.setAcceptExisting(true);
cacheManagerFactoryBean.setCacheManagerName(CacheManager.getInstance().getName());
cacheManagerFactoryBean.afterPropertiesSet();

factoryBean.setName("aclCache");
factoryBean.setCacheManager(cacheManagerFactoryBean.getObject());
factoryBean.setMaxBytesLocalHeap("10M");
factoryBean.setMaxEntriesLocalHeap(0L);
factoryBean.afterPropertiesSet();

return new EhCacheBasedAclCache(factoryBean.getObject(), grantingStrategy(), aclAuthorizationStrategy());
}

@Bean
public AuditLogger auditLogger() {
return new ConsoleAuditLogger();
}

@Bean
public LookupStrategy lookupStrategy() {
BasicLookupStrategy lookupStrategy = new BasicLookupStrategy(
dataSource(),
aclCache(),
aclAuthorizationStrategy(),
grantingStrategy()
);

lookupStrategy.setLookupObjectIdentitiesWhereClause("(acl_object_identity.object_id_identity = cast(? as bigint) and acl_class.class = ?)");
return lookupStrategy;
}

@Bean
public PermissionGrantingStrategy grantingStrategy() {
/**
* Use customized PermissionGrantingStrategy
*/
return new DemoPermissionGrantingStrategy();
}

@Bean
public AclAuthorizationStrategy aclAuthorizationStrategy() {
return new AclAuthorizationStrategyImpl(
// authority which is used to change owner
new SimpleGrantedAuthority("ROLE_ADMIN"),
// authority which is used to change authorization
new SimpleGrantedAuthority("EDITOR"),
// authority which is used to chage other information.
// Changes ACL will use this.
new SimpleGrantedAuthority("EDITOR")
);
}

/**
* If we want to customize how to check security expression,
* we can implement our own MethodSecurityExpressionHandler,
* and create it here
*/

@Bean
public MethodSecurityExpressionHandler expressionHandler() {
DefaultMethodSecurityExpressionHandler expressionHandler = new DefaultMethodSecurityExpressionHandler();
expressionHandler.setPermissionEvaluator(aclPermissionEvaluator);
return expressionHandler;
}

@Autowired
private DemoAclPermissionEvaluator aclPermissionEvaluator;

@Bean
public DemoAclPermissionEvaluator aclPermissionEvaluator() {
/**
* Use customized AclPermissionEvaluator and build-in BasePermission
*/
DemoAclPermissionEvaluator aclPermissionEvaluator = new DemoAclPermissionEvaluator(aclService);
aclPermissionEvaluator.setPermissionFactory(new DefaultPermissionFactory(BasePermission.class));
return aclPermissionEvaluator;
}
}

To provide ACL support, we only need to provide an AclService instance to operate and manage the permissions of user data.

Creates an AclService object, it need 3 arguments.

  • DataSource: access the acl tables in database
  • AclCache: cache the Object Identify and Acl entities in memory
  • LookupStrategy: lookup acl recodes for authorizing and granting permission

There are some chance to customize the logic of ACL on expression and authorization to match our own requirements.

To customize the expression operation, we can implement our own ‘AclPermissionEvaluator’; and ‘AclAuthorizationStrategy’ and ‘PermissionGrantingStrategy’ for authorization policies.

And also, if you need your special permission settings, you can extends the ‘BasePermission’ class to make them.

There is something need to be noticed. You must already found there are some sqls are defined in ‘aclService’ method. And we also changed the data type of ‘acl_object_identity’ in ‘acl_entry’ table. Why we need do it?

JdbcMutableAclService and BasicLookupStrategy has issues when query contains the ‘acl_object_identity.object_id_identity’ in DB table. If we use ‘varchar’ as the type of this field, following exception will always be thrown. so, we MUST override it.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
org.postgresql.util.PSQLException: ERROR: operator does not exist: bigint = character varying
Hint: No operator matches the given name and argument types. You might need to add explicit type casts.
Position: 190
at org.postgresql.core.v3.QueryExecutorImpl.receiveErrorResponse(QueryExecutorImpl.java:2533) ~[postgresql-42.2.12.jar:42.2.12]
at org.postgresql.core.v3.QueryExecutorImpl.processResults(QueryExecutorImpl.java:2268) ~[postgresql-42.2.12.jar:42.2.12]
at org.postgresql.core.v3.QueryExecutorImpl.execute(QueryExecutorImpl.java:313) ~[postgresql-42.2.12.jar:42.2.12]
at org.postgresql.jdbc.PgStatement.executeInternal(PgStatement.java:448) ~[postgresql-42.2.12.jar:42.2.12]
at org.postgresql.jdbc.PgStatement.execute(PgStatement.java:369) ~[postgresql-42.2.12.jar:42.2.12]
at org.postgresql.jdbc.PgPreparedStatement.executeWithFlags(PgPreparedStatement.java:159) ~[postgresql-42.2.12.jar:42.2.12]
at org.postgresql.jdbc.PgPreparedStatement.executeQuery(PgPreparedStatement.java:109) ~[postgresql-42.2.12.jar:42.2.12]
at com.zaxxer.hikari.pool.ProxyPreparedStatement.executeQuery(ProxyPreparedStatement.java:52) ~[HikariCP-3.4.3.jar:na]
at com.zaxxer.hikari.pool.HikariProxyPreparedStatement.executeQuery(HikariProxyPreparedStatement.java) ~[HikariCP-3.4.3.jar:na]
at org.springframework.jdbc.core.JdbcTemplate$1.doInPreparedStatement(JdbcTemplate.java:678) ~[spring-jdbc-5.2.6.RELEASE.jar:5.2.6.RELEASE]
at org.springframework.jdbc.core.JdbcTemplate.execute(JdbcTemplate.java:617) ~[spring-jdbc-5.2.6.RELEASE.jar:5.2.6.RELEASE]
at org.springframework.jdbc.core.JdbcTemplate.query(JdbcTemplate.java:669) ~[spring-jdbc-5.2.6.RELEASE.jar:5.2.6.RELEASE]
at org.springframework.jdbc.core.JdbcTemplate.query(JdbcTemplate.java:700) ~[spring-jdbc-5.2.6.RELEASE.jar:5.2.6.RELEASE]
at org.springframework.jdbc.core.JdbcTemplate.query(JdbcTemplate.java:712) ~[spring-jdbc-5.2.6.RELEASE.jar:5.2.6.RELEASE]
at org.springframework.jdbc.core.JdbcTemplate.queryForObject(JdbcTemplate.java:783) ~[spring-jdbc-5.2.6.RELEASE.jar:5.2.6.RELEASE]
at org.springframework.jdbc.core.JdbcTemplate.queryForObject(JdbcTemplate.java:809) ~[spring-jdbc-5.2.6.RELEASE.jar:5.2.6.RELEASE]
at org.springframework.security.acls.jdbc.JdbcMutableAclService.retrieveObjectIdentityPrimaryKey(JdbcMutableAclService.java:341) ~[spring-security-acl-5.2.4.RELEASE.jar:5.2.4.RELEASE]
at org.springframework.security.acls.jdbc.JdbcMutableAclService.createAcl(JdbcMutableAclService.java:107) ~[spring-security-acl-5.2.4.RELEASE.jar:5.2.4.RELEASE]
at com.simplejourney.securityacl.controllers.NoteController.save(NoteController.java:66) ~[classes/:na]
at com.simplejourney.securityacl.controllers.NoteController$$FastClassBySpringCGLIB$$d4cbf884.invoke(<generated>) ~[classes/:na]
at org.springframework.cglib.proxy.MethodProxy.invoke(MethodProxy.java:218) ~[spring-core-5.2.6.RELEASE.jar:5.2.6.RELEASE]
...

About the details of issue, please refer to Spring Security ACL: No operator matches the given name and argument type #5508

DemoAclPermissionEvaluator.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
package com.simplejourney.securityacl.config;

import org.springframework.security.access.PermissionEvaluator;
import org.springframework.security.acls.domain.DefaultPermissionFactory;
import org.springframework.security.acls.domain.ObjectIdentityRetrievalStrategyImpl;
import org.springframework.security.acls.domain.PermissionFactory;
import org.springframework.security.acls.domain.SidRetrievalStrategyImpl;
import org.springframework.security.acls.model.*;
import org.springframework.security.core.Authentication;
import org.springframework.stereotype.Component;

import java.io.Serializable;
import java.util.Arrays;
import java.util.List;
import java.util.Locale;

/**
* If we want to make the data which has not ACL can be visited by any users,
* we need override this interface
* 'hasPermission' of PermissionEvaluator will be called before 'isGranted' of PermissionGrantingStrategy
*/

@Component
public class DemoAclPermissionEvaluator implements PermissionEvaluator {
private AclService aclService;
private ObjectIdentityRetrievalStrategy objectIdentityRetrievalStrategy = new ObjectIdentityRetrievalStrategyImpl();
private ObjectIdentityGenerator objectIdentityGenerator = new ObjectIdentityRetrievalStrategyImpl();
private SidRetrievalStrategy sidRetrievalStrategy = new SidRetrievalStrategyImpl();
private PermissionFactory permissionFactory = new DefaultPermissionFactory();

public DemoAclPermissionEvaluator(AclService aclService) {
this.aclService = aclService;
}

public void setPermissionFactory(PermissionFactory permissionFactory) {
this.permissionFactory = permissionFactory;
}

@Override
public boolean hasPermission(Authentication authentication, Object domainObject, Object permission) {
if (domainObject == null) {
return false;
} else {
ObjectIdentity objectIdentity = this.objectIdentityRetrievalStrategy.getObjectIdentity(domainObject);
return this.checkPermission(authentication, objectIdentity, permission);
}
}

@Override
public boolean hasPermission(Authentication authentication, Serializable targetId, String targetType, Object permission) {
ObjectIdentity objectIdentity = this.objectIdentityGenerator.createObjectIdentity(targetId, targetType);
return this.checkPermission(authentication, objectIdentity, permission);
}

private boolean checkPermission(Authentication authentication, ObjectIdentity oid, Object permission) {
List<Sid> sids = this.sidRetrievalStrategy.getSids(authentication);
List<Permission> requiredPermission = this.resolvePermission(permission);

try {
Acl acl = this.aclService.readAclById(oid, sids);
if (acl.isGranted(requiredPermission, sids, false)) {
return true;
}
} catch (NotFoundException var8) {
/**
* There is not acl data for object,
* Return true, if data should be public for all visitors;
* otherwise, false
*/
return true;
}

return false;
}

List<Permission> resolvePermission(Object permission) {
if (permission instanceof Integer) {
return Arrays.asList(this.permissionFactory.buildFromMask((Integer)permission));
} else if (permission instanceof Permission) {
return Arrays.asList((Permission)permission);
} else if (permission instanceof Permission[]) {
return Arrays.asList((Permission[])((Permission[])permission));
} else {
if (permission instanceof String) {
String permString = (String)permission;

Permission p;
try {
p = this.permissionFactory.buildFromName(permString);
} catch (IllegalArgumentException var5) {
p = this.permissionFactory.buildFromName(permString.toUpperCase(Locale.ENGLISH));
}

if (p != null) {
return Arrays.asList(p);
}
}

throw new IllegalArgumentException("Unsupported permission: " + permission);
}
}
}

The ‘hasPermission’ method will be called firstly when authorizing data access of user. You may found there only few changes with the code of ‘AclPermissionEvaluator’ since we hope the data which has not the ACL object can be accessed by all users. By default, if you use ‘AclPermissionEvaluator’, it will be refused.

DemoPermissionGrantingStrategy.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
package com.simplejourney.securityacl.config;

import com.simplejourney.securityacl.common.Constants;
import com.simplejourney.securityacl.entities.UserGroup;
import com.simplejourney.securityacl.repositories.UserGroupRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.acls.domain.GrantedAuthoritySid;
import org.springframework.security.acls.domain.PrincipalSid;
import org.springframework.security.acls.model.*;
import org.springframework.stereotype.Component;

import java.util.List;

@Component
public class DemoPermissionGrantingStrategy implements PermissionGrantingStrategy {
@Autowired
private UserGroupRepository userGroupRepository;

@Override
public boolean isGranted(Acl acl, List<Permission> permissions, List<Sid> sids, boolean b) {
String username = ((PrincipalSid)sids.get(0)).getPrincipal();

for (int index = 1; index < sids.size(); index++) {
if (GrantedAuthoritySid.class.isAssignableFrom(sids.get(index).getClass())) {
if ("ADMIN".equals(((GrantedAuthoritySid) sids.get(index)).getGrantedAuthority())) {
return true;
}
}
}

// check user is owner or not
String owner_name = ((PrincipalSid)acl.getOwner()).getPrincipal();
if (username.equals(owner_name)) {
return true;
}

// check aces
int authorizedPermission = 0;
List<AccessControlEntry> aces = acl.getEntries();
for (AccessControlEntry ace : aces) {
Sid sid = ace.getSid();

if (PrincipalSid.class.isAssignableFrom(sid.getClass())) {
if (((PrincipalSid)sid).getPrincipal().startsWith(Constants.USER_SID_PREFIX)) { // user
String authorized_user_name = ((PrincipalSid)sid).getPrincipal().substring(2);
if (authorized_user_name.equals(username)) {
authorizedPermission |= ace.getPermission().getMask();
}
} else if (((PrincipalSid)sid).getPrincipal().startsWith(Constants.GROUP_SID_PREFIX)) { // group
String authorized_group_name = ((PrincipalSid)sid).getPrincipal().substring(2);
List<UserGroup> userGroups = userGroupRepository.findUserGroupByUserIsAndGroupIs(username, authorized_group_name);
if (null != userGroups) {
for (UserGroup ugr : userGroups) {
if (ugr.getUser().getName().equals(username)) {
authorizedPermission |= ace.getPermission().getMask();
}
}
}
} else if (((PrincipalSid)sid).getPrincipal().equals(Constants.OTHERS_SID_NAME)) { // public
authorizedPermission |= ace.getPermission().getMask();
}
}
}

return checkPermission(permissions, authorizedPermission);
}

private boolean checkPermission(List<Permission> permissions, int mask) {
for (Permission permission : permissions) {
if (0 != (mask & permission.getMask())) {
return true;
}
}
return false;
}
}

When ‘PermissionEvaluator’ call ‘isGranted’ method of ACL, it will call here to complete the permission checking and granting. It is major position which implementing our customizing policies.

DemoBasePermission.java

1
2
3
4
5
6
7
8
9
package com.simplejourney.securityacl.config;

import org.springframework.security.acls.domain.BasePermission;

public class DemoBasePermission extends BasePermission {
public DemoBasePermission(int mask) {
super(mask);
}
}

Extends the ‘BasePermission’ here, we only want to provide ability to create combined permission. With default, we only can assign one of permissions to an ACE. But we want combined permission in it. Such as, if an user has READ and WRITE permission on a data, we should set ‘Permission.READ | Permission.WRITE’ in ACE.

Business Logic

Ok, the ACL has been provided, let’s start to implement our business.

First, we need provide some APIs to make user can access nodes. With our requirements, we should have

  • Creates note: POST requirement
  • Deletes note: DELETE requirement
  • Updates note: PUT requirement
  • Query notes list: GET requirement
  • Query details of note: GET requirement
  • Share note: POST requirement

NoteController.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
package com.simplejourney.securityacl.controllers;

import com.simplejourney.securityacl.common.Constants;
import com.simplejourney.securityacl.config.DemoBasePermission;
import com.simplejourney.securityacl.dto.NoteDTO;
import com.simplejourney.securityacl.dto.ShareSettingDTO;
import com.simplejourney.securityacl.entities.Group;
import com.simplejourney.securityacl.entities.ShareSetting;
import com.simplejourney.securityacl.entities.User;
import com.simplejourney.securityacl.repositories.GroupRepository;
import com.simplejourney.securityacl.repositories.UserRepositoy;
import com.simplejourney.securityacl.services.NoteService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.acls.domain.PrincipalSid;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.*;

import java.util.List;
import java.util.Optional;
import java.util.stream.Collectors;

@Controller
@RequestMapping(value = "/note")
public class NoteController {
@Autowired
private NoteService noteService;

@Autowired
private UserRepositoy userRepositoy;

@Autowired
private GroupRepository groupRepository;

@PostMapping
public ResponseEntity<NoteDTO> save(@RequestBody NoteDTO noteDTO) throws Exception {
return ResponseEntity.ok(noteService.save(noteDTO));
}

@PutMapping
public ResponseEntity<NoteDTO> edit(@RequestBody NoteDTO noteDTO) throws Exception {
return ResponseEntity.ok(noteService.edit(noteDTO));
}

@DeleteMapping(value = "/{id}")
public ResponseEntity remove(@PathVariable long id) throws Exception {
noteService.remove(id);
return ResponseEntity.ok().build();
}

@GetMapping
public ResponseEntity<List<NoteDTO>> list() throws Exception {
return ResponseEntity.ok(noteService.list());
}

@GetMapping(value = "/{id}")
public ResponseEntity<NoteDTO> get(@PathVariable long id) throws Exception {
return ResponseEntity.ok(noteService.getById(id));
}

@PostMapping(value = "/{id}/share")
public ResponseEntity share(@PathVariable long id, @RequestBody List<ShareSettingDTO> settings) throws Exception {
List<ShareSetting> shareSettings = settings.stream().map((ShareSettingDTO dto) -> {
ShareSetting ss = new ShareSetting();
ss.setPermissions(new DemoBasePermission(dto.getPermissions()));

switch (dto.getEntityType()) {
case 0: // user
Optional<User> user = userRepositoy.findById(dto.getEntityId());
if (user.isPresent()) {
ss.setSid(new PrincipalSid(Constants.USER_SID_PREFIX + user.get().getName()));
}
break;
case 1: // group
Optional<Group> group = groupRepository.findById(dto.getEntityId());
if (group.isPresent()) {
ss.setSid(new PrincipalSid(Constants.GROUP_SID_PREFIX + group.get().getName()));
}
break;
case 2:
ss.setSid(new PrincipalSid(Constants.OTHERS_SID_NAME));
break;
}
return ss;
}).collect(Collectors.toList());

if (noteService.share(id, shareSettings)) {
return ResponseEntity.ok().build();
}
return new ResponseEntity(HttpStatus.BAD_REQUEST);
}
}

When a note is created, we should add an ACL for it. it will mark the owner of the note. Otherwise, the note will be public for all users. And also, you can remove ‘DemoAclPermissionEvaluator’ and use built-in ‘AclPermissionEvaluator’ to make note private by default.

When a note is deleted, we also need remove related ACL at same time.

The ‘share’ api will apply a list of sharing settings, and an ACE will be created for each of them and add to ACL object. If we don’t want continue to share a note, we just need remove the ACE(s).

NoteService.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
package com.simplejourney.securityacl.services;

import com.simplejourney.securityacl.dto.NoteDTO;
import com.simplejourney.securityacl.entities.ShareSetting;

import java.util.List;

public interface NoteService {
NoteDTO save(NoteDTO noteDTO) throws Exception;
NoteDTO edit(NoteDTO noteDTO) throws Exception;
void remove(long id) throws Exception;
List<NoteDTO> list() throws Exception;
NoteDTO getById(long id) throws Exception;
boolean share(Long noteId, List<ShareSetting> settings) throws Exception;
}

NoteServiceImpl.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
package com.simplejourney.securityacl.services.impl;

import com.simplejourney.securityacl.utils.AuthUtil;
import com.simplejourney.securityacl.dto.NoteDTO;
import com.simplejourney.securityacl.entities.ShareSetting;
import com.simplejourney.securityacl.entities.Note;
import com.simplejourney.securityacl.entities.User;
import com.simplejourney.securityacl.repositories.NoteRepository;
import com.simplejourney.securityacl.services.NoteService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.security.access.prepost.PostFilter;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.acls.domain.ObjectIdentityImpl;
import org.springframework.security.acls.model.*;
import org.springframework.stereotype.Service;
import org.springframework.web.client.HttpServerErrorException;

import javax.transaction.Transactional;
import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;
import java.util.Optional;

@Service
public class NoteServiceImpl implements NoteService {
@Autowired
private AclService aclService;

@Autowired
private NoteRepository noteRepository;

@Autowired
private AuthUtil authUtil;

@Transactional
public NoteDTO save(NoteDTO noteDTO) {
User userEntity = authUtil.getUserEntity();

Note note = noteDTO.toNote();
note.setAuthorId(userEntity.getId());
note.setCreateDate(System.currentTimeMillis());

Note saved = noteRepository.save(note);

ObjectIdentity oi = new ObjectIdentityImpl(Note.class, saved.getId());
MutableAcl acl = ((MutableAclService) aclService).createAcl(oi);

return NoteDTO.from(saved);
}

@PreAuthorize("hasPermission(#noteDTO.id, 'com.simplejourney.securityacl.entities.Note', 1) and hasPermission(#noteDTO.id, 'com.simplejourney.securityacl.entities.Note', 2)")
public NoteDTO edit(NoteDTO noteDTO) {
Optional<Note> exists = noteRepository.findById(noteDTO.getId());
if (!exists.isPresent()) {
throw new HttpServerErrorException(HttpStatus.NOT_FOUND);
}

Note saved = noteRepository.save(noteDTO.toNote());
return NoteDTO.from(saved);
}

@Transactional
@PreAuthorize("hasPermission(#id, 'com.simplejourney.securityacl.entities.Note', 8)")
public void remove(long id) {
noteRepository.deleteById(id);
this.removeACL(id);
}

@PostFilter("hasPermission(filterObject.id, 'com.simplejourney.securityacl.entities.Note', 1)")
public List<NoteDTO> list() {
Iterable<Note> noteIterable = noteRepository.findAll();
if (null == noteIterable) {
throw new HttpServerErrorException(HttpStatus.NOT_FOUND);
}

Iterator<Note> iterator = noteIterable.iterator();
if (!iterator.hasNext()) {
throw new HttpServerErrorException(HttpStatus.NOT_FOUND);
}

List<NoteDTO> noteDTOS = new ArrayList<>();
while (iterator.hasNext()) {
noteDTOS.add(NoteDTO.from(iterator.next()));
}

return noteDTOS;
}

@PreAuthorize("hasPermission(#id, 'com.simplejourney.securityacl.entities.Note', 1)")
public NoteDTO getById(long id) {
Optional<Note> note = noteRepository.findById(id);
if (!note.isPresent()) {
throw new HttpServerErrorException(HttpStatus.NOT_FOUND);
}
return NoteDTO.from(note.get());
}

@Transactional
@PreAuthorize("hasPermission(#noteId, 'com.simplejourney.securityacl.entities.Note', 4)")
public boolean share(Long noteId, List<ShareSetting> settings) {
if (null == settings || settings.isEmpty()) {
return false;
}

ObjectIdentity oi = new ObjectIdentityImpl(Note.class, noteId);
MutableAcl acl = null;

try {
acl = (MutableAcl) aclService.readAclById(oi);
if (acl != null) {
clearACEs(acl);
}
} catch (NotFoundException ex){
acl = ((MutableAclService) aclService).createAcl(oi);
}

for (ShareSetting setting : settings) {
acl.insertAce(acl.getEntries().size(), setting.getPermissions(), setting.getSid(), true);
}

((MutableAclService) aclService).updateAcl(acl);
return true;
}

private void removeACL(long noteId) {
ObjectIdentity oi = new ObjectIdentityImpl(Note.class, noteId);
((MutableAclService) aclService).deleteAcl(oi, true);
}

private void clearACEs(MutableAcl acl) {
try {
int count = acl.getEntries().size();
for (int index = count - 1; index >= 0; index++) {
acl.deleteAce(index);
}
} catch (NotFoundException ex) {
return;
}
}
}

The real jobs of data accessing are done here. To use ACL mechanism, we need add annotation and expression for each method to indicate what permission are needed on these data.

For all annotations and expressions, please refer to Spring Security Reference: 11.3. Expression-Based Access Control.

Notice, when we operate the ACL data, transaction is needed.

Test

OK, all things done. We can test our application now.

Add SwaggerUI Support

Add following code in ‘SwaggerUI.java’ in same package as ‘DemoApplication.java’, we can easy to use swagger ui to test all APIs in a web page.

SwaggerUI.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
package com.simplejourney.securityacl;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import springfox.documentation.builders.ApiInfoBuilder;
import springfox.documentation.builders.ParameterBuilder;
import springfox.documentation.builders.PathSelectors;
import springfox.documentation.builders.RequestHandlerSelectors;
import springfox.documentation.schema.ModelRef;
import springfox.documentation.service.ApiInfo;
import springfox.documentation.service.Parameter;
import springfox.documentation.spi.DocumentationType;
import springfox.documentation.spring.web.plugins.Docket;
import springfox.documentation.swagger2.annotations.EnableSwagger2;

import java.util.ArrayList;
import java.util.List;

@Configuration
@EnableSwagger2
public class SwaggerUI {
@Bean
public Docket createRestApi() {
ParameterBuilder tokenPar = new ParameterBuilder();
List<Parameter> pars = new ArrayList<>();

tokenPar.name("Authorization").description("Token").modelRef(new ModelRef("string")).parameterType("header").required(false).build();
pars.add(tokenPar.build());

return new Docket(DocumentationType.SWAGGER_2)
.select()
.apis(RequestHandlerSelectors.basePackage("com.simplejourney.securityacl.controllers"))
.paths(PathSelectors.any())
.build()
.globalOperationParameters(pars)
.apiInfo(apiInfo());
}

private ApiInfo apiInfo() {
return new ApiInfoBuilder().title("Simple APIs")
.description("simple apis")
.version("1.0")
.build();
}
}

About how to use Swagger UI, you can refer to previous posts, or access Offical Site of it to find more.

Test Cases

Case 1:

Tom create 1 note should be successful.

Tom update the content of it should be successful.

Tom can get note list which contains this note.

Tom can get details of this note.

Tom can delete this note.

Jerry, Nibbles and Spike cannot access this note.

Case 2:

Tom create 1 note should be successful.

Tom share this note with READ permission to family should be successful.

Jerry can get note list which contains this note.

Jerry can get details of this note.

Jerry cannot update and delete this note.

Nibbles and Spike cannot access this note.

Case 3:

Tom create 1 note should be successful.

Tom share this note with READ permission to public should be successful.

Jerry, Nibbles and Spike can get note list which contains this note.

Jerry, Nibbles and Spike can get details of this note.

Jerry, Nibbles and Spike cannot update and delete this note.

Others

There are more test cases to test our application, I will not show all of them here. If you are interested in this demo, you can think and try them.

Conclusion

OK, the service work well now.

If you have any questions or suggestions, please feel free to submit your comments to issue.

The demo projects you can download from Github with following links

References

Spring Security Reference: 11.3. Expression-Based Access Control
Spring Security Reference: 22.3 ACL Schema
Spring Security ACL: No operator matches the given name and argument type #5508