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

grails create-app acltest
cd acltest

Download the plugin with ACL support here and install it:

grails install-plugin /path/to/grails-acegi-0.5.2-ACL.zip

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 create-auth-domains acltest.User acltest.Role acltest.Requestmap
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:

grails create-acl-domains

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 from grails-app/controllers/RoleController.groovy
  • in grails-app/conf/SecurityConfig.groovy, disable requestmaps (useRequestMapDomainClass = false) and enable annotations (useControllerAnnotations = true), and remove the requestMapClass 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:

grails create-domain-class acltest.Report

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 an Authentication) 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 an Authentication) 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 an Authentication) 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 an Authentication) 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 Permissions as parameters, so Strings 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:

grails create-service acltest.Report

and add some methods that work with Reports:

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 either ROLE_USER or ROLE_ADMIN (but no ACL rules)
  • getAllReports requires ROLE_USER and will have elements removed from the returned List that the user doesn’t have an ACL grant for (thanks to AFTER_ACL_COLLECTION_READ); the user must have one of the permissions defined in the afterAclCollectionRead bean (by default BasePermission.READ) for each element in the list; elements that don’t have access granted will be removed
  • getReport requires ROLE_USER and will be denied (thanks to AFTER_ACL_READ) unless the user has one of the permissions defined in the afterAclRead bean (by default BasePermission.READ).
  • updateReport has no role restrictions but must satisfy the requirements of the aclReportWriteVoter voter (which has the ACL_REPORT_WRITE config attribute), i.e. BasePermission.ADMINISTRATION or BasePermission.WRITE
  • deleteReport has no role restrictions but must satisfy the requirements of the aclReportDeleteVoter voter (which has the ACL_REPORT_DELETE config attribute), i.e. BasePermission.ADMINISTRATION or BasePermission.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:

grails generate-all acltest.Report

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:

grails run-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.

16 Responses to “ACLs in the Grails Spring Security Plugin”

  1. Anonymous says:

    I think apache Shiro plugin already provides this feature(ACL)

  2. Burt says:

    @Anonymous yes, Shiro has this feature but it’s a different and incompatible plugin.

  3. Eric P says:

    Cool! Have you tested it with an LDAP backend? That is where you find a lot of hierarchical roles/ACL type data.

  4. Burt says:

    @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.

  5. […] This post was Twitted by wanswins […]

  6. Pablo says:

    Very very good job Burt !! Congratulations !!!

  7. […] The official announcement including an example application can be found on his blog (http://burtbeckwith.com/blog/?p=287). […]

  8. Adam C says:

    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)?

  9. Burt says:

    @Adam sorry about that, the link is near the top but a little buried, so I added it down at the bottom too.

  10. Daniel says:

    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.

  11. Adam C says:

    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

  12. Adam C says:

    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

  13. Harish says:

    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!

  14. Ashish says:

    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

  15. KaLu10 says:

    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

  16. Alain says:

    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

Creative Commons License
This work is licensed under a Creative Commons Attribution 3.0 License.