Custom Tenant Resolver by Current Logged in User
Learn how to create a custom tenant resolver and use Grails Multi-Tenancy capabilities to switch tenants based on the current logged user or by a JWT.
Authors: Sergio del Amo
Grails Version: 4.0.1
1 Grails Training
Grails Training - Developed and delivered by the folks who created and actively maintain the Grails framework!.
2 Getting Started
GORM Multi-Tenancy Support includes several Built-In Tenant resolvers. In this guide, you are going to create your own tenant resolver to switch tenants based on the logged user in a Grails rest-api application which uses Spring Security REST plugin.
2.1 What you will need
To complete this guide, you will need the following:
-
Some time on your hands
-
A decent text editor or IDE
-
JDK 1.8 or greater installed with
JAVA_HOME
configured appropriately
2.2 How to complete the guide
To get started do the following:
-
Download and unzip the source
or
-
Clone the Git repository:
git clone https://github.com/grails-guides/grails-custom-security-tenant-resolver.git
The Grails guides repositories contain two folders:
-
initial
Initial project. Often a simple Grails app with some additional code to give you a head-start. -
complete
A completed example. It is the result of working through the steps presented by the guide and applying those changes to theinitial
folder.
To complete the guide, go to the initial
folder
-
cd
intograils-guides/grails-custom-security-tenant-resolver/initial
and follow the instructions in the next sections.
You can go right to the completed example if you cd into grails-guides/grails-custom-security-tenant-resolver/complete
|
3 Writing the Application
This guide uses Multi-Tenancy DISCRIMINATOR mode. To learn more, read Single Database Multi-Tenancy Discriminator Column Guide. |
3.1 Create Domain Class
package demo
import grails.gorm.MultiTenant
class Plan implements MultiTenant<Plan> { (1)
String title
String username
static mapping = {
tenantId name: 'username' (2)
}
}
1 | Implement MultiTenant trait to regard this domain class as multi tenant. |
2 | Setup the tenant identifier as a column named username |
Create an interface PlanService
which is a GORM Data Service.
package demo
import grails.gorm.multitenancy.CurrentTenant
import grails.gorm.services.Service
@Service(Plan) (1)
@CurrentTenant (2)
interface PlanDataService {
List<Plan> findAll()
Plan save(String title)
void deleteByTitle(String title)
}
1 | Annotate it with the grails.gorm.services.Service annotation with the domain class the service applies to. |
2 | Resolve the current tenant for the context of this class |
3.2 Controller
Create a simple controller which uses with the previously defined PlanService
service.
package demo
import groovy.transform.CompileStatic
@CompileStatic
class PlanController {
PlanDataService planDataService
def index() {
[planList: planDataService.findAll()]
}
}
We render the Plan
instances as JSON with the help of JSON Views
import demo.Plan
model {
Iterable<Plan> planList
}
json tmpl.plan('plan', planList)
import demo.Plan
model {
Plan plan
}
json {
title plan.title
}
3.3 Wire up Security
Create security domain classes. You could create these classes with Spring Security Core s2-quickstart
.
package demo
import groovy.transform.EqualsAndHashCode
import groovy.transform.ToString
import grails.compiler.GrailsCompileStatic
@GrailsCompileStatic
@EqualsAndHashCode(includes='username')
@ToString(includes='username', includeNames=true, includePackage=false)
class User implements Serializable {
private static final long serialVersionUID = 1
String username
String password
boolean enabled = true
boolean accountExpired
boolean accountLocked
boolean passwordExpired
Set<Role> getAuthorities() {
(UserRole.findAllByUser(this) as List<UserRole>)*.role as Set<Role>
}
static constraints = {
password nullable: false, blank: false, password: true
username nullable: false, blank: false, unique: true
}
static mapping = {
password column: '`password`'
}
}
package demo
import groovy.transform.EqualsAndHashCode
import groovy.transform.ToString
import grails.compiler.GrailsCompileStatic
@GrailsCompileStatic
@EqualsAndHashCode(includes='authority')
@ToString(includes='authority', includeNames=true, includePackage=false)
class Role implements Serializable {
private static final long serialVersionUID = 1
String authority
static constraints = {
authority nullable: false, blank: false, unique: true
}
static mapping = {
cache true
}
}
Create a class UserPasswordEncoderListener
which deals with the User’s password encoding.
It uses the @Listener
annotation to listen synchronously to Events from GORM.
package demo
import grails.plugin.springsecurity.SpringSecurityService
import org.grails.datastore.mapping.engine.event.AbstractPersistenceEvent
import org.grails.datastore.mapping.engine.event.PreInsertEvent
import org.grails.datastore.mapping.engine.event.PreUpdateEvent
import org.springframework.beans.factory.annotation.Autowired
import grails.events.annotation.gorm.Listener
import groovy.transform.CompileStatic
@CompileStatic
class UserPasswordEncoderListener {
@Autowired
SpringSecurityService springSecurityService
@Listener(User)
void onPreInsertEvent(PreInsertEvent event) {
encodePasswordForEvent(event)
}
@Listener(User)
void onPreUpdateEvent(PreUpdateEvent event) {
encodePasswordForEvent(event)
}
private void encodePasswordForEvent(AbstractPersistenceEvent event) {
if (event.entityObject instanceof User) {
User u = event.entityObject as User
if (u.password && ((event instanceof PreInsertEvent) || (event instanceof PreUpdateEvent && u.isDirty('password')))) {
event.getEntityAccess().setProperty('password', encodePassword(u.password))
}
}
}
private String encodePassword(String password) {
springSecurityService?.passwordEncoder ? springSecurityService.encodePassword(password) : password
}
}
Define the previous listener as a bean:
import demo.UserPasswordEncoderListener
beans = {
userPasswordEncoderListener(UserPasswordEncoderListener)
}
Configure Spring Security Core in application.yml
grails
grails:
plugin:
springsecurity:
rest:
token:
storage:
jwt:
secret: qrD6h8K6S9503Q06Y6Rfk21TErImPYqa # change this for a new one
securityConfigType: InterceptUrlMap
userLookup:
userDomainClassName: demo.User
authorityJoinClassName: demo.UserRole
authority:
className: demo.Role
filterChain:
chainMap:
- # Stateless chain
pattern: /**
filters: 'JOINED_FILTERS,-exceptionTranslationFilter,-authenticationProcessingFilter,-securityContextPersistenceFilter,-rememberMeAuthenticationFilter'
interceptUrlMap:
-
pattern: /
access:
- ROLE_VILLAIN
-
pattern: /error
access:
- permitAll
-
pattern: /api/login
access:
- ROLE_ANONYMOUS
-
pattern: /api/validate
access:
- ROLE_ANONYMOUS
-
pattern: /oauth/access_token
access:
- ROLE_ANONYMOUS
-
pattern: /plan
access:
- ROLE_VILLAIN
Create several GORM Data Services to work with the Domain Classes involved in the securization of our app.
package demo
import grails.gorm.services.Service
@Service(Role)
interface RoleDataService {
void delete(Serializable id)
void deleteByAuthority(String authority)
Role findByAuthority(String authority)
Role saveByAuthority(String authority)
}
package demo
import grails.gorm.services.Service
import groovy.transform.CompileStatic
@CompileStatic
@Service(User)
interface UserDataService {
User save(String username, String password)
void delete(Serializable id)
User findByUsername(String username)
}
package demo
import grails.gorm.services.Query
import grails.gorm.services.Service
import groovy.transform.CompileStatic
@Service(UserRole)
@CompileStatic
interface UserRoleDataService {
@Query("""select $user.username from ${UserRole userRole}
inner join ${User user = userRole.user}
inner join ${Role role = userRole.role}
where $role.authority = $authority""")
List<String> findAllUsernameByAuthority(String authority)
UserRole save(User user, Role role)
void delete(User user, Role role)
void deleteByUser(User user)
}
Create a service to handle User creation with a specific role.
package demo
import grails.gorm.transactions.Transactional
import groovy.transform.CompileStatic
@CompileStatic
class UserService {
RoleDataService roleDataService
UserRoleDataService userRoleDataService
UserDataService userDataService
@Transactional
User save(String username, String password, String authority) {
Role role = roleDataService.findByAuthority(authority)
if ( !role ) {
role = roleDataService.saveByAuthority(authority)
}
User user = userDataService.save(username, password)
userRoleDataService.save(user, role)
user
}
}
3.4 Custom Tenant Resolver
The following table contains all of the TenantResolver
implementations that ship with GORM and are usable out of the box. The package name has been shorted from org.grails.datastore.mapping
to o.g.d.m
for brevity:
name | description |
---|---|
|
Resolves against a fixed tenant id |
|
Resolves the tenant id from a system property called |
|
Resolves the tenant id from the subdomain via DNS |
|
Resolves the current tenant from an HTTP cookie named |
|
Resolves the current tenant from the HTTP session using the attribute |
However, you have the flexibility to create your a custom tenant resolver is easy. In order to do that, create a class which implements
org.grails.datastore.mapping.multitenancy.TenantResolver
.
The next sections will show you how to create two custom tenant resolvers.
3.5 Tenant Resolver by JWT
Grails Spring Security REST plugin allows you to authenticate your application with a JWT token present in the Authorization
header.
Example: A request to /plan
contains a JWT token in the Authorization
header.
## Request Duplicate
curl "http://localhost:8080/plan" \
-H "Authorization: Bearer eyJhbGciOiJIUzI1NiJ9....." \
-H "Accept: application/json"
We can create Custom Tenant Resolver which extracts the JWT token, re-hydrates it and extracts the username.
package demo
import grails.gorm.DetachedCriteria
import grails.plugin.springsecurity.rest.token.storage.TokenStorageService
import groovy.transform.CompileStatic
import org.grails.datastore.mapping.multitenancy.AllTenantsResolver
import org.grails.datastore.mapping.multitenancy.exceptions.TenantNotFoundException
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.security.core.userdetails.UserDetails
import org.springframework.web.context.request.RequestAttributes
import org.springframework.web.context.request.RequestContextHolder
import org.springframework.web.context.request.ServletWebRequest
import javax.servlet.http.HttpServletRequest
@CompileStatic
class CurrentUserByJwtTenantResolver implements AllTenantsResolver { (1)
public static final String HEADER_NAME = 'Authorization'
public static final String HEADER_VALUE_PREFFIX = 'Bearer '
String headerName = HEADER_NAME
String headerValuePreffix = HEADER_VALUE_PREFFIX
@Autowired
TokenStorageService tokenStorageService
@Override
Serializable resolveTenantIdentifier() throws TenantNotFoundException {
RequestAttributes requestAttributes = RequestContextHolder.getRequestAttributes()
if(requestAttributes instanceof ServletWebRequest) {
HttpServletRequest httpServletRequest = ((ServletWebRequest) requestAttributes).getRequest()
String token = httpServletRequest.getHeader(headerName.toLowerCase())
if ( !token ) {
throw new TenantNotFoundException("Tenant could not be resolved from HTTP Header: ${headerName}")
}
if (token.startsWith(headerValuePreffix)) {
token = token.substring(headerValuePreffix.length())
}
UserDetails userDetails = tokenStorageService.loadUserByToken(token)
String username = userDetails?.username
if ( username ) {
return username
}
throw new TenantNotFoundException("Tenant could not be resolved from HTTP Header: ${headerName}")
}
throw new TenantNotFoundException("Tenant could not be resolved outside a web request")
}
@Override
Iterable<Serializable> resolveTenantIds() {
User.withTransaction(readOnly: true) {
new DetachedCriteria(User)
.distinct('username')
.list() as Iterable<Serializable>
}
}
}
1 | When using discriminator-based multi-tenancy then you may need to implement the AllTenantsResolver interface in your TenantResolver implentation if you want to at any point iterate over all available tenants. AllTenantsResolver extends `TenantResolver . To create your own tenant resolver implement org.grails.datastore.mapping.multitenancy.TenantResolver . |
Define the Custom Tenant Resolver as a bean:
import demo.CurrentUserByJwtTenantResolver
beans = {
currentUserByJwtTenantResolver(CurrentUserByJwtTenantResolver)
}
Create an Integration Test to test the custom Tenant Resolver.
package demo
import grails.plugin.springsecurity.rest.token.AccessToken
import grails.plugin.springsecurity.rest.token.generation.TokenGenerator
import grails.testing.mixin.integration.Integration
import org.grails.datastore.mapping.multitenancy.exceptions.TenantNotFoundException
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.mock.web.MockHttpServletRequest
import org.springframework.security.core.GrantedAuthority
import org.springframework.security.core.userdetails.UserDetails
import org.springframework.web.context.request.RequestContextHolder
import org.springframework.web.context.request.ServletWebRequest
import spock.lang.Specification
import spock.lang.IgnoreIf
@IgnoreIf( { System.getenv('TRAVIS') as boolean } )
@Integration
class CurrentUserByJwtTenantResolverSpec extends Specification {
@Autowired
CurrentUserByJwtTenantResolver currentUserTenantResolver
TokenGenerator tokenGenerator
void "Test HttpHeader resolver throws an exception outside a web request"() {
when:
currentUserTenantResolver.resolveTenantIdentifier()
then:
def e = thrown(TenantNotFoundException)
e.message == "Tenant could not be resolved outside a web request"
}
void "Test not tenant id found"() {
setup:
def request = new MockHttpServletRequest("GET", "/foo")
RequestContextHolder.setRequestAttributes(new ServletWebRequest(request))
when:
currentUserTenantResolver.resolveTenantIdentifier()
then:
def e = thrown(TenantNotFoundException)
e.message == "Tenant could not be resolved from HTTP Header: ${CurrentUserByJwtTenantResolver.HEADER_NAME}"
cleanup:
RequestContextHolder.setRequestAttributes(null)
}
void "Test HttpHeader value is the tenant id when a request is present"() {
setup:
MockHttpServletRequest request = new MockHttpServletRequest("GET", "/foo")
def userDetails = Stub(UserDetails) {
getUsername() >> 'vector'
isAccountNonExpired() >> true
isAccountNonLocked() >> true
isCredentialsNonExpired() >> true
isEnabled() >> true
}
AccessToken accessToken = tokenGenerator.generateAccessToken(userDetails, 3600)
String jwt = accessToken.accessToken
request.addHeader('Authorization', "Bearer $jwt")
RequestContextHolder.setRequestAttributes(new ServletWebRequest(request))
when:
def tenantId = currentUserTenantResolver.resolveTenantIdentifier()
then:
tenantId == "vector"
cleanup:
RequestContextHolder.setRequestAttributes(null)
}
}
Next were are going to configure our app to use this custom tenant resolver.
Configure Multi-Tenancy by modifying application.yml
.
grails
gorm:
multiTenancy:
mode: DISCRIMINATOR (1)
tenantResolverClass: demo.CurrentUserByJwtTenantResolver (2)
reactor:
# Whether to translate GORM events into Reactor events
# Disabled by default for performance reasons
events: false
1 | Define Multi-Tenancy mode as DISCRIMINATOR |
2 | Set the Tenant Resolver class with a custom class which you will write later in this guide. |
3.6 Tenant Resolver by User
Alternatively, you may want to use a combination of Stateless endpoints secured by Spring Security REST and Stateful endpoints secured by Spring Security Core. You will be interested to create a custom resolver by the current logged in user.
package demo
import grails.gorm.DetachedCriteria
import grails.plugin.springsecurity.SpringSecurityService
import groovy.transform.CompileDynamic
import groovy.transform.CompileStatic
import org.grails.datastore.mapping.multitenancy.AllTenantsResolver
import org.grails.datastore.mapping.multitenancy.TenantResolver
import org.grails.datastore.mapping.multitenancy.exceptions.TenantNotFoundException
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.context.annotation.Lazy
import org.springframework.security.core.userdetails.UserDetails
@CompileStatic
class CurrentUserTenantResolver implements AllTenantsResolver { (1)
@Autowired
@Lazy
SpringSecurityService springSecurityService
@Override
Serializable resolveTenantIdentifier() throws TenantNotFoundException {
String username = loggedUsername()
if ( username ) {
return username
}
throw new TenantNotFoundException("Tenant could not be resolved from Spring Security Principal")
}
String loggedUsername() {
if ( springSecurityService.principal instanceof String ) {
return springSecurityService.principal
}
if (springSecurityService.principal instanceof UserDetails) {
return ((UserDetails) springSecurityService.principal).username
}
null
}
@Override
Iterable<Serializable> resolveTenantIds() {
User.withTransaction(readOnly: true) {
new DetachedCriteria(User)
.distinct('username')
.list() as Iterable<Serializable>
}
}
}
1 | When using discriminator-based multi-tenancy then you may need to implement the AllTenantsResolver interface in your TenantResolver implentation if you want to at any point iterate over all available tenants. AllTenantsResolver extends `TenantResolver . To create your own tenant resolver implement org.grails.datastore.mapping.multitenancy.TenantResolver . |
Define the Custom Tenant Resolver as a bean:
import demo.CurrentUserTenantResolver
beans = {
currentUserTenantResolver(CurrentUserTenantResolver)
}
Create an Integration Test to test the custom Tenant Resolver.
package demo
import grails.testing.mixin.integration.Integration
import org.grails.datastore.mapping.multitenancy.exceptions.TenantNotFoundException
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken
import org.springframework.security.core.GrantedAuthority
import org.springframework.security.core.authority.AuthorityUtils
import org.springframework.security.core.context.SecurityContextHolder
import org.springframework.test.annotation.Rollback
import spock.lang.Specification
@Integration
class CurrentUserTenantResolverSpec extends Specification {
UserDataService userDataService
RoleDataService roleDataService
UserRoleDataService userRoleDataService
@Autowired
CurrentUserTenantResolver currentUserTenantResolver
void "Test Current User throws a TenantNotFoundException if not logged in"() {
when:
currentUserTenantResolver.resolveTenantIdentifier()
then:
def e = thrown(TenantNotFoundException)
e.message == "Tenant could not be resolved from Spring Security Principal"
}
void "Test current logged in user is resolved "() {
given:
Role role = roleDataService.saveByAuthority('ROLE_USER')
User user = userDataService.save('admin', 'admin')
userRoleDataService.save(user, role)
when:
loginAs('admin', 'ROLE_USER')
Serializable username = currentUserTenantResolver.resolveTenantIdentifier()
then:
username == user.username
when: "verify AllTenantsResolver::resolveTenantIds"
Iterable<Serializable> tenantIds
demo.User.withNewSession {
tenantIds = currentUserTenantResolver.resolveTenantIds()
}
then:
tenantIds.toList().size() == 1
tenantIds.toList().get(0) == 'admin'
cleanup:
userRoleDataService.delete(user, role)
roleDataService.delete(role)
userDataService.delete(user.id)
}
void loginAs(String username, String authority) {
User user = userDataService.findByUsername(username)
if ( user ) {
// have to be authenticated as an admin to create ACLs
List<GrantedAuthority> authorityList = AuthorityUtils.createAuthorityList(authority)
SecurityContextHolder.context.authentication = new UsernamePasswordAuthenticationToken(user.username,
user.password,
authorityList)
}
}
}
Next were are going to configure our app to use this custom tenant resolver.
Configure Multi-Tenancy by modifying application.yml
.
grails
gorm:
multiTenancy:
mode: DISCRIMINATOR (1)
tenantResolverClass: demo.CurrentUserTenantResolver (2)
1 | Define Multi-Tenancy mode as DISCRIMINATOR |
2 | Set the Tenant Resolver class with a custom class which you will write later in this guide. |
3.7 Functional Test
Create a functional test which verifies that we can consume the API switching users.
package demo
import grails.gorm.multitenancy.Tenants
import grails.testing.mixin.integration.Integration
import grails.testing.spock.OnceBefore
import io.micronaut.http.HttpRequest
import io.micronaut.http.HttpResponse
import io.micronaut.http.HttpStatus
import io.micronaut.http.client.HttpClient
import spock.lang.Shared
import spock.lang.Specification
import spock.lang.IgnoreIf
@IgnoreIf( { System.getenv('TRAVIS') as boolean } )
@Integration
class PlanControllerSpec extends Specification {
PlanDataService planDataService
UserDataService userDataService
UserRoleDataService userRoleDataService
UserService userService
RoleDataService roleDataService
@Shared
HttpClient client
@OnceBefore
void init() {
String baseUrl = "http://localhost:$serverPort"
this.client = HttpClient.create(baseUrl.toURL())
}
String accessToken(String u, String p) {
HttpRequest request = HttpRequest.POST("/api/login", [username: u, password: p])
HttpResponse<Map> resp = client.toBlocking().exchange(request, Map)
if ( resp.status == HttpStatus.OK) {
return resp.body().access_token
}
null
}
def "Plans for current logged user are retrieved"() {
given:
User vector = userService.save('vector', 'secret', 'ROLE_VILLAIN')
User gru = userService.save('gru', 'secret', 'ROLE_VILLAIN')
Tenants.withId("gru") { (1)
planDataService.save('Steal the Moon')
}
Tenants.withId("vector") {
planDataService.save('Steal a Pyramid')
}
when: 'login with the gru'
String gruAccessToken = accessToken('gru', 'secret')
then:
gruAccessToken
when:
HttpRequest request = HttpRequest.GET("/plan").bearerAuth(gruAccessToken)
HttpResponse<String> resp = client.toBlocking().exchange(request, String)
then:
resp.status == HttpStatus.OK
resp.body() == '[{"title":"Steal the Moon"}]'
when: 'login with the vector'
String vectorAccessToken = accessToken('vector', 'secret')
then:
vectorAccessToken
when:
request = HttpRequest.GET("/plan").bearerAuth(vectorAccessToken)
resp = client.toBlocking().exchange(request, String)
then:
resp.status == HttpStatus.OK
resp.body() == '[{"title":"Steal a Pyramid"}]'
cleanup:
Tenants.withId("gru") { (1)
planDataService.deleteByTitle('Steal the Moon')
}
Tenants.withId("vector") {
planDataService.deleteByTitle('Steal the Pyramid')
}
userRoleDataService.deleteByUser(gru)
userDataService.delete(gru.id)
userRoleDataService.deleteByUser(vector)
userDataService.delete(vector.id)
roleDataService.deleteByAuthority('ROLE_VILLAIN')
}
}
1 | You can use a specific tenant id using the withId method |
4 Run the tests
To run the tests:
./grailsw
grails> test-app
grails> open test-report
or
./gradlew check
open build/reports/tests/index.html