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/requestmapdirectory and its GSPs - delete
Requestmapimport fromgrails-app/controllers/RoleController.groovy - in
grails-app/conf/SecurityConfig.groovy, disable requestmaps (useRequestMapDomainClass = false) and enable annotations (useControllerAnnotations = true), and remove therequestMapClassproperty:security {
active = trueloginUserDomainClass = 'acltest.User'
authorityDomainClass = 'acltest.Role'useRequestMapDomainClass = false
useControllerAnnotations = true
}
To enable ACL processing, set the useAcl attribute to true:
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:
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:
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 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:
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:
and add some methods that work with Reports:
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:
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:
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:
-
getReportNamerequires that the authenticated user have eitherROLE_USERorROLE_ADMIN(but no ACL rules) -
getAllReportsrequiresROLE_USERand will have elements removed from the returnedListthat the user doesn't have an ACL grant for (thanks toAFTER_ACL_COLLECTION_READ); the user must have one of the permissions defined in theafterAclCollectionReadbean (by defaultBasePermission.READ) for each element in the list; elements that don't have access granted will be removed -
getReportrequiresROLE_USERand will be denied (thanks toAFTER_ACL_READ) unless the user has one of the permissions defined in theafterAclReadbean (by defaultBasePermission.READ). -
updateReporthas no role restrictions but must satisfy the requirements of theaclReportWriteVotervoter (which has theACL_REPORT_WRITEconfig attribute), i.e.BasePermission.ADMINISTRATIONorBasePermission.WRITE -
deleteReporthas no role restrictions but must satisfy the requirements of theaclReportDeleteVotervoter (which has theACL_REPORT_DELETEconfig attribute), i.e.BasePermission.ADMINISTRATIONorBasePermission.DELETE -
createReporthas no restrictions
To test this out we'll need some users; create those and their grants in BootStrap.groovy:
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:
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:
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.
