bekwam courses

WildFly JPA Data Security

January 15, 2019

A previous post described how to apply Role Based Access Control (RBAC) to a JavaEE app. RBAC associates a role with an EJB method. Users who don't have have the required role can't call the function. A secondary mechanism is needed to provide "need-to-know" data access. All users can use the same updateAccount() function, but a user -- unless an administrator -- should be restricted to updating their own account.

This type of security isn't provided by A JavaEE app server. Instead, two application functions will be presented in this article.

Check Data Access

The Check Data Access function will prevent users from accessing accounts that are not theirs. The following screenshot shows a successful call to retrieve an account. The user "NatsFan001" is the owner of the account with id=1. The field gamertag provides is the data model support for that relation.

ReadyAPI Screenshot
Successful Call to Lookup an Account

If the user is not the account holder -- gamertag does not match the username -- a 403 Forbidden error is returned. See this screenshot.

ReadyAPI Screenshot
Forbidden Call to Lookup an Account

Redact Sensitive Info

The demo app provides the ability for users to search other users by gamertag. However, the search should not return sensitive info, in this case first and last name. This removal does not apply to administrators who need the ability to view any account. This screenshot shows an administrator making a call to lookup all accounts with an "L" in the first name, lastname, or gamertag field.

ReadyAPI Screenshot
Complete Info Provided to Admin

If a user authenticates and searches, the user will get the same number of records, but with the sensitive info (first name and last name) stripped.

ReadyAPI Screenshot
Complete Info Provided to Admin

Web Service

The JavaEE application will pair a JAX-RS RESTful web service with an Enterprise Java Bean (EJB). The EJB will be outfitted with two interceptors for handling the data security requirements. The app will be configured to use RBAC and Basic Authentication as provided by WildFly.

This class diagram shows the classes used in the web service.

UML Class Diagram
Class Model of Secured Web Application

AccountResource is the JAX-RS endpoint. It provides methods for adding, updating, deleting, and searching accounts. The endpoint is secured using a standard JavaEE security-constraint configured in the project's web.xml file. Account is a transport serialized to and from JSON. APIApplication is the JAX-RS registration for AccountResource and two ExceptionMappers: DataAccessExceptionMapper and EJBAccessExceptionMapper.

AccountResource.java

	
package securitydemo.rs;

import securitydemo.ejb.AccountEntity;
import securitydemo.ejb.AccountServiceBean;

import javax.ejb.EJB;
import javax.validation.Valid;
import javax.ws.rs.*;
import javax.ws.rs.core.MediaType;
import javax.ws.rs.core.Response;
import java.util.List;
import java.util.stream.Collectors;

@Path("/accounts")
public class AccountsResource {

    @EJB
    private AccountServiceBean ejb;

    @GET
    @Produces(MediaType.APPLICATION_JSON)
    public List<Account> findAccounts(@QueryParam("name") String name,
                                      @DefaultValue("false") @QueryParam("showDeleted") Boolean deleted) {
        return ejb.findAccountsByName("%" + name + "%", deleted)
                .stream()
                .map( ae -> new Account(ae.getAccountId(), ae.getFirstName(), ae.getLastName(), ae.getGamertag(), ae.getDeleted(), ae.getInfoRedacted()))
                .collect( Collectors.toList() );
    }

    @GET
    @Path("/{id}")
    @Produces(MediaType.APPLICATION_JSON)
    public Account findAccount(@PathParam("id") Long id) {
        return ejb
                .findAccount(id)
                .map(
                    entity -> new Account(entity.getAccountId(), entity.getFirstName(), entity.getLastName(), entity.getGamertag(), entity.getDeleted(), entity.getInfoRedacted())
                )
                .orElseThrow(() -> new WebApplicationException(Response.Status.NOT_FOUND));
    }

