diff --git a/MultiTenantCoreGrailsPlugin.groovy b/MultiTenantCoreGrailsPlugin.groovy index 18653c9..f6b7462 100644 --- a/MultiTenantCoreGrailsPlugin.groovy +++ b/MultiTenantCoreGrailsPlugin.groovy @@ -27,8 +27,11 @@ import grails.plugin.multitenant.core.DomainNameDatabaseTenantResolver import grails.plugin.multitenant.core.DomainNamePropertyTenantResolver import grails.plugin.multitenant.core.CurrentTenantThreadLocal +import grails.plugin.multitenant.CurrentTenantWithMasterMode +import grails.plugin.multitenant.TenantEventHandlerWithMasterMode + class MultiTenantCoreGrailsPlugin { - def version = "1.0.0" + def version = "1.0.1" def grailsVersion = "1.3.0 > *" def dependsOn = [falconeUtil: "1.0"] def author = "Eric Martineau, Scott Ryan" @@ -62,14 +65,21 @@ class MultiTenantCoreGrailsPlugin { } } else { - + //This registers hibernate events that force filtering on domain classes //In single tenant mode, the records are automatically filtered by different //data sources. - tenantEventHandler(TenantEventHandler) { - sessionFactory = ref("sessionFactory") - currentTenant = ref("currentTenant") - } + if(ConfigurationHolder.config.tenant.withMasterMode){ + tenantEventHandler(TenantEventHandlerWithMasterMode) { + sessionFactory = ref("sessionFactory") + currentTenant = ref("currentTenant") + } + }else { + tenantEventHandler(TenantEventHandler) { + sessionFactory = ref("sessionFactory") + currentTenant = ref("currentTenant") + } + } } //Bean container for all multi-tenant beans tenantBeanContainer(TenantBeanContainer) { @@ -82,9 +92,15 @@ class MultiTenantCoreGrailsPlugin { def resolverType = ConfigHelper.get("request") {it.tenant.resolver.type} if (resolverType == "request") { //This implementation - currentTenant(CurrentTenantThreadLocal) { - eventBroker = ref("eventBroker") - } + if(ConfigurationHolder.config.tenant.withMasterMode){ + currentTenant(CurrentTenantWithMasterMode) { + eventBroker = ref("eventBroker") + } + } else { + currentTenant(CurrentTenantThreadLocal) { + eventBroker = ref("eventBroker") + } + } def requestResolverType = ConfigHelper.get("config") {it.tenant.resolver.request.dns.type} if (requestResolverType == "config") { @@ -110,16 +126,33 @@ class MultiTenantCoreGrailsPlugin { //Listen for criteria created events hibernate.criteriaCreated("tenantFilter") { CriteriaContext context -> + + if(ConfigurationHolder.config.tenant.withMasterMode && ctx.currentTenant.isMasterMode()){ + return + } + boolean hasAnnotation = TenantUtils.isAnnotated(context.entityClass) - if (context.entityClass == null || hasAnnotation) { + boolean hasSharedAnnotation = TenantUtils.isAnnotatedAsShared(context.entityClass) + if (context.entityClass == null || (hasAnnotation || hasSharedAnnotation)) { final Integer tenant = ctx.currentTenant.get(); - context.criteria.add(Expression.eq("tenantId", tenant)); + if(hasAnnotation){ + context.criteria.add(Expression.eq("tenantId", tenant)); + } else if(hasSharedAnnotation){ + if(!context.criteria.iterateSubcriteria().toList().find{it.path == "tenants"}){ + context.criteria.createCriteria("tenants").add(Expression.eq("tenantId", tenant)) + } + } } } //Listen for query created events - hibernate.queryCreated("tenantFilter") { - Query query -> + hibernate.queryCreated("tenantFilter") { Query query -> + + if(ConfigurationHolder.config.tenant.withMasterMode && ctx.currentTenant.isMasterMode()){ + return + } + + for (String param: query.getNamedParameters()) { if ("tenantId".equals(param)) { query.setParameter("tenantId", ctx.currentTenant.get(), new IntegerType()); @@ -194,12 +227,15 @@ class MultiTenantCoreGrailsPlugin { def doWithDynamicMethods = {ctx -> if (ConfigurationHolder.config.tenant.mode != "singleTenant") { + //Add a nullable contraint for tenantId. application.domainClasses.each {DefaultGrailsDomainClass domainClass -> - domainClass.constraints?.get("tenantId")?.applyConstraint(ConstrainedProperty.NULLABLE_CONSTRAINT, true); - domainClass.clazz.metaClass.beforeInsert = { - if (tenantId == null) tenantId = 0 - } + if(domainClass.clazz.metaClass.properties.find {p -> p.name == "tenantId"}){ + domainClass.constraints?.get("tenantId")?.applyConstraint(ConstrainedProperty.NULLABLE_CONSTRAINT, true); + domainClass.clazz.metaClass.beforeInsert = { + if (tenantId == null) tenantId = 0 + } + } } } diff --git a/grails-app/domain/grails/plugin/multitenant/TenantId.groovy b/grails-app/domain/grails/plugin/multitenant/TenantId.groovy new file mode 100644 index 0000000..5932c5c --- /dev/null +++ b/grails-app/domain/grails/plugin/multitenant/TenantId.groovy @@ -0,0 +1,9 @@ +package grails.plugin.multitenant + +class TenantId { + + Integer tenantId + + static constraints = { + } +} diff --git a/src/groovy/grails/plugin/multitenant/CurrentTenantWithMasterMode.groovy b/src/groovy/grails/plugin/multitenant/CurrentTenantWithMasterMode.groovy new file mode 100644 index 0000000..b60d1c3 --- /dev/null +++ b/src/groovy/grails/plugin/multitenant/CurrentTenantWithMasterMode.groovy @@ -0,0 +1,20 @@ +package grails.plugin.multitenant + +import grails.plugin.multitenant.core.CurrentTenantThreadLocal + +class CurrentTenantWithMasterMode extends CurrentTenantThreadLocal { + + static ThreadLocal masterMode = new ThreadLocal() + + def isMasterMode(){ + if(masterMode.get() == null){ + masterMode.set(false) + } + masterMode.get() + } + + def setMasterMode(gm) { + masterMode.set(gm as Boolean) + } + +} \ No newline at end of file diff --git a/src/groovy/grails/plugin/multitenant/TenantEventHandlerWithMasterMode.groovy b/src/groovy/grails/plugin/multitenant/TenantEventHandlerWithMasterMode.groovy new file mode 100644 index 0000000..b2dad3a --- /dev/null +++ b/src/groovy/grails/plugin/multitenant/TenantEventHandlerWithMasterMode.groovy @@ -0,0 +1,149 @@ +package grails.plugin.multitenant + +import grails.plugin.multitenant.core.hibernate.TenantEventHandler +import org.hibernate.event.* +import org.hibernate.event.LoadEventListener.LoadType +import org.hibernate.tuple.StandardProperty + +import org.hibernate.SessionFactory +import util.hibernate.HibernateEventUtil +import org.hibernate.event.InitializeCollectionEventListener +import grails.plugin.multitenant.TenantId +import grails.plugin.multitenant.core.util.TenantUtils + +import org.codehaus.groovy.grails.commons.ConfigurationHolder + +class TenantEventHandlerWithMasterMode extends TenantEventHandler{ + + + private Map reflectedCache = [:] + + public TenantEventHandlerWithMasterMode() { + } + + public boolean onPreInsert(PreInsertEvent preInsertEvent) { + def shouldFail = false + boolean hasAnnotation = TenantUtils.isAnnotated(preInsertEvent.getEntity().getClass()) + boolean hasSharedAnnotation = false + if (ConfigurationHolder.config.tenant.mode != "singleTenant") { + hasSharedAnnotation = TenantUtils.isAnnotatedAsShared(preInsertEvent.getEntity().getClass()) + } + + def setTenantId + + def findTenantIdIndex = { where -> + int result = -1 + for(def property in where) { + result++ + if (property.getName() == "tenantId") { + break + } + } + result + } + + if(hasAnnotation){ + setTenantId = preInsertEvent.getEntity().tenantId + + if (setTenantId == 0 || setTenantId == null) { + preInsertEvent.getEntity().tenantId = currentTenant.get() + StandardProperty[] properties = preInsertEvent.getPersister().getEntityMetamodel().getProperties() + int tenandIdIndex = findTenantIdIndex(properties) + if (tenandIdIndex > -1) { + preInsertEvent.getState()[tenandIdIndex] = currentTenant.get() + } + } else { + if (setTenantId != currentTenant.get()) { + shouldFail = true + return shouldFail + } + } + } else if(hasSharedAnnotation){ + setTenantId = preInsertEvent.getEntity().tenants*.tenantId + if(!setTenantId){ + def tenantToAdd = TenantId.findByTenantId(currentTenant.get()) ?: new TenantId(tenantId: currentTenant.get()) + preInsertEvent.getEntity().addToTenants(tenantToAdd) + + } else { + if (!(currentTenant.get() in setTenantId)) { + shouldFail = true + return shouldFail + } + } + } + return shouldFail; + } + + + + public void onLoad(LoadEvent event, LoadType loadType) { + if (ConfigurationHolder.config.tenant.withMasterMode && !currentTenant.isMasterMode() && annotated(event.getEntityClassName())) { + Object result = event.getResult() + if (result != null) { + int currentTenant = currentTenant.get() + def violates = attemptingTenantViolation(event.getEntityClassName(), result, currentTenant) + if (violates && !event.isAssociationFetch()) { + println "Trying to load record from a different app (should be ${currentTenant} but was ...)" + event.setResult null + } else if(!event.isAssociationFetch()){ + event.setResult result + } + } + } + } + + + /** + * Checks before deleting a record that the record is for the current tenant. THrows an exception otherwise + */ + public boolean onPreDelete(PreDeleteEvent event) { + boolean shouldFail = attemptingTenantViolation(event.getEntity().getClass().getName(), event.getEntity(), currentTenant.get()) + if(shouldFail){ + println "Failed Delete Because TenantId Doesn't Match" + } + return shouldFail; + } + + public boolean onPreUpdate(PreUpdateEvent preUpdateEvent) { + boolean shouldFail = attemptingTenantViolation(preUpdateEvent.getEntity().getClass().getName(), preUpdateEvent.getEntity(), currentTenant.get()) + if(shouldFail){ + println "Failed Update Because TenantId Doesn't Match" + } + return shouldFail; + } + + + private Class getClassFromName(String className) { + if (!reflectedCache.containsKey(className)) { + Class aClass = this.class.classLoader.loadClass("${className}") + reflectedCache.put(className, aClass) + } + return reflectedCache.get(className) + } + + private annotated(entityClassName){ + boolean hasAnnotation = TenantUtils.isAnnotated(getClassFromName(entityClassName)) + boolean hasSharedAnnotation = false + if (ConfigurationHolder.config.tenant.mode != "singleTenant") { + hasSharedAnnotation = TenantUtils.isAnnotatedAsShared(getClassFromName(entityClassName)) + } + + hasAnnotation || hasSharedAnnotation + } + + private attemptingTenantViolation(entityClassName, entity, currentTenantId){ + if (ConfigurationHolder.config.tenant.withMasterMode && !currentTenant.isMasterMode() && annotated(entityClassName)) { + def loaded + def hasAnnotation = TenantUtils.isAnnotated(getClassFromName(entityClassName)) + if(hasAnnotation){ + loaded = [entity.tenantId] + } else { + loaded = entity.tenants*.tenantId + } + return !(currentTenantId in loaded) + } + false + } + + +} \ No newline at end of file diff --git a/src/java/grails/plugin/multitenant/core/groovy/compiler/MultiTenantShared.java b/src/java/grails/plugin/multitenant/core/groovy/compiler/MultiTenantShared.java new file mode 100644 index 0000000..6889204 --- /dev/null +++ b/src/java/grails/plugin/multitenant/core/groovy/compiler/MultiTenantShared.java @@ -0,0 +1,18 @@ +package grails.plugin.multitenant.core.groovy.compiler; + +import org.codehaus.groovy.transform.GroovyASTTransformationClass; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Target; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Retention; + +/** + * Annotation used to mark domain classes that should be converted to multi-tenant. + * + * Currently, this annotation will add a tenantId property to the class. + */ +@Retention(RetentionPolicy.RUNTIME) +@Target({ElementType.TYPE}) +@GroovyASTTransformationClass("grails.plugin.multitenant.core.groovy.compiler.SharedTenantASTTransformation") +public @interface MultiTenantShared {} diff --git a/src/java/grails/plugin/multitenant/core/groovy/compiler/SharedTenantASTTransformation.java b/src/java/grails/plugin/multitenant/core/groovy/compiler/SharedTenantASTTransformation.java new file mode 100644 index 0000000..23b4c10 --- /dev/null +++ b/src/java/grails/plugin/multitenant/core/groovy/compiler/SharedTenantASTTransformation.java @@ -0,0 +1,66 @@ +package grails.plugin.multitenant.core.groovy.compiler; + +import org.codehaus.groovy.transform.ASTTransformation; +import org.codehaus.groovy.transform.GroovyASTTransformation; +import org.codehaus.groovy.control.CompilePhase; +import org.codehaus.groovy.control.SourceUnit; +import org.codehaus.groovy.ast.ASTNode; +import org.codehaus.groovy.ast.ClassNode; +import org.codehaus.groovy.ast.expr.ConstantExpression; +import org.codehaus.groovy.grails.compiler.injection.GrailsASTUtils; +import org.apache.commons.logging.LogFactory; +import org.apache.commons.logging.Log; +import org.codehaus.groovy.ast.expr.ArrayExpression; +import org.codehaus.groovy.ast.expr.Expression; +import org.codehaus.groovy.ast.expr.MapExpression; +import org.codehaus.groovy.ast.expr.MapEntryExpression; +import org.codehaus.groovy.ast.expr.ClassExpression; +import org.codehaus.groovy.ast.PropertyNode; + +import java.util.List; +import java.util.ArrayList; +import java.util.Map; +import java.util.Set; + +import java.lang.reflect.Modifier; + +import grails.plugin.multitenant.TenantId; + +/** + * Performs an ast transformation on a class - adds a tenantId property to the subject class. + */ +@GroovyASTTransformation(phase = CompilePhase.CANONICALIZATION ) +public class SharedTenantASTTransformation implements ASTTransformation { +// ======================================================================================================================== +// Static Fields +// ======================================================================================================================== + + private static final Log LOG = LogFactory.getLog(TenantASTTransformation.class); + private static final String key = "tenants"; + +// ======================================================================================================================== +// Public Instance Methods +// ======================================================================================================================== + + public void visit(ASTNode[] astNodes, SourceUnit sourceUnit) { + for (ASTNode astNode : astNodes) { + if (astNode instanceof ClassNode) { + ClassNode classNode = (ClassNode) astNode; + final boolean hasTenants = GrailsASTUtils.hasProperty(classNode, key); + final boolean hasHasMany = GrailsASTUtils.hasProperty(classNode, "hasMany"); + if(!hasTenants){ + if(!hasHasMany){ + MapExpression me = new MapExpression(); + me.addMapEntryExpression(new ConstantExpression(key), new ClassExpression(new ClassNode(TenantId.class))); + classNode.addProperty("hasMany", Modifier.PUBLIC | Modifier.STATIC, new ClassNode(java.util.Map.class), me, null, null); + } else { + PropertyNode hm = classNode.getProperty("hasMany"); + MapExpression me = (MapExpression)hm.getInitialExpression(); + me.addMapEntryExpression(new ConstantExpression(key), new ClassExpression(new ClassNode(TenantId.class))); + } + classNode.addProperty(new PropertyNode(key, Modifier.PUBLIC, new ClassNode(Set.class), classNode, null, null, null)); + } + } + } + } +} diff --git a/src/java/grails/plugin/multitenant/core/util/TenantUtils.java b/src/java/grails/plugin/multitenant/core/util/TenantUtils.java index ac6ec71..a94b172 100644 --- a/src/java/grails/plugin/multitenant/core/util/TenantUtils.java +++ b/src/java/grails/plugin/multitenant/core/util/TenantUtils.java @@ -2,6 +2,7 @@ import grails.plugin.multitenant.core.CurrentTenant; import grails.plugin.multitenant.core.groovy.compiler.MultiTenant; +import grails.plugin.multitenant.core.groovy.compiler.MultiTenantShared; import groovy.lang.Closure; import java.lang.annotation.Annotation; @@ -76,5 +77,27 @@ public static boolean isAnnotated(Class aClass) { } return hasAnnotation; } + + /** + * Whether or not a particular class is annotated as MultiTenant and has an ability to be hared across multiple tenants (multiTenant DB mode only) + * + * @param aClass + * @return + */ + + public static boolean isAnnotatedAsShared(Class aClass) { + boolean hasAnnotation = false; + if (aClass != null) { + Annotation[] annotations = aClass.getAnnotations(); + for (Annotation annotation : annotations) { + if ((annotation instanceof MultiTenantShared)) { + hasAnnotation = true; + break; + } + } + } + return hasAnnotation; + } + }