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.
-- ACL_SID table, allows us to universally identify any principle or authority in the system CREATETABLE acl_sid ( id BIGSERIAL NOTNULLPRIMARYKEY, sid VARCHAR(100) NOTNULL, principal BOOLEANNOTNULL, CONSTRAINT unique_uk_1 UNIQUE(sid, principal) ); COMMENT ONCOLUMN 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 ONCOLUMN acl_sid.sid IS'PrincipalSid (user) or GrantedAuthoritySid (role)';
-- ACL_CLASS table, store class name of the domain object CREATETABLE acl_class ( id BIGSERIAL NOTNULLPRIMARYKEY, class VARCHAR(100) NOTNULL, CONSTRAINT unique_uk_2 UNIQUE(class) ); COMMENT ONCOLUMN 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 CREATETABLE acl_object_identity ( id BIGSERIAL NOTNULLPRIMARYKEY, object_id_class BIGINTNOTNULL, object_id_identity BIGINTNOTNULL, parent_object BIGINT, owner_sid BIGINTNOTNULL, entries_inheriting BOOLEANNOTNULL, CONSTRAINT unique_uk_3 UNIQUE(object_id_class, object_id_identity), CONSTRAINT foreign_fk_1 FOREIGNKEY(parent_object) REFERENCES acl_object_identity(id), CONSTRAINT foreign_fk_2 FOREIGNKEY(object_id_class) REFERENCES acl_class(id), CONSTRAINT foreign_fk_3 FOREIGNKEY(owner_sid) REFERENCES acl_sid(id) ); COMMENT ONCOLUMN acl_object_identity.object_id_class IS'define the domain object class, links to ACL_CLASS table'; COMMENT ONCOLUMN 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 ONCOLUMN acl_object_identity.parent_object IS'specify parent of this Object Identity within this table'; COMMENT ONCOLUMN acl_object_identity.owner_sid IS'ID of the object owner, links to ACL_SID table'; COMMENT ONCOLUMN 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 CREATETABLE acl_entry ( id BIGSERIAL NOTNULLPRIMARYKEY, acl_object_identity BIGINTNOTNULL, ace_order INTEGERNOTNULL, sid BIGINTNOTNULL, mask INTEGERNOTNULL, granting BOOLEANNOTNULL, audit_success BOOLEANNOTNULL, audit_failure BOOLEANNOTNULL, CONSTRAINT unique_uk_4 UNIQUE(acl_object_identity, ace_order), CONSTRAINT foreign_fk_4 FOREIGNKEY(acl_object_identity) REFERENCES acl_object_identity(id), CONSTRAINT foreign_fk_5 FOREIGNKEY(sid) REFERENCES acl_sid(id) ); COMMENT ONCOLUMN acl_entry.acl_object_identity IS'specify the object identity, links to ACL_OBJECT_IDENTITY table'; COMMENT ONCOLUMN acl_entry.ace_order IS'the order of current entry in the ACL entries list of corresponding Object Identity'; COMMENT ONCOLUMN acl_entry.sid IS'the target SID which the permission is granted to or denied from, links to ACL_SID table'; COMMENT ONCOLUMN acl_entry.mask IS'the integer bit mask that represents the actual permission being granted or denied'; COMMENT ONCOLUMN acl_entry.audit_success IS'for auditing purpose'; COMMENT ONCOLUMN acl_entry.audit_failure IS'for auditing purpose';
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.
@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();
@Bean public AuditLogger auditLogger(){ returnnew 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 */ returnnew DemoPermissionGrantingStrategy(); }
@Bean public AclAuthorizationStrategy aclAuthorizationStrategy(){ returnnew 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; }
@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.
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] ...
/** * 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 publicclassDemoAclPermissionEvaluatorimplementsPermissionEvaluator{ private AclService aclService; private ObjectIdentityRetrievalStrategy objectIdentityRetrievalStrategy = new ObjectIdentityRetrievalStrategyImpl(); private ObjectIdentityGenerator objectIdentityGenerator = new ObjectIdentityRetrievalStrategyImpl(); private SidRetrievalStrategy sidRetrievalStrategy = new SidRetrievalStrategyImpl(); private PermissionFactory permissionFactory = new DefaultPermissionFactory();
try { Acl acl = this.aclService.readAclById(oid, sids); if (acl.isGranted(requiredPermission, sids, false)) { returntrue; } } catch (NotFoundException var8) { /** * There is not acl data for object, * Return true, if data should be public for all visitors; * otherwise, false */ returntrue; }
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.
for (int index = 1; index < sids.size(); index++) { if (GrantedAuthoritySid.class.isAssignableFrom(sids.get(index).getClass())) { if ("ADMIN".equals(((GrantedAuthoritySid) sids.get(index)).getGrantedAuthority())) { returntrue; } } }
// check user is owner or not String owner_name = ((PrincipalSid)acl.getOwner()).getPrincipal(); if (username.equals(owner_name)) { returntrue; }
privatebooleancheckPermission(List<Permission> permissions, int mask){ for (Permission permission : permissions) { if (0 != (mask & permission.getMask())) { returntrue; } } returnfalse; } }
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.
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
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).
privatevoidremoveACL(long noteId){ ObjectIdentity oi = new ObjectIdentityImpl(Note.class, noteId); ((MutableAclService) aclService).deleteAcl(oi, true); }
privatevoidclearACEs(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.
@Configuration @EnableSwagger2 publicclassSwaggerUI{ @Bean public Docket createRestApi(){ ParameterBuilder tokenPar = new ParameterBuilder(); List<Parameter> pars = new ArrayList<>();