ACLs in the Grails Spring Security Plugin

Posted by: Burt Beckwith on 2009-12-27 20:08:00.0

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: '[email protected]').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: '[email protected]').save()
      userRole.addToPeople user1

      def user2 = new User(username: 'user2', userRealName: 'user2',
            passwd: passwordEncoder.encodePassword('user2', null),
            enabled: true, email: '[email protected]').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.


be the first to rate this blog

About Burt Beckwith

Burt Beckwith

Burt Beckwith is a Java developer with over ten years of experience in a variety of industries including biotech, travel, e-learning, social networking, and financial services. For the past two years he's been working with Grails and Groovy full-time. Along the way he's released five Grails plugins and is the primary developer of the Spring Security plugin. He was the technical editor for Grails in Action.

More About Burt »

NFJS, the Magazine

2010-04-01 00:00:00.0 Issue Now Available
  • Execute Around Method Pattern in Java and JVM Languages
    by Venkat Subramaniam
  • Hadoop and a Healthy Hoarding Mentality
    by Matthew McCullough
  • Ajax: Tools of the Trade
    by Nathaniel Schutta
  • Rest and SOAP Web Service Testing with soapUI, Part 2
    by Rohit Bhardwaj
Learn More »