Search code examples
multi-tenantshiro

Multi tenancy in Shiro


We are evaluating Shiro for a custom Saas app that we are building. Seems like a great framework does does 90% of what we want, out of the box. My understanding of Shiro is basic, and here is what I am trying to accomplish.

  • We have multiple clients, each with an identical database
  • All authorization (Roles/Permissions) will be configured by the clients within their own dedicated database
  • Each client will have a unique Virtual host eg. client1.mycompany.com, client2.mycompany.com etc

Scenario 1

Authentication done via LDAP (MS Active Directory)
Create unique users in LDAP, make app aware of LDAP users, and have client admins provision them into whatever roles..

Scenario 2

Authentication also done via JDBC Relam in their database

Questions:

Common to Sc 1 & 2 How can I tell Shiro which database to use? I realize it has to be done via some sort of custom authentication filter, but can someone guide me to the most logical way ? Plan to use the virtual host url to tell shiro and mybatis which DB to use.

Do I create one realm per client?

Sc 1 (User names are unique across clients due to LDAP) If user jdoe is shared by client1 and client2, and he is authenticated via client1 and tries to access a resource of client2, will Shiro permit or have him login again?

Sc 2 (User names unique within database only) If both client 1 and client 2 create a user called jdoe, then will Shiro be able to distinguish between jdoe in Client 1 and jdoe in Client 2 ?

My Solution based on Les's input..

public class MultiTenantAuthenticator extends ModularRealmAuthenticator {

    @Override
    protected AuthenticationInfo doAuthenticate(AuthenticationToken authenticationToken) throws AuthenticationException {
        assertRealmsConfigured();
        TenantAuthenticationToken tat = null;
        Realm tenantRealm = null;

        if (!(authenticationToken instanceof TenantAuthenticationToken)) {
            throw new AuthenticationException("Unrecognized token , not a typeof TenantAuthenticationToken ");
        } else {
            tat = (TenantAuthenticationToken) authenticationToken;
            tenantRealm = lookupRealm(tat.getTenantId());
        }

        return doSingleRealmAuthentication(tenantRealm, tat);

    }

    protected Realm lookupRealm(String clientId) throws AuthenticationException {
        Collection<Realm> realms = getRealms();
        for (Realm realm : realms) {
            if (realm.getName().equalsIgnoreCase(clientId)) {
                return realm;
            }
        }
        throw new AuthenticationException("No realm configured for Client " + clientId);
    }
}

New Type of token..

public final class TenantAuthenticationToken extends UsernamePasswordToken {

       public enum TENANT_LIST {

            CLIENT1, CLIENT2, CLIENT3 
        }
        private String tenantId = null;

        public TenantAuthenticationToken(final String username, final char[] password, String tenantId) {
            setUsername(username);
            setPassword(password);
            setTenantId(tenantId);
        }

        public TenantAuthenticationToken(final String username, final String password, String tenantId) {
            setUsername(username);
            setPassword(password != null ? password.toCharArray() : null);
            setTenantId(tenantId);
        }

        public String getTenantId() {
            return tenantId;
        }

        public void setTenantId(String tenantId) {
            try {
                TENANT_LIST.valueOf(tenantId);
            } catch (IllegalArgumentException ae) {
                throw new UnknownTenantException("Tenant " + tenantId + " is not configured " + ae.getMessage());
            }
            this.tenantId = tenantId;
        }
    }

Modify my inherited JDBC Realm

public class TenantSaltedJdbcRealm extends JdbcRealm {

    public TenantSaltedJdbcRealm() {
        // Cant seem to set this via beanutils/shiro.ini
        this.saltStyle = SaltStyle.COLUMN;
    }

    @Override
    public boolean supports(AuthenticationToken token) {
        return super.supports(token) && (token instanceof TenantAuthenticationToken);
    }

And finally use the new token when logging in

// This value is set via an Intercepting Servlet Filter
String client = (String)request.getAttribute("TENANT_ID");

        if (!currentUser.isAuthenticated()) {
            TenantAuthenticationToken token = new TenantAuthenticationToken(user,pwd,client);
            token.setRememberMe(true);
            try {
                currentUser.login(token);
            } catch (UnknownAccountException uae) {
                log.info("There is no user with username of " + token.getPrincipal());
            } catch (IncorrectCredentialsException ice) {
                log.info("Password for account " + token.getPrincipal() + " was incorrect!");
            } catch (LockedAccountException lae) {
                log.info("The account for username " + token.getPrincipal() + " is locked.  "
                        + "Please contact your administrator to unlock it.");
            } // ... catch more exceptions here (maybe custom ones specific to your application?
            catch (AuthenticationException ae) {
                //unexpected condition?  error?
                ae.printStackTrace();
            }
        }

}

Solution

  • You will probably need a ServletFilter that sits in front of all requests and resolves a tenantId pertaining to the request. You can store that resolved tenantId as a request attribute or a threadlocal so it is available anywhere for the duration of the request.

    The next step is to probably create a sub-interface of AuthenticationToken, e.g. TenantAuthenticationToken that has a method: getTenantId(), which is populated by your request attribute or threadlocal. (e.g. getTenantId() == 'client1' or 'client2', etc).

    Then, your Realm implementations can inspect the Token and in their supports(AuthenticationToken) implementation, and return true only if the token is a TenantAuthenticationToken instance and the Realm is communicating with the datastore for that particular tenant.

    This implies one realm per client database. Beware though - if you do this in a cluster, and any cluster node can perform an authentication request, every client node will need to be able to connect to every client database. The same would be true for authorization if authorization data (roles, groups, permissions, etc) is also partitioned across databases.

    Depending on your environment, this might not scale well depending on the number of clients - you'll need to judge accordingly.

    As for JNDI resources, yes, you can reference them in Shiro INI via Shiro's JndiObjectFactory:

    [main]
    datasource = org.apache.shiro.jndi.JndiObjectFactory
    datasource.resourceName = jdbc/mydatasource
    # if the JNDI name is prefixed with java:comp/env (like a Java EE environment),
    # uncomment this line:
    #datasource.resourceRef = true
    
    jdbcRealm = com.foo.my.JdbcRealm
    jdbcRealm.datasource = $datasource
    

    The factory will look up the datasource and make it available to other beans as if it were declared in the INI directly.