3 Tutorial - Reference Documentation
Authors: Burt Beckwith
Version: 1.1.1
3 Tutorial
First create a test application:
$ grails create-app acltest
$ cd acltest
Install the plugin:
$ grails install-plugin spring-security-acl
This will install the Spring Security Core plugin, so you'll need to configure that by running the s2-quickstart
script:
$ grails s2-quickstart com.testacl User Role
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, s2-create-acl-domains
. This script is run when the plugin is installed (otherwise the plugin code wouldn't compile) but you can run it again to re-create the domain classes:
$ grails s2-create-acl-domains
Note that you cannot change the domain class names or packages since they're used by the plugin. The domain class mappings are configured to generate the same DDL as is required by the standard Spring Security JDBC implementation for portability.We'll need a domain class to test with, so create a Report
domain class:
$ grails create-domain-class com.testacl.Report
and add a name
property for testing:
package com.testaclclass Report { String name }
$ grails create-service com.testacl.Report
and add some methods that work with Report
s:
package com.testaclimport org.springframework.security.access.prepost.PostFilter import org.springframework.security.access.prepost.PreAuthorize import org.springframework.security.acls.domain.BasePermission import org.springframework.security.acls.model.Permission import org.springframework.transaction.annotation.Transactionalclass ReportService { static transactional = false def aclPermissionFactory def aclService def aclUtilService def springSecurityService void addPermission(Report report, String username, int permission) { addPermission report, username, aclPermissionFactory.buildFromMask(permission) } @PreAuthorize("hasPermission(#report, admin)") @Transactional void addPermission(Report report, String username, Permission permission) { aclUtilService.addPermission report, username, permission } @Transactional @PreAuthorize("hasRole('ROLE_USER')") Report create(String name) { Report report = new Report(name: name) report.save() // Grant the current principal administrative permission addPermission report, springSecurityService.authentication.name, BasePermission.ADMINISTRATION report } @PreAuthorize("hasPermission(#id, 'com.testacl.Report', read) or " + "hasPermission(#id, 'com.testacl.Report', admin)") Report get(long id) { Report.get id } @PreAuthorize("hasRole('ROLE_USER')") @PostFilter("hasPermission(filterObject, read) or " + "hasPermission(filterObject, admin)") List<Report> list(Map params) { Report.list params } int count() { Report.count() } @Transactional @PreAuthorize("hasPermission(#report, write) or " + "hasPermission(#report, admin)") void update(Report report, String name) { report.name = name } @Transactional @PreAuthorize("hasPermission(#report, delete) or " + "hasPermission(#report, admin)") void delete(Report report) { report.delete() // Delete the ACL information as well aclUtilService.deleteAcl report } @Transactional @PreAuthorize("hasPermission(#report, admin)") void deletePermission(Report report, String username, Permission permission) { def acl = aclUtilService.readAcl(report) // Remove all permissions associated with this particular // recipient (string equality to KISS) acl.entries.eachWithIndex { entry, i -> if (entry.sid.equals(recipient) && entry.permission.equals(permission)) { acl.deleteAce i } } aclService.updateAcl acl } }
addPermission
requires that the authenticated user have admin permission on the report instance to grant a permission to someone elsecreate
requires that the authenticated user haveROLE_USER
get
requires that the authenticated user have read or admin permission on the specified Reportlist
requires that the authenticated user have ROLE_USER and read or admin permission on each returned Report; instances that don't have granted permissions will be removed from the returned Listcount
has no restrictionsupdate
requires that the authenticated user have write or admin permission on the report instance to edit itdelete
requires that the authenticated user have delete or admin permission on the report instance to edit itdeletePermission
requires that the authenticated user have admin permission on the report instance to delete a grant
import com.testacl.Report import com.testacl.Role import com.testacl.User import com.testacl.UserRoleimport static org.springframework.security.acls.domain.BasePermission.ADMINISTRATION import static org.springframework.security.acls.domain.BasePermission.DELETE import static org.springframework.security.acls.domain.BasePermission.READ import static org.springframework.security.acls.domain.BasePermission.WRITEimport org.springframework.security.authentication. UsernamePasswordAuthenticationToken import org.springframework.security.core.authority.AuthorityUtils import org.springframework.security.core.context.SecurityContextHolder as SCHclass BootStrap { def aclService def aclUtilService def objectIdentityRetrievalStrategy def sessionFactory def springSecurityService def init = { servletContext -> createUsers() loginAsAdmin() grantPermissions() sessionFactory.currentSession.flush() // logout SCH.clearContext() } private void loginAsAdmin() { // have to be authenticated as an admin to create ACLs SCH.context.authentication = new UsernamePasswordAuthenticationToken( 'admin', 'admin123', AuthorityUtils.createAuthorityList('ROLE_ADMIN')) } private void createUsers() { def roleAdmin = new Role(authority: 'ROLE_ADMIN').save() def roleUser = new Role(authority: 'ROLE_USER').save() 3.times { long id = it + 1 def user = new User(username: "user$id", enabled: true, password: springSecurityService.encodePassword("password$id")).save() UserRole.create user, roleUser } def admin = new User(username: 'admin', enabled: true, password: springSecurityService.encodePassword('admin123')).save() UserRole.create admin, roleUser UserRole.create admin, roleAdmin, true } private void grantPermissions() { def reports = [] 100.times { long id = it + 1 def report = new Report(name: "report$id").save() reports << report aclService.createAcl( objectIdentityRetrievalStrategy.getObjectIdentity(report)) } // grant user 1 admin on 11,12 and read on 1-67 aclUtilService.addPermission reports[10], 'user1', ADMINISTRATION aclUtilService.addPermission reports[11], 'user1', ADMINISTRATION 67.times { aclUtilService.addPermission reports[it], 'user1', READ } // grant user 2 read on 1-5, write on 5 5.times { aclUtilService.addPermission reports[it], 'user2', READ } aclUtilService.addPermission reports[4], 'user2', WRITE // user 3 has no grants // grant admin admin on all for (report in reports) { aclUtilService.addPermission report, 'admin', ADMINISTRATION } // grant user 1 ownership on 1,2 to allow the user to grant aclUtilService.changeOwner reports[0], 'user1' aclUtilService.changeOwner reports[1], 'user1' } }
Report
controller and GSPs:
$ grails generate-all com.testacl.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 com.testaclimport org.springframework.dao.DataIntegrityViolationException import org.springframework.security.acls.model.Permissionimport grails.plugins.springsecurity.Secured@Secured(['ROLE_USER']) class ReportController { static defaultAction = 'list' def reportService def list = { params.max = Math.min(params.max ? params.int('max') : 10, 100) [reportInstanceList: reportService.list(params), reportInstanceTotal: reportService.count()] } def create = { [reportInstance: new Report(params)] } def save = { def report = reportService.create(params.name) if (!renderWithErrors('create', report)) { redirectShow "Report $report.id created", report.id } } def show = { def report = findInstance() if (!report) return [reportInstance: report] } def edit = { def report = findInstance() if (!report) return [reportInstance: report] } def update = { def report = findInstance() if (!report) return reportService.update report, params.name if (!renderWithErrors('edit', report)) { redirectShow "Report $report.id updated", report.id } } def delete = { def report = findInstance() if (!report) return try { reportService.delete report flash.message = "Report $params.id deleted" redirect action: list } catch (DataIntegrityViolationException e) { redirectShow "Report $params.id could not be deleted", params.id } } def grant = { def report = findInstance() if (!report) return if (!request.post) { return [reportInstance: report] } reportService.addPermission(report, params.recipient, params.int('permission')) redirectShow "Permission $params.permission granted on Report $report.id " + "to $params.recipient", report.id } private Report findInstance() { def report = reportService.get(params.long('id')) if (!report) { flash.message = "Report not found with id $params.id" redirect action: list } report } private void redirectShow(message, id) { flash.message = message redirect action: show, id: id } private boolean renderWithErrors(String view, Report report) { if (report.hasErrors()) { render view: view, model: [reportInstance: report] return true } false } }
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.Finally, we'll make a few adjustments so errors are handled gracefully.First, edit grails-app/conf/UrlMappings.groovy
and add some error code mappings:import org.springframework.security.access.AccessDeniedException import org.springframework.security.acls.model.NotFoundExceptionclass UrlMappings { static mappings = { "/$controller/$action?/$id?"{ constraints {} } "/"(view:"/index") "403"(controller: "errors", action: "error403") "404"(controller: "errors", action: "error404") "500"(controller: "errors", action: "error500") "500"(controller: "errors", action: "error403", exception: AccessDeniedException) "500"(controller: "errors", action: "error403", exception: NotFoundException) } }
ErrorsController
that these reference:
$ grails create-controller com.testacl.Errors
and add this code:package com.testaclimport grails.plugins.springsecurity.Secured@Secured(['permitAll']) class ErrorsController { def error403 = {} def error404 = {} def error500 = { render view: '/error' } }
grails-app/views/errors/error403.gsp
:<html> <head> <title>Access denied!</title> <meta name='layout' content='main' /> </head><body> <h1>Access Denied</h1> <p>We're sorry, but you are not authorized to perform the requested operation.</p> </body> </html>
grails-app/views/errors/error404.gsp
:
<html> <head> <title>Not Found</title> <meta name='layout' content='main' /> </head><body> <h1>Not Found</h1> <p>We're sorry, but that page doesn't exist.</p> </body> </html>
actionSubmit issues
Grails has a convenient feature where it supports multiple submit actions per form. This is done by posting to theindex
action but with a special parameter that indicates which action to invoke. This is a problem in general for security since any URL rules for edit, delete, save, etc. will be bypassed. It's an even more significant issue with ACLs because of the way that the access denied exception interacts with the actionSubmit
processing. If you don't make any adjustments for this, your users will see a blank page when they attempt to submit a form and the action is disallowed. The solution is to remove actionSubmit
buttons and replace them with regular submit buttons. This requires one form per button, and without adjusting the CSS the buttons will look differently than if they were in-line actionSubmit
buttons, but that is fixable with the appropriate CSS changes.It's simple to adjust the actionSubmit
buttons; in grails-app/views/report/show.gsp
, replace the two actionSubmit buttons with these two forms (maintain the g:message tags; the strings are hard-coded here to reduce clutter):<div class="buttons"> <g:form action='edit'> <g:hiddenField name="id" value="${reportInstance?.id}" /> <span class="button"> <g:submitButton class="edit" name="Edit" /> </span> </g:form> <g:form action='delete'> <g:hiddenField name="id" value="${reportInstance?.id}" /> <span class="button"> <g:submitButton class="delete" name="Delete" onclick="return confirm('Are you sure?');" /> </span> </g:form> </div>
grails-app/views/report/edit.gsp
, change the <form>
tag to<g:form action='update'>
<div class="buttons"> <span class="button"><g:submitButton class="save" name="Update" /></span> </div>
<g:form action='delete'> <g:hiddenField name="id" value="${reportInstance?.id}" /> <div class="buttons"> <span class="button"> <g:submitButton class="delete" name="Delete" onclick="return confirm('Are you sure?');" /> </span> </div> </g:form>
list.gsp
and show.gsp
are fine as they are.Testing
Now start the app:
$ grails run-app
and open http://localhost:8080/acltest/report/listLogin as user1/password1 and you should see the first page of results. But if you click on page 7 or higher, you'll see that you can only see a subset of the Report
s. This illustrates one issue with using ACLs to restrict view access to instances; you would have to add joins in your query to the ACL database tables to get an accurate count of the total number of visible instances.Click on any of the report instance links (e.g. http://localhost:8080/acltest/report/show/63) to verify that you can view the instance. You can test that you have no view access to the filtered instances by navigating to http://localhost:8080/acltest/report/show/83.Verify that user1 has admin permission on report #11 by editing it and deleting it.Verify that user1 doesn't have admin permission on report #13 by trying to editing or delete it and you should see the error page when you submit the form.Logout (by navigating to http://localhost:8080/acltest/logout) and login as user2/password2. You should only see the first five reports. Verify that you can edit #5 but not any of the others, and that you can't delete any.Finally. logout and login as admin/admin123. You should be able to view, edit, and delete all instances.