In this post I will demonstrate how to configure Spring security with a per tenant user database.
In order to make it work we will need:
- Custom UserDetailsService
- Rewrite the UserPasswordEncoderListener
- Initialize per tenant beans
First lets create the application and add security.
# Open a terminal, cd into a desired base directory
grails create-app ssc-multitenant-test1 --profile web
Import the new project into IDEA.
Edit build.gradle:
dependencies {
.. add ssc
compile 'org.grails.plugins:spring-security-core:4.0.0'
..
}
Initialize spring security core from the terminal:
cd ssc-multitenant-test1
grails s2-quickstart no.prpr.security User Role
Make all domains MultiTenant, by adding MultiTenant<domain>. Below you’ll find the updated first line of the domain classes.
class Role implements Serializable, MultiTenant<Role> {
class User implements Serializable, MultiTenant<User> {
class UserRole implements Serializable, MultiTenant<UserRole> {
Configure application for multitenancy
This application will be set up with one datasource for one tenant. No default datasource will be used.
Edit application.yml and add multiTenancy. For this purpose we will use the SystemPropertyTenantResolver:
grails:
gorm:
multiTenancy:
mode: DATABASE
tenantResolverClass: org.grails.datastore.mapping.multitenancy.resolvers.SystemPropertyTenantResolver
Configure datasources. Remove all the default datasource and replace with:
dataSources:
abc:
dbCreate: create-drop
driverClassName: org.h2.Driver
username: sa
password: ''
url: jdbc:h2:mem:devDb;MVCC=TRUE;LOCK_TIMEOUT=10000;DB_CLOSE_ON_EXIT=FALSE
Create a custom UserDetailsService
Create src/main/groovy/no.prpr.security.PerTenantUserDetailsService.groovy
package no.prpr.security
import grails.gorm.multitenancy.CurrentTenant
import grails.gorm.transactions.Transactional
import grails.plugin.springsecurity.SpringSecurityUtils
import grails.plugin.springsecurity.userdetails.GrailsUser
import grails.plugin.springsecurity.userdetails.GrailsUserDetailsService
import grails.plugin.springsecurity.userdetails.NoStackUsernameNotFoundException
import groovy.util.logging.Slf4j
import org.springframework.dao.DataAccessException
import org.springframework.security.core.authority.SimpleGrantedAuthority
import org.springframework.security.core.userdetails.UserDetails
import org.springframework.security.core.userdetails.UsernameNotFoundException
@Slf4j
@CurrentTenant
class PerTenantUserDetailsService implements GrailsUserDetailsService {
static final List NO_ROLES = [new SimpleGrantedAuthority(SpringSecurityUtils.NO_ROLE)]
@Override
UserDetails loadUserByUsername(String username, boolean loadRoles) throws UsernameNotFoundException, DataAccessException {
loadUserByUsername(username)
}
@Transactional(readOnly = true, noRollbackFor = [IllegalArgumentException, UsernameNotFoundException])
@Override
UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
log.debug("username: ${username}")
User user = User.findByUsername(username)
if (!user) throw new NoStackUsernameNotFoundException()
def roles = user.getAuthorities()
def authorities = roles.collect {
new SimpleGrantedAuthority(it.authority)
}
new GrailsUser(user.username, user.password, user.enabled,
!user.accountExpired, !user.passwordExpired,
!user.accountLocked, authorities ?: NO_ROLES, user.id)
}
}
Update resources.groovy – remove the UserPasswordEncoder and add the PerTenantUserDetailsService. It will be added during BootStrap.
import no.prpr.security.PerTenantUserDetailsService
// Place your Spring DSL code here
beans = {
userDetailsService(PerTenantUserDetailsService)
}
Rewrite src/main/groovy/no.prpr.security/UserPasswordEncoderListener.groovy:
package no.prpr.security
import grails.core.GrailsApplication
import grails.plugin.springsecurity.SpringSecurityService
import groovy.transform.CompileStatic
import groovy.util.logging.Slf4j
import org.grails.datastore.mapping.core.Datastore
import org.grails.datastore.mapping.engine.event.AbstractPersistenceEvent
import org.grails.datastore.mapping.engine.event.AbstractPersistenceEventListener
import org.grails.datastore.mapping.engine.event.PreInsertEvent
import org.grails.datastore.mapping.engine.event.PreUpdateEvent
import org.springframework.context.ApplicationEvent
@CompileStatic
@Slf4j
class UserPasswordEncoderListener extends AbstractPersistenceEventListener {
SpringSecurityService springSecurityService
protected UserPasswordEncoderListener(Datastore datastore) {
super(datastore)
}
private void encodePasswordForEvent(AbstractPersistenceEvent event) {
log.debug('encodePasswordForEvent')
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) {
manuallyWireServices()
String result = springSecurityService?.passwordEncoder ? springSecurityService.encodePassword(password) : password
log.debug("encodePassword: ${password} -> ${result}")
result
}
@Override
protected void onPersistenceEvent(AbstractPersistenceEvent event) {
log.debug("Source: ${event.source}. Datastore: ${this.datastore}. Event: ${event}")
encodePasswordForEvent(event)
}
@Override
boolean supportsEventType(Class<? extends ApplicationEvent> eventType) {
boolean supportsEvent = eventType.isAssignableFrom(PreInsertEvent) ||
eventType.isAssignableFrom(PreUpdateEvent)
log.debug("${eventType}: ${supportsEvent}")
return supportsEvent
}
void manuallyWireServices() {
if (springSecurityService) {
return
}
GrailsApplication grailsApplication = grails.util.Holders.grailsApplication
if (!springSecurityService) {
springSecurityService = (SpringSecurityService) grailsApplication.mainContext.getBean('springSecurityService')
log.info "getBean authUserService: ${springSecurityService}"
}
}
}
Make the log more verbose, edit logback.groovy:
// at the end - replace root(ERROR with root(INFO
root(INFO, ['STDOUT'])
// add more logging for our own code.
logger('no.prpr', DEBUG, ['STDOUT'], false)
Add initialization of listener, users and roles to BootStrap:
package ssc.multitenant.test1
import grails.core.GrailsApplication
import groovy.util.logging.Slf4j
import no.prpr.security.Role
import no.prpr.security.User
import no.prpr.security.UserPasswordEncoderListener
import no.prpr.security.UserRole
import org.grails.datastore.mapping.multitenancy.resolvers.SystemPropertyTenantResolver
import org.grails.orm.hibernate.HibernateDatastore
import static grails.gorm.multitenancy.Tenants.withId
@Slf4j
class BootStrap {
GrailsApplication grailsApplication
HibernateDatastore hibernateDatastore
def init = { servletContext ->
def ctx = grailsApplication.mainContext
['abc'].each { String tenantName ->
// create a listener for each tenant
HibernateDatastore ds = hibernateDatastore.getDatastoreForConnection(tenantName)
UserPasswordEncoderListener listener = new UserPasswordEncoderListener(ds)
ctx.addApplicationListener(listener)
// add users and roles for each tenant
withId(tenantName) {
def adminRole = Role.findByAuthority('ROLE_ADMIN')
if (!adminRole) {
log.info("Adding role ROLE_ADMIN")
Role.withNewTransaction {
adminRole = new Role(authority: 'ROLE_ADMIN')
adminRole.save()
}
}
def adminUser = User.findByUsername('admin')
if (!adminUser) {
log.info("Adding user admin")
User.withNewTransaction {
adminUser = new User(username: 'admin', password: 'password')
adminUser.save()
}
}
def adminUserRole = UserRole.get(adminUser.id, adminRole.id)
if (!adminUserRole) {
log.info("adding userrole")
UserRole.withNewTransaction {
adminUserRole = new UserRole(user: adminUser, role: adminRole)
adminUserRole.save()
}
}
}
}
System.setProperty(SystemPropertyTenantResolver.PROPERTY_NAME, 'abc')
}
def destroy = {
}
}
You should now be able to log in using the ‘abc’ tenant datasource.
Add User UI with:
grails generate-all no.prpr.security.User
Make the LogoutController work by appending to application.groovy:
....
grails.plugin.springsecurity.logout.postOnly = false
And then add @CurrentTenant and @Secured on UserController:
@Secured('ROLE_ADMIN')
@CurrentTenant
class UserController {
...
And modify UserService for multitenancy:
@CurrentTenant
@Service(User)
interface UserService {
Here’s the application: https://johnny.prpr.no/wp-content/uploads/2020/05/ssc-multitenant-test1.zip