diff --git a/README.md b/README.md index d7b1ca3..9aceb47 100755 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ The project is currently in development and is considered experimental at this s The MultiTenant plugin is best to implement when you begin developing your application, but with some work you should be able to adapt an existing application to use the Plugin. -The plugin currently implements the following multi-tenancy architecures (Strategy). +The plugin currently implements the following multi-tenancy architecures (Strategy): ### Domain Strategy @@ -21,6 +21,17 @@ The plugin currently implements the following multi-tenancy architecures (Strate * Single Application Instance * Subdomain per Tenant +### Session Strategy + +* Shared Database, Shared Schema +* Single Application Instance +* Using same domain, the tenant is identified by the session path matched against the model field configured. + +### Configuration Strategy + +* Shared Database, Shared Schema +* Single Application Instance +* Using same domain, the tenant is identified by a key in the CakePHP configuration storage ### Tenants @@ -41,7 +52,9 @@ application. ie signup/register code. #### 'tenant' Context -'tenant' context represents this tenant. When the user is accessing the application at the subdomain, this is +'tenant' context represents this tenant. + +* Domain Strategy: When the user is accessing the application at the subdomain, this is considered the 'tenant' context. #### Custom Contexts @@ -141,9 +154,9 @@ Add the following to the bottom of your application's config\app.php * * ## Options * - * - `strategy` - 'domain' is currently the only implemented strategy - * - `primaryDomain` - The domain for the main application - * value to false, when dealing with older versions of IE, Chrome Frame or certain web-browsing devices and AJAX + * - `strategy` - 'domain', 'session' or 'configuration'. + * - `primaryDomain` - The domain for the main application. All tenant subdomains finish with this. + * - `primarySubdomains` - Subdomains of the primaryDomain considered also as primary, for example, "www". * - `model` - The model that represents the tenant, usually 'Accounts' * - `redirectInactive` - URI to redirect when the tenant is not active or does not exist. This should be a uri at the * primary domain, usually your signup page or feature pitch page with call-to-action signup button. @@ -155,15 +168,18 @@ Add the following to the bottom of your application's config\app.php * */ 'MultiTenant' => [ - 'strategy'=>'domain', - 'primaryDomain'=>'www.example.com', - 'model'=>[ - 'className'=>'Accounts', - 'field'=>'domain', //field of model that holds subdomain/domain tenants - 'conditions'=>['is_active'=>1] //query conditions to match active accounts + 'strategy' => 'domain', + 'primaryDomain' => 'example.com', + 'primarySubdomains => [ + 'www', + ] + 'model' => [ + 'className' => 'Accounts', + 'field' => 'domain', //field of model that holds subdomain/domain tenants + 'conditions' => ['is_active'=>1] //query conditions to match active accounts ], - 'redirectInactive'=>'/register', - 'reservedDomains'=>[ + 'redirectInactive' => '/register', + 'reservedDomains' => [ 'admin', 'superuser', 'system', @@ -172,13 +188,53 @@ Add the following to the bottom of your application's config\app.php 'contextMap' => [ 'admin'=>'admin.example.com' //an example of a custom context ], - 'scopeBehavior'=>[ - 'global_value'=>0, //global records are matched by this value - 'foreign_key_field'=>'account_id' //the foreign key field that associates records to tenant model + 'scopeBehavior' => [ + 'global_value' => 0, //global records are matched by this value + 'foreign_key_field' => 'account_id' //the foreign key field that associates records to tenant model ] ] ``` +Here we have a session strategy configuration. When the users logs in +the account_id is stored in the session. Then it's checked against the +Accounts table (id field) where the account is active. +``` + 'MultiTenant' => [ + 'strategy' => [ + 'session' => [ + 'path' => 'Auth.User.account_id' + ] + ], + 'primaryDomain' => 'www.example.com', + 'model' => [ + 'className' => 'Accounts', + 'field' => 'id', //field of model that holds subdomain/domain tenants + 'conditions' => ['is_active' => 1] //query conditions to match active accounts + ], + 'redirectInactive' => '/register', + 'scopeBehavior' => [ + 'global_value' => 1, //global records are matched by this value + 'foreign_key_field' => 'account_id' //the foreign key field that associates records to tenant model + ] + ] +``` + +Here you can see a *configuration* strategy: +``` + 'MultiTenant' => [ + 'strategy' => [ + 'configuration' => [ + 'tenant_key' => 'account_id' + ] + ], + ... + + // ...then, somewhere in your code, you can change the tenant + // simply writing to the configuration key. + Configure::write('account_id', 34); + ... +``` + Note: don't forget to add the , to the bottom config section when pasting the above configuration. A syntax error in config\app.php is a silent failure (blank page). ## Usage diff --git a/src/Core/MTApp.php b/src/Core/MTApp.php index 51c8cb6..9ea4c44 100755 --- a/src/Core/MTApp.php +++ b/src/Core/MTApp.php @@ -15,129 +15,169 @@ */ namespace MultiTenant\Core; +use Cake\Core\Configure; use Cake\Core\StaticConfigTrait; use Cake\Core\Exception\Exception; +use Cake\Network\Session; +use Cake\ORM\Entity; use Cake\ORM\TableRegistry; +use Cake\Utility\Hash; //TODO Implement Singleton/Caching to eliminate sql query on every call -class MTApp { - - use StaticConfigTrait { - config as public _config; - } - - protected static $_cachedAccounts = []; - - - /** - * find the current context based on domain/subdomain - * - * @return String 'global', 'tenant', 'custom' - * - */ - public static function getContext() { - //get tenant qualifier - $qualifier = self::_getTenantQualifier(); - - if ( $qualifier == '' ) { - return 'global'; - } +class MTApp +{ - return 'tenant'; - } - - /** - * - * - */ - public static function isPrimary() { - //get tenant qualifier - $qualifier = self::_getTenantQualifier(); - - if ( $qualifier == '' ) { - return true; + use StaticConfigTrait { + config as public _config; } - return false; - } - /** - * - * Can be used throughout Application to resolve current tenant - * Returns tenant entity - * - * @returns Cake\ORM\Entity - */ - public static function tenant( ) { - - //if tentant/_findTenant is called at the primary domain the plugin is being used wrong; - if ( self::isPrimary() ) { - throw new Exception('MTApp::tenant() cannot be called from primaryDomain context'); - } + protected static $_cachedAccounts = []; - $tenant = static::_findTenant(); - //Check for inactive/nonexistant domain - if ( !$tenant ) { - self::_redirectInactive(); - } + /** + * find the current context based on domain/subdomain + * + * @return string 'global', 'tenant', 'custom' + * + */ + public static function getContext() + { + //get tenant qualifier + $qualifier = self::_getTenantQualifier(); - return $tenant; + if ($qualifier == '') { + return 'global'; + } - } + return 'tenant'; + } - - protected static function _findTenant() { - - //if tentant/_findTenant is called at the primary domain the plugin is being used wrong; - if ( self::isPrimary() ) { - throw new Exception('MTApp::tenant() cannot be called from primaryDomain context'); + /** + * Checks if the current tenant is primary. + * + * @returns boolean Primary domain indicator + * + */ + public static function isPrimary() + { + // Get tenant qualifier + $qualifier = self::_getTenantQualifier(); + + // The domain is primary if we are not in any subdomain, or if it is a primary subdomain. + return $qualifier == '' || in_array($qualifier, self::config('primarySubdomains')); } - - //get tenant qualifier - $qualifier = self::_getTenantQualifier(); - - //Read entity from cache if it exists - if ( array_key_exists($qualifier, self::$_cachedAccounts)) { - return self::$_cachedAccounts[$qualifier]; + + /** + * + * Can be used throughout Application to resolve current tenant + * Returns tenant entity + * + * @returns Cake\ORM\Entity + */ + public static function tenant() + { + //if tentant/_findTenant is called at the primary domain the plugin is being used wrong; + if (self::isPrimary()) { + throw new Exception('MTApp::tenant() cannot be called from primaryDomain context'); + } + + $tenant = static::_findTenant(); + + //Check for inactive/nonexistant domain + if (!$tenant) { + self::_redirectInactive(); + } + + return $tenant; } - //load model - $modelConf= self::config('model'); - $tbl = TableRegistry::get( $modelConf['className'] ); - $conditions = array_merge([$modelConf['field']=>$qualifier], $modelConf['conditions']); - //Query model and store in cache - self::$_cachedAccounts[$qualifier] = $tbl->find('all', ['skipTenantCheck' => true])->where($conditions)->first(); + /** + * Return the current tenant data. + * + * @return Entity Current tenant entity. + */ + protected static function _findTenant() + { + // if tentant/_findTenant is called at the primary domain the plugin is being used wrong; + if (self::isPrimary()) { + throw new Exception('MTApp::tenant() cannot be called from primaryDomain context'); + } + + //get tenant qualifier + $qualifier = self::_getTenantQualifier(); + + //Read entity from cache if it exists + if (array_key_exists($qualifier, self::$_cachedAccounts)) { + return self::$_cachedAccounts[$qualifier]; + } - return self::$_cachedAccounts[$qualifier]; - - } + //load model + $modelConf = self::config('model'); + $tbl = TableRegistry::get($modelConf['className']); + $conditions = array_merge([$modelConf['field'] => $qualifier], $modelConf['conditions']); - protected static function _redirectInactive() { - - $uri = self::config('redirectInactive'); + //Query model and store in cache + self::$_cachedAccounts[$qualifier] = $tbl->find('all', ['skipTenantCheck' => true])->where($conditions)->first(); - if(strpos($uri, 'http') !== false) { - $full_uri = $uri; - } else { - $full_uri = env('REQUEST_SCHEME') .'://' . self::config('primaryDomain') . $uri; + return self::$_cachedAccounts[$qualifier]; } - - header( 'Location: ' . $full_uri ); - exit; - - } - protected static function _getTenantQualifier() { - //for domain this is the SERVER_NAME from $_SERVER - if ( self::config('strategy') == 'domain' ) { - - // check if tenant is available and server name valid - if (substr_count(env('SERVER_NAME'), self::config('primaryDomain')) > 0 && substr_count(env('SERVER_NAME'), '.') > 1) { - return str_replace('.' . self::config('primaryDomain'), '', env('SERVER_NAME')); - } else { - return ''; - } + + + /** + * Redirects inactive tenants to the same URI withing the primary domain. + */ + protected static function _redirectInactive() + { + $uri = self::config('redirectInactive'); + + if (strpos($uri, 'http') !== false) { + $fullUri = $uri; + } else { + $fullUri = env('REQUEST_SCHEME') . '://' . self::config('primaryDomain') . $uri; + } + + header('Location: ' . $fullUri); + exit; } - } + + /** + * Returns the current tenant qualifier depending on the strategy configured. + * + * - 'domain': will extract the tenant from the subdomain where the request + * comes from. + * + * - 'session': will take the tenant identifier from the current session using + * the path configured in the configuration. + * + * @return string Tenant qualifier + */ + protected static function _getTenantQualifier() + { + $tenant = ''; + + $strategyConfig = self::config('strategy'); + $strategy = is_array($strategyConfig) ? + key($strategyConfig) : + $strategyConfig; + switch ($strategy) { + case 'domain': + // check if tenant is available and server name valid + if (substr_count(env('SERVER_NAME'), self::config('primaryDomain')) > 0 && + substr_count(env('SERVER_NAME'), '.') > 1 + ) { + $tenant = str_replace('.' . self::config('primaryDomain'), '', env('SERVER_NAME')); + } + break; + case 'session': + $tenant = Hash::get($_SESSION, Hash::get($strategyConfig, 'session.path')); + break; + case 'configuration': + $tenant = Configure::read(Hash::get($strategyConfig, 'configuration.tenant_key')); + break; + } + + return $tenant; + } }