ACLs in the Grails Spring Security Plugin
Update: If you’re interested in using ACLs with Spring Security and Grails, you can use the Spring Security ACL plugin
It has taken way too long, but the Grails Spring Security plugin
finally has ACL support. It’s not officially available yet, but people have offered to beta test an early version of the plugin with ACLs, so you can download that here and report any issues back. Once it’s stable I’ll do an official release.
History
Stephan February did the first work adding ACL support to the plugin. Unfortunately at the time the plugin was based on Acegi 1.0.x and I had just converted it to use Spring Security 2.0. No one did the work to convert the ACL support to the new package layout and approach, so this wasn’t used.
This is a frequently requested feature, so I created a feature request as a TODO item for myself. I found some time to work on this over the summer and created an initial GORM-based implementation (the standard Spring Security implementation uses JDBC). I was fortunate to be able to use this at a client project at InnoCentive
which helped to flesh out the ideas and identify a few issues.
Around the same time, Phillip Merensky mentioned on the mailing list that he was working on an implementation. He wrote about his approach here
and attached his version of the plugin to the JIRA issue. Phillip’s work was very helpful; I’ve merged his version with mine for the current implementation.
Working with ACLs in Spring Security is complex but it will be easier to understand with a sample application.
Test Application
Create a test application
cd acltest
Download the plugin with ACL support here and install it:
As with any application using the plugin, you need to run the create-auth-domains
script, plus generate-manager
if you want the generated GSPs and generate-registration
if you want basic registration support:
grails generate-manager
grails generate-registration
The ACL support uses domain classes but to allow customizing the domain classes (e.g. to enable Hibernate 2nd-level caching) there’s a script that copies the domain classes into your application:
The script takes no parameters since the package and names aren’t configurable – the plugin code imports the domain classes.
Next, switch from using Requestmap
entries in the database to using annotated controllers:
- delete
grails-app/domain/acltest/Requestmap.groovy
- delete
grails-app/controllers/RequestmapController.groovy
- delete the
grails-app/views/requestmap
directory and its GSPs - delete
Requestmap
import fromgrails-app/controllers/RoleController.groovy
- in
grails-app/conf/SecurityConfig.groovy
, disable requestmaps (useRequestMapDomainClass = false
) and enable annotations (useControllerAnnotations = true
), and remove therequestMapClass
property:security { active = true loginUserDomainClass = 'acltest.User' authorityDomainClass = 'acltest.Role' useRequestMapDomainClass = false useControllerAnnotations = true }
To enable ACL processing, set the useAcl
attribute to true
:
security { active = true loginUserDomainClass = 'acltest.User' authorityDomainClass = 'acltest.Role' useRequestMapDomainClass = false useControllerAnnotations = true useAcl = true }
We’ll need a domain class to test with, so create a Report
domain class:
and add a name
property for testing:
package acltest class Report { String name }
Working with ACLs
Probably the most important interface for ACLs is Permission. You can implement the interface yourself, but BasePermission
has
READ
, WRITE
, CREATE
, DELETE
, and ADMINISTRATION
instances that should be sufficient for your needs.
The plugin provides a new service, AclUtilService
, to grant and revoke permissions, and to check if permissions are granted. The service methods are:
-
void addPermission(object, recipient, Permission permission)
grants the specified permission to the recipient (either the login name or anAuthentication
) for the specified instance -
void addPermission(Class< ?> domainClass, long id, recipient, Permission permission)
grants the specified permission to the recipient (either the login name or anAuthentication
) for the specified instance; use this overload to avoid loading the instance -
void deletePermission(object, recipient, Permission permission)
removes the grant of the specified permission from the recipient (either the login name or anAuthentication
) for the specified instance -
void deletePermission(Class< ?> domainClass, long id, recipient, Permission permission)
removes the grant of the specified permission from the recipient (either the login name or anAuthentication
) for the specified instance; use this overload to avoid loading the instance -
boolean hasPermission(Authentication authentication, domainObject, Permission permission)
checks if the authentication has a grant of the specified permission for the specified instance -
boolean hasPermission(Authentication authentication, domainObject, Permission[] permissions)
checks if the authentication has a grant of any of the specified permissions for the specified instance; the first one that is found is used, so the order of the array matters
Creating, editing, or deleting permissions requires an authenticated user. The default required role is ROLE_ADMIN
for all actions, but this can be configured in SecurityConfig.groovy
. Change the acl.authority.changeOwnership
property to change who can call OwnershipAcl.setOwner()
. Change the acl.authority.modifyAuditingDetails
property to change who can call AuditableAcl.updateAuditing()
. And change acl.authority.changeAclDetails
to change who can call MutableAcl.deleteAce()
, MutableAcl.insertAce()
, MutableAcl.setEntriesInheriting()
, MutableAcl.setParent()
, or MutableAcl.updateAce()
.
You’ll probably want to create an admin UI that uses AclUtilService
and is aware of your secured domain classes and business rules.
Configuration
Configuring ACL support happens in two places; you configure Voters that have one or more associated permissions and a domain class (which can be an abstract base class), and you configure which service methods use which voters. Often there will be a 1-1 relationship between these but since they’re separate, you can re-use the voters for multiple service methods. And you may not even need custom voters; if you only want to secure methods with roles, or if you only need return value checking, then you wouldn’t configure any voters, but you’d still configure method restrictions.
There are two types of ACL checks; method return value and method parameter. The plugin creates two voters for return value checks, one for single values (AFTER_ACL_READ
) and one for collections (AFTER_ACL_COLLECTION_READ
). Each requires that the authenticated user have BasePermission.READ
. An optimization would be to allow access to admins (who have been granted BasePermission.ADMINISTRATION); to configure this, redefine the beans in grails-app/conf/spring/resources.groovy
:
beans = { afterAclCollectionRead(AclEntryAfterInvocationCollectionFilteringProvider, ref('aclService'), [BasePermission.READ, BasePermission.ADMINISTRATION]) afterAclRead(AclEntryAfterInvocationProvider, ref('aclService'), [BasePermission.READ, BasePermission.ADMINISTRATION]) }
Voters for method parameter checks (the first parameter of the specified type or a subclass is checked) can be configured either in SecurityConfig.groovy
or in domain class annotations. Putting the configuration in SecurityConfig.groovy
keeps everything in one place, whereas the annotations let you put the declarations where they apply, so they’re self-documenting. Use whichever approach you prefer.
To configure them in SecurityConfig.groovy
, use the acl.voters
property, e.g.
import org.springframework.security.acls.domain.BasePermission import acltest.Report security { ... useAcl = true acl.voters = [ aclReportWriteVoter: [ configAttribute: 'ACL_REPORT_WRITE', permissions: [BasePermission.ADMINISTRATION, BasePermission.WRITE], domainObjectClass: Report], aclReportDeleteVoter: [ configAttribute: 'ACL_REPORT_DELETE', permissions: [BasePermission.ADMINISTRATION, BasePermission.DELETE], domainObjectClass: Report] ] }
which creates a ‘write’ voter and a ‘delete’ voter. The equivalent annotations would be:
package acltest import org.codehaus.groovy.grails.plugins.springsecurity.acl.AclVoter import org.codehaus.groovy.grails.plugins.springsecurity.acl.AclVoters @AclVoters([ @AclVoter(name='aclReportWriteVoter', configAttribute='ACL_REPORT_WRITE', permissions=['ADMINISTRATION', 'WRITE']), @AclVoter(name='aclReportDeleteVoter', configAttribute='ACL_REPORT_DELETE', permissions=['ADMINISTRATION', 'DELETE']) ]) class Report { String name }
Note that since you cannot use an annotation more than once, in a case like this where there can be multiple voter annotations for a domain class they need to be defined as attributes of a containing annotation (AclVoters
). If you only have a single voter then you can annotate the class with that and omit the containing annotation.
The voter configuration should be fairly clear; there’s a name
parameter that’s used as the Spring bean name (so it must be unique), a configAttribute
parameter that’s arbitrary but typically uses a naming convention where it starts with ‘ACL_’, and one or more permissions. The one limitation of annotations over the static configuration is that annotations cannot have Permission
s as parameters, so String
s are used instead. This limits you to naming fields of the BasePermission class. If you have custom permission classes you’ll need to use the static configuration.
Securing Service Methods
As with voters, there are two ways to define the access rules for service methods. You can define a static springSecurityACL
property with configuration options, or annotate the class and/or individual methods.
Let’s create a service to test ACLs:
and add some methods that work with Report
s:
package acltest class ReportService { boolean transactional = true Report getReport(long id) { Report.get(id) } Report createReport(params) { Report report = new Report(params) report.save() report } List getAllReports(params = [:]) { Report.list(params) } String getReportName(long id) { Report.get(id).name } Report updateReport(Report report, params) { report.properties = params if (!report.hasErrors()) { report.save() } report } void deleteReport(Report report) { report.delete() } }
To configure the rules in one place, add a springSecurityACL
property:
static springSecurityACL = [ getReportName: ['ROLE_USER', 'ROLE_ADMIN'], getAllReports: ['ROLE_USER', 'AFTER_ACL_COLLECTION_READ'], getReport: ['ROLE_USER', 'AFTER_ACL_READ'], updateReport: ['ACL_REPORT_WRITE'], deleteReport: ['ACL_REPORT_DELETE'] ]
and the equivalent annotated version would be:
package acltest import org.codehaus.groovy.grails.plugins.springsecurity.Secured class ReportService { boolean transactional = true @Secured(['ROLE_USER', 'AFTER_ACL_READ']) Report getReport(long id) { Report.get(id) } Report createReport(params) { Report report = new Report(params) report.save() report } @Secured(['ROLE_USER', 'AFTER_ACL_COLLECTION_READ']) List getAllReports(params = [:]) { Report.list(params) } @Secured(['ROLE_USER', 'ROLE_ADMIN']) String getReportName(long id) { Report.get(id).name } @Secured(['ACL_REPORT_WRITE']) Report updateReport(Report report, params) { report.properties = params if (!report.hasErrors()) { report.save() } report } @Secured(['ACL_REPORT_DELETE']) void deleteReport(Report report) { report.delete() } }
The configuration specifies these rules:
-
getReportName
requires that the authenticated user have eitherROLE_USER
orROLE_ADMIN
(but no ACL rules) -
getAllReports
requiresROLE_USER
and will have elements removed from the returnedList
that the user doesn’t have an ACL grant for (thanks toAFTER_ACL_COLLECTION_READ
); the user must have one of the permissions defined in theafterAclCollectionRead
bean (by defaultBasePermission.READ
) for each element in the list; elements that don’t have access granted will be removed -
getReport
requiresROLE_USER
and will be denied (thanks toAFTER_ACL_READ
) unless the user has one of the permissions defined in theafterAclRead
bean (by defaultBasePermission.READ
). -
updateReport
has no role restrictions but must satisfy the requirements of theaclReportWriteVoter
voter (which has theACL_REPORT_WRITE
config attribute), i.e.BasePermission.ADMINISTRATION
orBasePermission.WRITE
-
deleteReport
has no role restrictions but must satisfy the requirements of theaclReportDeleteVoter
voter (which has theACL_REPORT_DELETE
config attribute), i.e.BasePermission.ADMINISTRATION
orBasePermission.DELETE
-
createReport
has no restrictions
To test this out we’ll need some users; create those and their grants in BootStrap.groovy:
import org.springframework.security.GrantedAuthority import org.springframework.security.GrantedAuthorityImpl import org.springframework.security.acls.domain.BasePermission import org.springframework.security.context.SecurityContextHolder as SCH import org.springframework.security.providers.UsernamePasswordAuthenticationToken import acltest.Report import acltest.Role import acltest.User class BootStrap { def aclUtilService def passwordEncoder def sessionFactory def init = { servletContext -> createUsers() createReports() createGrants() sessionFactory.currentSession.flush() } private void createUsers() { def adminRole = new Role(description: 'Admin', authority: 'ROLE_ADMIN').save() def admin = new User(username: 'admin', userRealName: 'admin', passwd: passwordEncoder.encodePassword('admin', null), enabled: true, email: 'admin@admin.com').save() adminRole.addToPeople admin def userRole = new Role(description: 'User', authority: 'ROLE_USER').save() def user1 = new User(username: 'user1', userRealName: 'user1', passwd: passwordEncoder.encodePassword('user1', null), enabled: true, email: 'user1@user.com').save() userRole.addToPeople user1 def user2 = new User(username: 'user2', userRealName: 'user2', passwd: passwordEncoder.encodePassword('user2', null), enabled: true, email: 'user2@user.com').save() userRole.addToPeople user2 } private void createReports() { (1..10).each { new Report(name: "report $it").save() } } private void createGrants() { loginAsAdmin() try { // user1 can see reports 1-4 def user = User.findByUsername('user1') (1..4).each { def report = Report.findByName("report $it") aclUtilService.addPermission(report, user.username, BasePermission.READ) } // and can edit #3 aclUtilService.addPermission(Report.findByName('report 3'), user.username, BasePermission.WRITE) // and edit and delete #4 aclUtilService.addPermission(Report.findByName('report 4'), user.username, BasePermission.WRITE) aclUtilService.addPermission(Report.findByName('report 4'), user.username, BasePermission.DELETE) // user2 can see reports 5, 10 user = User.findByUsername('user2') [5, 10].each { def report = Report.findByName("report $it") aclUtilService.addPermission(report, user.username, BasePermission.READ) } } finally { SCH.clearContext() } } // have to be authenticated as an admin to create ACLs private void loginAsAdmin() { SCH.context.authentication = new UsernamePasswordAuthenticationToken( 'admin', 'password', [new GrantedAuthorityImpl('ROLE_ADMIN')] as GrantedAuthority[]) } def destroy = {} }
And to have a UI to test with, let’s create a Report
controller and GSPs:
But to use the controller, it will have to be reworked to use ReportService
. It’s a good idea to put all create/edit/delete code in a transactional service, but in this case we need to move all database access to the service to ensure that appropriate access checks are made:
package acltest import org.codehaus.groovy.grails.plugins.springsecurity.Secured import org.springframework.dao.DataIntegrityViolationException @Secured(['ROLE_ADMIN', 'ROLE_USER']) class ReportController { static allowedMethods = [delete: 'POST', save: 'POST', update: 'POST'] static defaultAction = 'list' def reportService def list = { params.max = Math.min(params.max ? params.max.toInteger() : 10, 100) [reportInstanceList: reportService.getAllReports(params), reportInstanceTotal: Report.count()] } def show = { def reportInstance = reportService.getReport(params.id?.toLong()) if (!reportInstance) { flash.message = "Report not found with id $params.id" redirect action: list return } [reportInstance: reportInstance] } def delete = { def reportInstance = reportService.getReport(params.id?.toLong()) if (!reportInstance) { flash.message = "Report not found with id $params.id" redirect action: list return } try { reportService.deleteReport(reportInstance) flash.message = "Report $params.id deleted" redirect action: list } catch (DataIntegrityViolationException e) { flash.message = "Report $params.id could not be deleted" redirect action: show, id: params.id } } def edit = { def reportInstance = reportService.getReport(params.id?.toLong()) if (!reportInstance) { flash.message = "Report not found with id $params.id" redirect action: list return } [reportInstance: reportInstance] } def update = { def reportInstance = reportService.getReport(params.id?.toLong()) if (!reportInstance) { flash.message = "Report not found with id $params.id" redirect action: list return } if (params.version) { long version = params.version.toLong() if (reportInstance.version > version) { reportInstance.errors.rejectValue('version', 'report.optimistic.locking.failure', 'Another user has updated this Report while you were editing.') render view:'edit',model: [reportInstance: reportInstance] return } } reportService.updateReport(reportInstance, params) if (reportInstance.hasErrors()) { render view: 'edit', model: [reportInstance: reportInstance] return } flash.message = "Report $params.id updated" redirect action: show, id: reportInstance.id } def create = { [reportInstance: new Report(params)] } def save = { def reportInstance = reportService.createReport(params) if (reportInstance.hasErrors()) { render view: 'create', model: [reportInstance: reportInstance] return } flash.message = "Report $reportInstance.id created" redirect action: show, id: reportInstance.id } }
Note that the controller is annotated to require either ROLE_USER
or ROLE_ADMIN
. Since services have nothing to do with HTTP, when access is blocked you cannot be redirected to the login page as when you try to access a URL that requires an authentication. So you need to configure URLs with similar role requirements to give the user a chance to attempt a login before calling secured service methods.
Start the app:
and open http://localhost:8080/acltest/report/list
Login as user2/user2 and you should only see reports #5 and #10. Logout via http://localhost:8080/acltest/logout and open the list page again, this time logging in as user1/user1. Now you should be able to see instances #1-4.
Verify that you can view #3 directly by clicking the id or opening http://localhost:8080/acltest/report/show/3. Also verify that you can edit #3 but cannot delete it.
Verify that you can view #4 directly by clicking the id or opening http://localhost:8080/acltest/report/show/4 and that can edit and delete it.
Verify that you can’t view #7 directly by opening http://localhost:8080/acltest/report/show/7.
You can download the full sample app here and the plugin with ACL support here
.
There isn’t much documentation available for Spring Security ACLs, but I found this blog post to be very informative and thorough.
If you have questions or issues with the code, please email the Grails User mailing list so others who might be having similar problems can partipate in the conversation.
I think apache Shiro plugin already provides this feature(ACL)
@Anonymous yes, Shiro has this feature but it’s a different and incompatible plugin.
Cool! Have you tested it with an LDAP backend? That is where you find a lot of hierarchical roles/ACL type data.
@Eric no, no LDAP testing yet. But the datastore access is pluggable by implementing the org.springframework.security.acls.jdbc.LookupStrategy interface for the aclLookupStrategy bean (currently implemented by org.codehaus.groovy.grails.plugins.springsecurity.acl.GormAclLookupStrategy) and by the aclService bean which implements org.springframework.security.acls.MutableAclService (currently implemented by org.grails.plugins.springsecurity.service.acl.AclService) so it’s a possibility if users want it.
[…] This post was Twitted by wanswins […]
Very very good job Burt !! Congratulations !!!
[…] The official announcement including an example application can be found on his blog (http://burtbeckwith.com/blog/?p=287). […]
Hey Burt, Thanks for this! That is one exhaustive write-up. I would like to b-test the Grails Spring Security ACLs functionality in a large healthcare application we are writing. I’d like to download it, but couldn’t see a link. Is the best place to download it the plugin repo (for now)?
@Adam sorry about that, the link is near the top but a little buried, so I added it down at the bottom too.
Actually we are designing a new software project, and this is what I’ve been looking for for hours now. I hope this plugin gets stable very soon, but I think, we’ll give it a try in the next days.
Just one question: One requirement is to have ACLs even on fields of domain classes. Is there any (simple) solution for this? It is not needed, that the plugin supports it, but a Grails compatible solution would be nice.
Thanks in advance for any reply.
Hey Burt,
We’re looking at doing object level Access Control, but without Access Control Lists. This is because we can programatically determine which users have access to a particular object. Do you have any tips on how you could use your plugin to achieve such a thing? We could obviously write business logic in each of our service methods to perform the check, but we would rather use AOP techniques.
Any ideas would be gratefully received!!
Thanks for all your hard work,
Adam
Hey Burt,
Having scoured through the plugin code, I think that injecting our own “aclAccessDecisionManager” may do the trick. Does this make sense, or are we barking up the wrong tree? 🙂
Thanks again,
Adam
Hey Burt,
I am testing the plugin. ACLs works well when added in separate service methods. When added in controllers the AffirmativeBased decisionmakers’s ecide method does not get executed. Also added getters/setter in controller and modified aclplugin.config to create interceptors for controllers (by adding separate code block for class in application.controllerClasses, with that before_ and after_ interceptors do get created for controller classes but AffirmativeBased does not get invoked. Any pointer would be appreciated.
Thanks!
Hi Burt,
I am currently trying SpringSecurity plugin with ACL support.
I have a requirement to filter and display data/info specific to the logged-in user’s organization. One way is to manually apply the filter while building the Criteria.
Wondering whether this plugin can be deployed to achieve the above? Please suggest.
Thanks
Hi Burt!
Very nice work here!
I’d like to ask why we need to use the service and not only the controller. At my stage of development, having to re-write all controllers to use services is costly 🙁
Is there any workaround?
Thanks
Hi Burt,
I did some test with this plugin. I managed one small problem.
If you have 21 reports in total and you have access (read) to all of them except the first 4, then you will see the following problem.
– If you use the pagination by 10 (default in grails) you will see only 6 reports in your first list
Is there a solution for it?
regards
Alain