    @POST
    @Consumes(MediaType.APPLICATION_JSON)
    @Produces(MediaType.APPLICATION_JSON)
    public Account addAccount(@Valid Account account) {

        AccountEntity ae = new AccountEntity();
        ae.setFirstName( account.getFirstName() );
        ae.setLastName( account.getLastName() );
        ae.setGamertag( account.getGamertag() );

        AccountEntity fromDB = ejb.addAccount(ae);

        return new Account(fromDB.getAccountId(), fromDB.getFirstName(), fromDB.getLastName(), fromDB.getGamertag(), false);
    }

    @PUT
    @Path("/{id}")
    @Consumes(MediaType.APPLICATION_JSON)
    public void updateAccount(@PathParam("id") Long id, @Valid Account account) {
        ejb
            .findAccount(id)
            .map(
                entity -> {
                    entity.setFirstName( account.getFirstName() );
                    entity.setLastName( account.getLastName() );
                    entity.setGamertag( account.getGamertag() );
                    entity.setDeleted( account.getDeleted() );
                    ejb.updateAccount(entity);
                    return entity;
                }
            )
            .orElseThrow(() -> new WebApplicationException(Response.Status.NOT_FOUND));
    }

    @DELETE
    @Path("/{id}")
    public void expungeAccount(@PathParam("id") Long id) {
        ejb.removeAccount( id );
    }
}	
	

Account.java


package securitydemo.rs;

import javax.validation.constraints.NotNull;
import javax.validation.constraints.Size;
import java.io.Serializable;

public class Account implements Serializable {

    private Long id;

    @NotNull @Size(max=25)
    private String firstName;

    @NotNull @Size(max=25)
    private String lastName;

    @NotNull @Size(max=25)
    private String gamertag;

    private Boolean deleted = false;
    private Boolean infoRedacted = false;

    public Account() {
    }

    public Account(Long id, String firstName, String lastName, String gamertag, Boolean deleted) {
        this.id = id;
        this.firstName = firstName;
        this.lastName = lastName;
        this.gamertag = gamertag;
        this.deleted = deleted;
    }

    public Account(Long id, String firstName, String lastName, String gamertag, Boolean deleted, Boolean infoRedacted) {
        this(id, firstName, lastName, gamertag, deleted);
        this.infoRedacted = infoRedacted;
    }

    public Long getId() {
        return id;
    }

    public void setId(Long id) {
        this.id = id;
    }

    public String getFirstName() {
        return firstName;
    }

    public void setFirstName(String firstName) {
        this.firstName = firstName;
    }

    public String getLastName() {
        return lastName;
    }

    public void setLastName(String lastName) {
        this.lastName = lastName;
    }

    public String getGamertag() {
        return gamertag;
    }

    public void setGamertag(String gamertag) {
        this.gamertag = gamertag;
    }

    public Boolean getDeleted() {
        return deleted;
    }

    public void setDeleted(Boolean deleted) {
        this.deleted = deleted;
    }

    public Boolean getInfoRedacted() {
        return infoRedacted;
    }

    public void setInfoRedacted(Boolean infoRedacted) {
        this.infoRedacted = infoRedacted;
    }
}

APIApplication.java

	
package securitydemo.rs;

import javax.ws.rs.ApplicationPath;
import javax.ws.rs.core.Application;
import java.util.HashSet;
import java.util.Set;

@ApplicationPath("/api")
public class APIApplication extends Application {

    @Override
    public Set^lt;Class<?>> getClasses() {

        Set<Class<?>> set = new HashSet<>();

        set.add( AccountsResource.class );

        set.add( EJBAccessExceptionMapper.class );
        set.add( DataAccessExceptionMapper.class );

        return set;
    }
}

	

The ExceptionMappers will translate a RuntimeException from the business logic into something usable in a RESTful API. If a user attempts to access a method that they don't have access to, an EJBAccessException will be thrown from the business logic based on the aforementioned RBAC settings. If a user attempts to access an account that they don't have access to, a custom DataAccessException will be thrown. Both RuntimeExceptions will be translated into HTTP 403 Forbidden responses.

