Tuesday, June 21, 2011

ACL based security in JPA with jpasecurity: the next step after spring security

I had a clear need for ACL in my current project. Just protecting URLs is not enough and protecting method by method smells spaghetti code. Furthermore Spring solutions demand several hooks and still in my opinion they are still not addressing the real issue which is access control was removed from the database layer once ORM got mature but at the same time ORM did not provide a clean ACL solution.

If you are using JPA then you are in luck because jpasecurity project promises to resolve this limitation.

Rules are expressed in XML (I tested this so far) or Annotations (I had no luck with them so far). It allows granular access to CREATE, READ, UPDATE, DELETE operations based on roles and the current logged in user. It does that while wrapping all your JPA queries with the rules you specify. Basically you define access on let us say Client entity and every time Client entity appears in a JPA statement it wraps that statement adding the constraint. No need to say how powerful this is.

I used the trunk just because I was following up on some bugs that got corrected as I posted my questions. Probably you will get lucky and a stable 0.4.x release will be available by the time you decide to try it.

The examples here are based on a spring project using JPA + Hibernate + JTA + LDAP

Here are the main steps I followed:
  1. Edit your pom.xml
    <properties>
    ...
    <jpasecurity.version>0.4.0-SNAPSHOT</jpasecurity.version>
    ...
    </properties>
    ...
    <dependency>
              <groupId>net.sf.jpasecurity</groupId>
              <artifactId>jpasecurity-spring</artifactId>
              <version>${jpasecurity.version}</version>
              <exclusions>
               <exclusion>
                <artifactId>geronimo-ejb_3.1_spec</artifactId>
                <groupId>org.apache.geronimo.specs</groupId>
               </exclusion>
               <exclusion>
                <artifactId>geronimo-jpa_2.0_spec</artifactId>
                <groupId>org.apache.geronimo.specs</groupId>
               </exclusion>
              </exclusions>
    </dependency>
    ...
    
  2. If you need to debug some jpasecurity issues it is always useful to increase log level for the package
    log4j.logger.net.sf.jpasecurity=DEBUG
    
  3. I tried to use rules from annotations but the feature is still not well supported. I ended up putting my rules in XML. So here some samples. One of them as you can see quite complex:
    <security xmlns="http://jpasecurity.sf.net/xml/ns/security"
              xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
              xsi:schemaLocation="http://jpasecurity.sf.net/xml/ns/security
                                  http://jpasecurity.sf.net/xml/ns/security/security_1_0.xsd">
    
      <persistence-unit name="nestorurquizaPersistenceUnit">
    <access-rule>GRANT CREATE READ        ACCESS TO Client c</access-rule>
    <access-rule>GRANT                    ACCESS TO Client c WHERE 'ROLE_ADMIN' IN (CURRENT_ROLES)</access-rule>
    <access-rule>GRANT ACCESS TO Client c WHERE c.id IN (SELECT cs.client.id FROM ClientStaffing cs, ClientStatus cst, Employee e WHERE e.email=CURRENT_PRINCIPAL AND cs.employee=e AND cs.client=c AND cs.endDate IS NULL AND ( cst.name &lt;&gt; 'Closed' OR cst.name IS NULL) )</access-rule>
      </persistence-unit>
    </security>
    
  4. In persistence.xml for your container:
    <!-- Comment the below if using jpasecurity -->
    <!--    <provider>net.sf.jpasecurity.persistence.SecurePersistenceProvider</provider>-->
    
        <!-- Uncomment the below if not using jpasecurity -->
        <provider>org.hibernate.ejb.HibernatePersistence</provider>
    ...
    
        <properties>
                <!-- Comment the below if not using jpasecurity -->
    <!--            <property name="net.sf.jpasecurity.persistence.provider" value="org.hibernate.ejb.HibernatePersistence" />-->
    <!--            <property name="net.sf.jpasecurity.security.authentication.provider" value="com.nestorurquiza.security.JpasecuritySpringAuthenticationProvider"/>-->
    
  5. Note the need for a custom SpringAuthenticationProvider. This is just a hook to guarantee that CURRENT_PRINCIPAL maps to the user email which is the username in LDAP.
    package com.nestorurquiza.security;
    
    import net.sf.jpasecurity.spring.authentication.SpringAuthenticationProvider;
    
    import org.springframework.security.authentication.AnonymousAuthenticationToken;
    import org.springframework.security.core.Authentication;
    import org.springframework.security.core.context.SecurityContextHolder;
    import org.springframework.security.core.userdetails.UserDetails;
    
    public class JpasecuritySpringAuthenticationProvider extends SpringAuthenticationProvider{
        @Override
        public Object getPrincipal() {
            Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
            if (authentication == null || (authentication instanceof AnonymousAuthenticationToken)) {
                return null;
            }
            UserDetails userDetails = (UserDetails) authentication.getPrincipal();
            return userDetails.getUsername();
        }
    }
    
  6. I had to create a Custom Converter so Spring Binding works for forms. There is a bug I reported to Spring on this regard but anyway here is the workaround:
    package com.nestorurquiza.converter;
    
    import net.sf.jpasecurity.SecureObject;
    
    import org.springframework.core.convert.converter.Converter;
    
    public class SecureObjectToStringConverter implements Converter {
    
        @Override
        public String convert(SecureObject source) {
            return (source != null ? source.toString() : null);
        }
        
    }
    
  7. Then we need a custom ConversionServiceFactoryBean
    package com.nestorurquiza.converter;
    
    import org.springframework.beans.factory.annotation.Autowired;
    import org.springframework.core.convert.support.GenericConversionService;
    import org.springframework.format.FormatterRegistry;
    import org.springframework.format.support.FormattingConversionServiceFactoryBean;
    
    /**
     * Not being used.
     * @author jia
     */
    public class CustomConversionServiceFactoryBean extends FormattingConversionServiceFactoryBean {
    
        @Autowired
        private GenericConversionService genericConversionService;
        
        @Override
        protected void installFormatters(FormatterRegistry registry) {
            super.installFormatters(registry);
            //registry.addConverter(new BooleanToStringConverter());
            
            //Using org.springframework.format.support.FormattingConversionServiceFactoryBean from the xml declaration will not work
            //registry.addConverter(new SecureObjectToStringConverter());
            genericConversionService.addConverter(new SecureObjectToStringConverter());
        }
    }
    
    
  8. Then configure spring servlet with the necessary bean
    <!-- Registering custom ConversionService --> 
        <bean id="conversionService" class="com.nestorurquiza.converter.CustomConversionServiceFactoryBean" />
        <mvc:annotation-driven conversion-service="conversionService" />
        <!-- The below will not work at least for Binding --> 
        <!-- <bean id="conversionService" class="org.springframework.format.support.FormattingConversionServiceFactoryBean"> 
            <property name="converters"> 
                <list> 
                    <bean class="com.nestorurquiza.converter.SecureObjectToStringConverter"/> 
                </list> 
            </property> 
        </bean> 
        -->
    
  9. Include the jpasecurity taglib for access rules in JSP
    <%-- Comment the below if not using jpasecurity --%>
    <%--@ taglib prefix="access" uri="http://jpasecurity.sf.net/access" --%>
    
  10. Use rules as needed in JSP
    <access:updating entity="client">
           <security:authorize url="/client/${client.id}/edit"><a href="<spring:url value="/client/${client.id}/edit?ctoken=${sessionScope.ctoken}"/>"><spring:message code="edit" /></a></security:authorize>
        </access:updating>
    
  11. Typical response when security is violated:
    java.lang.SecurityException: The current user is not permitted to update the specified object of type com.nestorurquiza.model.Client
    
  12. Some Jpasecurity limitations so far:
    • Does not accept CONCAT function so we must pass the percentages for the LIKE clauses within the parameters (which is best practice anyway)
    • count(*) is not supported. Use the entity alias instead like "SELECT count(c) FROM Client c"
    • LOWER and CONCAT functions are not supported but probably you can live without them and the less functions the best performance.
  13. Existing JUnit tests will fail if you enable jpasecurity and do not use a user with valid roles in the current context. The way you correct them is presented below:
    ...
    String adminEmail = "Admin.User@nestorurquiza.com";
    injectCurrentUser(adminEmail, Roles.ROLE_ADMIN);
    …
    private void injectCurrentUser(String email, String role) {
            TestingAuthenticationToken token = new TestingAuthenticationToken(
                    email, email, new GrantedAuthority[]{
                        new GrantedAuthorityImpl(role)});       
            SecurityContextHolder.getContext().setAuthentication(token);
    }
    

1 comment:

codenuance said...

this is exactly what I was looking for. thx for the post.

Followers