DataAccessExceptionMapper.java


package securitydemo.rs;

import securitydemo.ejb.DataAccessException;

import javax.ejb.EJBAccessException;
import javax.ws.rs.core.Response;
import javax.ws.rs.ext.ExceptionMapper;

public class DataAccessExceptionMapper implements ExceptionMapper<DataAccessException> {
    @Override
    public Response toResponse(DataAccessException e) {
        return Response.status(Response.Status.FORBIDDEN).build();
    }
}

EJBAccessExceptionMapper.java


package securitydemo.rs;

import javax.ejb.EJBAccessException;
import javax.ws.rs.core.Response;
import javax.ws.rs.ext.ExceptionMapper;

public class EJBAccessExceptionMapper implements ExceptionMapper<EJBAccessException> {
    @Override
    public Response toResponse(EJBAccessException e) {
        return Response.status(Response.Status.FORBIDDEN).build();
    }
}

This is the security configuration for the RESTful web service. Once authenticated by passing in the Basic Authentication header, the established identity is shared with the EJB layer. This includes the security-related calls in the Interceptors.


<?xml version="1.0"?>

<web-app xmlns="http://xmlns.jcp.org/xml/ns/javaee"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://xmlns.jcp.org/xml/ns/javaee http://xmlns.jcp.org/xml/ns/javaee/web-app_4_0.xsd"
         version="4.0">


    <security-constraint>
        <web-resource-collection>
            <web-resource-name>API Resource</web-resource-name>
            <description>Constraint for API RESTful web services</description>
            <url-pattern>/api/*</url-pattern>
        </web-resource-collection>
        <auth-constraint>
            <role-name>user</role-name>
        </auth-constraint>
    </security-constraint>

    <login-config>
        <auth-method>BASIC</auth-method>
        <realm-name>ApplicationRealm</realm-name>
    </login-config>

    <security-role>
        <role-name>user</role-name>
    </security-role>

</web-app>

There's also a jboss-web.xml that strips the version of the WAR file name in the context.


<?xml version="1.0" encoding="UTF-8"?>
<jboss-web xmlns="http://www.jboss.com/xml/ns/javaee"
           xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
           xsi:schemaLocation="http://www.jboss.com/xml/ns/javaee http://www.jboss.org/j2ee/schema/jboss-web_10_0.xsd"
           version="10.0">

    <context-root>javaee-security-demo</context-root>

</jboss-web>

EJB

A primary design goal in this article is to build security into the business logic. This is important because the business logic can be used in different web services, a JSF app, a JMS client, a remoted desktop app, or other connections. With the security maintained at this layer, there is assurance that the callers -- say a collection of web services -- will be secure by extension.

The technique I'm using to apply security uses an Aspect-Oriented-Programming feature of JavaEE called Interceptors. Using an annotation, I associate an Interceptor with an EJB method. In the case of Check Data Access, DataAccessInterceptor looks up an AccountEntity and compares the gamertag with the currently-logged in user. If there's a match (or if the user is an admin) then the call proceeds and the found account is returned. If not, the DataAccessException is thrown and returned to the caller using the ExceptionMapper mentioned earlier.

A second Interceptor, DataRedactInterceptor, is placed after the EJB search call. It scrutinizes the user and if that user is not an administrator, the records returned by the search are scrubbed for sensitive information. In this example, the first and last names are blanked out.

This diagram shows the interaction between several requests, the Interceptors, and the EJB.

Interaction Diagram
Requests Handled by Interceptors

In the diagram, two updates are sent to the business logic. The first update passes through DataAccessException since the target record is owned by the logged-in user. The second update fails and a DataAccessException is thrown because there is a mismatch between user and record. Two searches are sent. The first one is from an admin user and the call is handled by AccountServiceBean with no effect from DataRedactInterceptor. The second request is from a plain user and that returned list is redacted.

DataAccessInterceptor.java


package securitydemo.ejb;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import securitydemo.rs.Account;

import javax.annotation.Resource;
import javax.ejb.SessionContext;
import javax.interceptor.AroundInvoke;
import javax.interceptor.InvocationContext;
import javax.persistence.EntityManager;
import javax.persistence.PersistenceContext;

public class DataAccessInterceptor {

    private Logger logger = LoggerFactory.getLogger(DataAccessInterceptor.class);

    @Resource private SessionContext session;

    @PersistenceContext
    private EntityManager em;

    @AroundInvoke
    public Object checkDataAccess(InvocationContext ctx) throws Exception {

        Object[] params = ctx.getParameters();

        Object p1 = params[0];

        if( p1 != null ) {

            Long accountId = -1L;
            if( p1 instanceof Long) {
                logger.debug("checking data access; param1={}", params[0]);
                accountId = (Long)params[0];
            } else if (p1 instanceof Account) {
                logger.debug("checking data access; param1.accountId={}", ((Account)params[0]).getId() );
                accountId = ((Account)params[0]).getId();
            }

            AccountEntity account = em.find( AccountEntity.class, accountId );

            logger.debug("is caller in role admin?={}", session.isCallerInRole("admin"));
            logger.debug("username={}", session.getCallerPrincipal().getName());

            if( account != null &&
                    account.getGamertag() != null &&
                    account.getGamertag().equals(session.getCallerPrincipal().getName()) ) {

                logger.debug("account=", account);
                session.getContextData().put("account", account);

            } else {

                throw new DataAccessException();

            }
        }

        return ctx.proceed();
    }
}

DataAccessInterceptor should be applied before one-argument EJB methods that pass in an accountId (Long). These are the update and the single Account lookup methods. For performance, the looked-up record is put on the EJB Session to avoid a second lookup in the EJB. It's architecturally cleaner to have each component be completely decoupled, but it's not efficient to double-up on each query.

DataAccessException.java

Keeping with the theme of low-coupling, I made DataAccessException a RuntimeException so that the call signatures didn't need to be modified. This means that any uncaught exceptions will rollback any transactions.

	
package securitydemo.ejb;

public class DataAccessException extends RuntimeException {}
	

DataRedactInterceptor should be applied after any EJB methods that return a List of Accounts. This is the name search. The position of the ctx.proceed() call has changed, allowing for post-processing.

DataRedactInterceptor.java


package securitydemo.ejb;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import javax.annotation.Resource;
import javax.ejb.SessionContext;
import javax.interceptor.AroundInvoke;
import javax.interceptor.InvocationContext;
import javax.persistence.EntityManager;
import javax.persistence.PersistenceContext;
import java.util.List;

public class DataRedactInterceptor {

    private Logger logger = LoggerFactory.getLogger(securitydemo.ejb.DataRedactInterceptor.class);

    @Resource
    private SessionContext session;

    @PersistenceContext
    private EntityManager em;

    @AroundInvoke
    public Object removeSensitiveInfo(InvocationContext ctx) throws Exception {

        Object retval = ctx.proceed();

        if( !session.isCallerInRole("admin") ) {

            if( retval != null ) {

                try {
                    List<AccountEntity> accounts = (List<AccountEntity>) retval;

                    accounts.forEach(
                            ae -> {
                                em.detach(ae);
                                ae.setFirstName("");
                                ae.setLastName("");
                                ae.setInfoRedacted(true);
                            }
                    );

                    // retval changed

                } catch(ClassCastException exc) {
                    if( logger.isWarnEnabled() ) {
                        logger.warn("[REMOVE SENSITIVE] unexpected retval type={}", retval.getClass().getName());
                    }
                }
            }
        }

        return retval;
    }
}

If the caller is an admin, each item in the list is detached from JPA and the sensitive fields are cleared. I also added a special marking to indicate that this removal has taking place. This is intended as a devlopment convience.

It is crucial that the JPA entity be detached from its managed context. Without that line, DataRedactInterceptor will actually produce updates and remove the persisted data, not just the data returned in the search call. The EntityManager injected into the Interceptors is the same as the one in the upcoming EJB listing.

AccountServiceBean.java

AccountServiceBean is a Stateless EJB with methods that manipulate and return JPA objects. The class-level RolesAllowed makes any unspecified methods require the user role. There is a method-level RolesAllowed for expungeAccount() which requires an admin. findAccount() and updateAccount() is called with the DataAccessInterceptor. findAccountsByName() is called with the DataRedactInterceptor.


package securitydemo.ejb;

import javax.annotation.Resource;
import javax.annotation.security.RolesAllowed;
import javax.ejb.SessionContext;
import javax.ejb.Stateless;
import javax.interceptor.Interceptors;
import javax.persistence.EntityManager;
import javax.persistence.PersistenceContext;
import javax.persistence.TypedQuery;
import javax.persistence.criteria.CriteriaBuilder;
import javax.persistence.criteria.CriteriaQuery;
import javax.persistence.criteria.Predicate;
import javax.persistence.criteria.Root;
import java.util.List;
import java.util.Optional;

@Stateless
@RolesAllowed("user")
public class AccountServiceBean {

    @PersistenceContext
    private EntityManager em;

    @Resource
    private SessionContext session;

    @Interceptors(DataAccessInterceptor.class)
    public Optional<AccountEntity> findAccount(Long accountId) {

        //
        // done for performance; no need to do 2 db queries
        //
        AccountEntity ae = (AccountEntity)session.getContextData().get("account");

        if( ae == null ) {
            ae = em.find(AccountEntity.class, accountId);  // operates w/o interceptor
        }

        return Optional.ofNullable( ae );
    }

    public AccountEntity addAccount(AccountEntity account) {
        em.persist(account);
        return account;
    }

    @RolesAllowed("admin")
    public void removeAccount(Long accountId) { em.remove( em.find(AccountEntity.class, accountId) ); }

    @Interceptors(DataAccessInterceptor.class)
    public void updateAccount(AccountEntity ae) {

        Optional<AccountEntity> fromDB = findAccount(ae.getAccountId());

        fromDB.ifPresent( fdb -> {
            fdb.setFirstName( ae.getFirstName() );
            fdb.setLastName(ae.getLastName());
            fdb.setGamertag(ae.getGamertag());
            fdb.setDeleted(ae.getDeleted());
        });
    }

    @Interceptors(DataRedactInterceptor.class)
    public List<AccountEntity> findAccountsByName(String name, Boolean showDeleted) {

        CriteriaBuilder cb = em.getCriteriaBuilder();
        CriteriaQuery<AccountEntity> cq = cb.createQuery(AccountEntity.class);
        Root<AccountEntity> ae = cq.from(AccountEntity.class);

        cq.select(ae);
        cq.orderBy(cb.asc(ae.get(AccountEntity_.gamertag)));

        Predicate names = cb.or(
                cb.like(cb.upper(ae.get(AccountEntity_.firstName)), name.toUpperCase()),
                cb.like(cb.upper(ae.get(AccountEntity_.lastName)), name.toUpperCase()),
                cb.like(cb.upper(ae.get(AccountEntity_.gamertag)), name.toUpperCase())
        );

        if( showDeleted ) {
            cq.where(names);  // no clause involving deleted; both true and false returned
        } else {
            cq.where(
                cb.and(
                    names,
                    cb.equal(ae.get(AccountEntity_.deleted), false))
            );
       }

        TypedQuery<AccountEntity> q = em.createQuery(cq);

        return q.getResultList();
    }

}

The JPA code is similar to what I presented in other posts. The findAccount(), and by extension the updateAccount(), has been optimized to first look in the session for its data. If not found, the findAccount() method will run its own query. This logic allows the EJB method to be operated without the Interceptor should requirements change.

AccountEntity is used by JPA. It's similar to Account, but separated for this demo to emphasize the different purpose and also because Entity objects often diverge from the transport objects used by callers. These callers may be different web services, web apps, or remoted desktop apps that don't want the JPA markings or behavior.


package securitydemo.ejb;

import javax.persistence.*;

@Entity
public class AccountEntity {

    @Id @GeneratedValue
    private Long accountId;

    private String firstName;
    private String lastName;

    /**
     * gamertag is a unique account identifier that relates this record to the auth subsystem
     */
    private String gamertag;

    @Version
    private Integer version;

    /**
     * a flag supporting a logical delete
     */
    private Boolean deleted = false;

    @Transient
    private Boolean infoRedacted = false;

    public AccountEntity() {
    }

    public AccountEntity(String firstName, String lastName, String gamertag, Integer version) {
        this.firstName = firstName;
        this.lastName = lastName;
        this.gamertag = gamertag;
        this.version = version;
    }

    public Long getAccountId() {
        return accountId;
    }

    public void setAccountId(Long accountId) {
        this.accountId = accountId;
    }

    public String getFirstName() {
        return firstName;
    }

    public void setFirstName(String firstName) {
        this.firstName = firstName;
    }

    public String getLastName() {
        return lastName;
    }

    public void setLastName(String lastName) {
        this.lastName = lastName;
    }

    public String getGamertag() {
        return gamertag;
    }

    public void setGamertag(String gamertag) {
        this.gamertag = gamertag;
    }

    public Integer getVersion() {
        return version;
    }

    public void setVersion(Integer version) {
        this.version = version;
    }

    public Boolean getDeleted() {
        return deleted;
    }

    public void setDeleted(Boolean deleted) {
        this.deleted = deleted;
    }

    public Boolean getInfoRedacted() {
        return infoRedacted;
    }

    public void setInfoRedacted(Boolean infoRedacted) {
        this.infoRedacted = infoRedacted;
    }

    @Override
    public String toString() {
        return "AccountEntity{" +
                "accountId=" + accountId +
                ", firstName='" + firstName + '\'' +
                ", lastName='" + lastName + '\'' +
                ", gamertag='" + gamertag + '\'' +
                ", version=" + version +
                ", deleted=" + deleted +
                ", infoRedacted=" + infoRedacted +
                '}';
    }
}

WildFly

This example uses the WildFly 15 app server. Out-of-the-box, there is an H2 datasource configured and a security store based on a pair of .properties files. To create the accounts in this demo (ex, NatsFan001 and mydemoadmin) I used the add-user.sh tool. The following is the persistence.xml file I used which maps to the ExampleDS shipped with WildFly.


<?xml version="1.0" encoding="UTF-8"?>
<persistence xmlns="http://xmlns.jcp.org/xml/ns/persistence"
             xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
             xsi:schemaLocation="http://xmlns.jcp.org/xml/ns/persistence
          http://xmlns.jcp.org/xml/ns/persistence/persistence_2_2.xsd"
             version="2.2">
    <persistence-unit name="myPU">
        <jta-data-source>java:jboss/datasources/ExampleDS</jta-data-source>
        <properties>
            <property name="hibernate.hbm2ddl.auto" value="create" />
        </properties>
    </persistence-unit>
</persistence>

While RBAC is provided by JavaEE, data security is not. Fortunately, JavaEE has features like Interceptors and a security model that can implement this in a reliable manner. An alternative approach might be to build up libraries that perform the function of DataAccessInterceptor and DataRedactInterceptor and mandate their use for EJB writers. I prefer the Interceptors because it's easier to mark a method than to insert trivial lines of code consistently thorughout a codebase. Also, the Interceptors help with de-coupling since the parameters can't be manipulated by the EJB as they could with a library call.


Headshot of Carl Walker

By Carl Walker

President and Principal Consultant of Bekwam, Inc