Repository: romegasoftware/Multitenancy Branch: master Commit: f2c706255cd1 Files: 39 Total size: 71.7 KB Directory structure: gitextract_nf3oca15/ ├── .gitignore ├── LICENSE ├── README.md ├── composer.json ├── config/ │ └── multitenancy.php ├── migrations/ │ └── create_tenants_table.php.stub ├── phpunit.xml ├── src/ │ ├── Commands/ │ │ ├── AssignAdminPrivileges.php │ │ ├── InstallCommand.php │ │ ├── MigrationMakeCommand.php │ │ └── stubs/ │ │ └── add_tenancy_to_table.stub │ ├── Contracts/ │ │ └── Tenant.php │ ├── Exceptions/ │ │ ├── TenantDoesNotExist.php │ │ └── UnauthorizedException.php │ ├── Middleware/ │ │ ├── GuestTenantMiddleware.php │ │ └── TenantMiddleware.php │ ├── Models/ │ │ └── Tenant.php │ ├── Multitenancy.php │ ├── MultitenancyFacade.php │ ├── MultitenancyServiceProvider.php │ └── Traits/ │ ├── BelongsToTenant.php │ └── HasTenants.php └── tests/ ├── Databases/ │ └── MigrateDatabaseTest.php ├── Feature/ │ ├── BelongsToTenantTest.php │ ├── Commands/ │ │ ├── AssignAdminPrivilegesTest.php │ │ ├── InstallCommandTest.php │ │ └── MigrationMakeCommandTest.php │ ├── GateTest.php │ ├── HasTenantTest.php │ ├── Middleware/ │ │ ├── GuestMiddlewareTest.php │ │ └── TenantMiddlewareTest.php │ ├── MultitenancyTest.php │ └── TenantTest.php ├── Fixtures/ │ ├── Controllers/ │ │ ├── ProductController.php │ │ └── UserController.php │ ├── Policies/ │ │ └── ProductPolicy.php │ ├── Product.php │ └── User.php └── TestCase.php ================================================ FILE CONTENTS ================================================ ================================================ FILE: .gitignore ================================================ .DS_Store /vendor .phpunit.result.cache ================================================ FILE: LICENSE ================================================ MIT License Copyright (c) 2019 Romega Digital Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================ # Multitenancy Laravel Package [![Total Downloads](https://img.shields.io/packagist/dt/romegadigital/multitenancy.svg?style=flat-square)](https://packagist.org/packages/romegadigital/multitenancy) This package provides a convenient way to add multitenancy to your Laravel application. It manages models and relationships for Tenants, identifies incoming traffic by subdomain, and associates it with a corresponding tenant. Users not linked with a specific subdomain or without a matching tenant in the Tenant table are presented with a 403 error. **Note:** Any resources saved while accessing a scoped subdomain will automatically be saved against the current tenant, based on subdomain. **Note:** The `admin` subdomain is reserved for the package to remove all scopes from users with a `Super Administrator` role. ## Table of Contents - [Installation](#installation) - [Usage](#usage) - [Console Commands](#console-commands) - [Nova Management](#managing-with-nova) - [Testing](#testing-package) ## Installation #### 1. Use composer to install the package: ``` bash composer require romegadigital/multitenancy ``` In Laravel 5.5 and newer, the service provider gets registered automatically. For older versions, add the service provider in the `config/app.php` file: ```php 'providers' => [ // ... RomegaDigital\Multitenancy\MultitenancyServiceProvider::class, ]; ``` #### 2. Publish the config file ```bash php artisan vendor:publish --provider="RomegaDigital\Multitenancy\MultitenancyServiceProvider" --tag="config" ``` #### 3. Run the setup ```bash php artisan multitenancy:install ``` This command will: - Publish and migrate required migrations - Add a `Super Administrator` role and `access admin` permission - Create an `admin` Tenant model #### 4. Update your `.env` file The package needs to know your base URL so it can determine what constitutes a tenant by the subdomain. Add this to your `.env` file: `MULTITENANCY_BASE_URL=` #### 5. Update your User model Apply the `RomegaDigital\Multitenancy\Traits\HasTenants` and `Spatie\Permission\Traits\HasRoles` traits to your User model(s): ```php use Spatie\Permission\Traits\HasRoles; use RomegaDigital\Multitenancy\Traits\HasTenants; use Illuminate\Foundation\Auth\User as Authenticatable; class User extends Authenticatable { use HasTenants, HasRoles; // ... } ``` ## Usage Tenants require a name to identify the tenant and a subdomain that is associated with that user. Example: `tenant1.example.com` `tenant2.example.com` **Note:** You define the base url `example.com` in the `config/multitenancy.php` file. These Tenants could be added to the database like so: ```php Tenant::create([ 'name' => 'An Identifying Name', 'domain' => 'tenant1' ]); Tenant::create([ 'name' => 'A Second Customer', 'domain' => 'tenant2' ]); ``` You can then attach user models to the Tenant: ```php $user = User::first(); Tenant::first()->users()->save($user); ``` Create Tenants, associate them with Users, and define access rules using provided Middleware. Check [the detailed usage guide](#detailed-usage-guide) for examples. ## Detailed Usage Guide ### 1. **Models and relationships:** Use Eloquent to access User's tenants (`User::tenants()->get()`) and Tenant's users (`Tenant::users()->get()`). Add new tenants and their associated users to the database. ### 2. **Middleware:** Add `TenantMiddleware` and `GuestTenantMiddleware` to your `app/Http/Kernel.php` file and apply them to routes. #### Tenant Middleware ```php protected $middlewareAliases = [ // ... 'tenant.auth' => \RomegaDigital\Multitenancy\Middleware\TenantMiddleware::class, ]; ``` Then you can bring multitenancy to your routes using middleware rules: ```php Route::group(['middleware' => ['tenant.auth']], function () { // ... }); ``` #### Guest Tenant Middleware This package comes with `GuestTenantMiddleware` middleware which applies the tenant scope to all models and can be used for allowing guest users to access Tenant related pages. You can add it inside your `app/Http/Kernel.php` file. ```php protected $middlewareAliases = [ // ... 'tenant.guest' => \RomegaDigital\Multitenancy\Middleware\GuestTenantMiddleware::class, ]; ``` Then you can bring multitenancy to your routes using middleware rules: ```php Route::group(['middleware' => ['tenant.guest']], function () { // ... }); ``` ### 3. **Tenant Assignment for Models:** Make models tenant-aware by adding a trait and migration. Then apply tenant scoping automatically. This allows users to access `tenant1.example.com` and return the data from `tenant1` only. For example, say you wanted Tenants to manage their own `Product`. In your `Product` model, add the `BelongsToTenant` trait. Then run the provided console command to add the necessary relationship column to your existing `products` table. ```php use Illuminate\Database\Eloquent\Model; use RomegaDigital\Multitenancy\Traits\BelongsToTenant; class Product extends Model { use BelongsToTenant; // ... } ``` **Add tenancy to a model's table:** `php artisan multitenancy:migration products` ### 4. **Access to Current Tenant:** Use `app('multitenancy')->currentTenant()` to get the current tenant model. ### 5. **Admin Domain Access:** Assign the `Super Administrator` role to a user to enable access to the `admin` subdomain. Manually create an admin portal if necessary. ### 6. **Auto-assign Users to Tenants:** Enable `ignore_tenant_on_user_creation` setting to automatically assign users to the Tenant subdomain on which they are created. ### 7. **Give a user `Super Administration` rights:** In order to access the `admin.example.com` subdomain, a user will need the `access admin` permission. This package relies on [Spatie's Laravel Permission](https://github.com/spatie/laravel-permission) package and is automatically included as a dependency when installing this package. We also provide a `Super Administrator` role on migration that has the relevant permission already associated with it. Assign the `Super Administrator` role to an admin user to provide the access they need. See the [Laravel Permission](https://github.com/spatie/laravel-permission) documentation for more on adding users to the appropriate role and permission. The Super Administrator is a special user role with privileged access. Users with this role can access all model resources, navigate across different tenants' domains, and gain entry to the `admin` subdomain where all tenant scopes are disabled. When a user is granted the `Super Administrator` role, they can freely access the `admin` subdomain. In this context, tenant scopes aren't applied. This privilege allows Super Administrators to manage data across all instances without requiring specific access to each individual tenant's account. Give a user `Super Administration` rights: `php artisan multitenancy:super-admin admin@example.com` ## Managing with Nova You can manage the resources of this package in Nova with the [MultitenancyNovaTool](https://github.com/romegadigital/MultitenancyNovaTool). ## Testing Package Run tests with the command: `php vendor/bin/testbench package:test` ================================================ FILE: composer.json ================================================ { "name": "romegadigital/multitenancy", "description": "Adds domain based multitenancy to Laravel applications.", "license": "MIT", "authors": [ { "name": "Braden Keith", "email": "bkeith@romegadigital.com" } ], "autoload": { "psr-4": { "RomegaDigital\\Multitenancy\\": "src/" } }, "autoload-dev": { "psr-4": { "RomegaDigital\\Multitenancy\\Tests\\": "tests/" } }, "require": { "spatie/laravel-permission": "^6.9.0" }, "extra": { "laravel": { "providers": [ "RomegaDigital\\Multitenancy\\MultitenancyServiceProvider" ] } }, "require-dev": { "orchestra/testbench": "^7", "nunomaduro/collision": "^6.0" } } ================================================ FILE: config/multitenancy.php ================================================ \App\Models\User::class, /* |-------------------------------------------------------------------------- | Base URL |-------------------------------------------------------------------------- | | This is the URL you would like to serve as the base of your app. It | should not contain a scheme (ie: http://, https://). | By default, it will attempt to use the host name with the TLD and domain | name stripped. | | Default: null */ 'base_url' => env('MULTITENANCY_BASE_URL', null), /* |-------------------------------------------------------------------------- | Roles |-------------------------------------------------------------------------- | | The values (on the right) determine how the roles with | keys (on the left) are being named in the database. */ 'roles' => [ // What the Super Administrator is called in your app 'super_admin' => 'Super Administrator', ], /* |-------------------------------------------------------------------------- | Policy Files |-------------------------------------------------------------------------- | | The policy file to use when using [MultitenancyNovaTool] | (https://github.com/romegasoftware/MultitenancyNovaTool) */ 'policies' => [ 'role' => \RomegaDigital\MultitenancyNovaTool\Policies\RolePolicy::class, 'permission' => \RomegaDigital\MultitenancyNovaTool\Policies\PermissionPolicy::class, ], /* |-------------------------------------------------------------------------- | Nova Resource Files |-------------------------------------------------------------------------- | | The Nova resources to use when using [MultitenancyNovaTool] | (https://github.com/romegasoftware/MultitenancyNovaTool) */ 'resources' => [ 'role' => \Vyuldashev\NovaPermission\Role::class, 'permission' => \Vyuldashev\NovaPermission\Permission::class, ], /* |-------------------------------------------------------------------------- | Tenant Model |-------------------------------------------------------------------------- | | This is the model you are using for Tenants that will be attached to the | User instance. It would be recommended to extend the Tenant model as | defined in the package, but if you replace it, be sure to implement | the RomegaDigital\Multitenancy\Contracts\Tenant contract. */ 'tenant_model' => \RomegaDigital\Multitenancy\Models\Tenant::class, 'table_names' => [ /** * We need to know which table to setup foreign relationships on. */ 'users' => 'users', /** * If overwriting `tenant_model`, you may also wish to define a new table */ 'tenants' => 'tenants', /** * Define the relationship table for the belongsToMany relationship */ 'tenant_user' => 'tenant_user', ], /* |-------------------------------------------------------------------------- | Redirect Route |-------------------------------------------------------------------------- | | This is the name of the route users who aren't logged in will be redirected to */ 'redirect_route' => 'login', /* |-------------------------------------------------------------------------- | Ignore Tenant on User creation |-------------------------------------------------------------------------- | | By default a user is assigned the tenant it is created on. If you create | a user while being on the `admin` tenant, this would assign the created | user the `admin` tenant automatically. If you don't want to get tenants | assigned to users automatically simply disable this setting by setting | it to false. */ 'ignore_tenant_on_user_creation' => false, ]; ================================================ FILE: migrations/create_tenants_table.php.stub ================================================ bigIncrements('id'); $table->string('name')->unique(); $table->string('domain')->unique(); $table->softDeletes(); $table->timestamps(); }); Schema::create($tableNames['tenant_user'], function (Blueprint $table) use ($tableNames) { $table->bigIncrements('id'); $table->unsignedBigInteger('tenant_id'); $table->foreign(Str::singular($tableNames['tenants']).'_id') ->references('id') ->on($tableNames['tenants']) ->onDelete('cascade'); $table->unsignedBigInteger('user_id'); $table->foreign(Str::singular($tableNames['users']).'_id') ->references('id') ->on($tableNames['users']) ->onDelete('cascade'); $table->timestamps(); $table->softDeletes(); }); } /** * Reverse the migrations. * * @return void */ public function down() { $tableNames = config('multitenancy.table_names'); Schema::table($tableNames['tenant_user'], function (Blueprint $table) use ($tableNames) { $table->dropForeign([Str::singular($tableNames['tenants']).'_id']); $table->dropForeign([Str::singular($tableNames['users']).'_id']); }); Schema::dropIfExists($tableNames['tenants']); Schema::dropIfExists($tableNames['tenant_user']); } } ================================================ FILE: phpunit.xml ================================================ tests src/ ================================================ FILE: src/Commands/AssignAdminPrivileges.php ================================================ multitenancy = $multitenancy; } /** * Execute the console command. * * @return mixed */ public function handle() { $column = $this->option('column'); $userModel = $this->option('model'); $identifier = $this->argument('identifier'); if (!class_exists($userModel)) { return $this->error('User model ' . $userModel . ' can not be found!'); } if (!$user = $this->getUser($userModel, $column, $identifier)) { return 0; } if (!$adminRole = $this->getAdminRole()) { return 0; } if (!$adminTenant = $this->getAdminTenant()) { return 0; } $user->assignRole($adminRole); $user->tenants()->save($adminTenant); $this->info('User with ' . $column . ' ' . $user->{$column} . ' granted Super-Administration rights.'); return 1; } /** * Get user model data. * * @param string $userModel * @param string $column * @param string $identifier * * @return Illuminate\Database\Eloquent\Model */ protected function getUser($userModel, $column, $identifier) { if (!$user = $userModel::where($column, $identifier)->first()) { return $this->modelNotFound('User', $column, $identifier); } return $user; } /** * Get admin role. * * @return Spatie\Permission\Contracts\Role */ protected function getAdminRole() { try { return Role::findByName(config('multitenancy.roles.super_admin')); } catch (RoleDoesNotExist $exception) { return $this->cancel('Role', 'name', config('multitenancy.roles.super_admin')); } } /** * Get admin tenant. * * @return RomegaDigital\Multitenancy\Contracts\Tenant */ protected function getAdminTenant() { try { return $this->multitenancy->getTenantClass()::findByDomain('admin'); } catch (TenantDoesNotExist $exception) { return $this->cancel('Tenant', 'domain', 'admin'); } } /** * Cancel the command due to errors. * * @param Illuminate\Database\Eloquent\Model $model * @param string $column * @param string $identifier * * @return bool */ protected function cancel($model, $column, $identifier) { $this->modelNotFound($model, $column, $identifier); $this->line(''); $this->alert('Did you already run `multitenancy:install` command?'); return false; } /** * Write an error for a model which can not be found. * * @param Illuminate\Database\Eloquent\Model $model * @param string $column * @param string $identifier * * @return void */ protected function modelNotFound($model, $column, $identifier) { $this->error("$model with $column `$identifier` can not be found!"); } } ================================================ FILE: src/Commands/InstallCommand.php ================================================ multitenancy = $multitenancy; } /** * Execute the console command. * * @return mixed */ public function handle() { $this->option('migrations') ?? $this->handleMigrations(); $this->option('roles') ?? $this->addSuperAdminRole(); $this->option('tenant') ?? $this->addAdminTenant(); return 1; } /** * Publishes and migrates required migrations. * * @return void */ protected function handleMigrations() { $this->info('Publishing required migrations...'); $this->callSilent('vendor:publish', [ '--provider' => 'Spatie\Permission\PermissionServiceProvider', '--tag' => ['permission-migrations'], ]); $this->callSilent('vendor:publish', [ '--provider' => 'RomegaDigital\Multitenancy\MultitenancyServiceProvider', '--tag' => ['migrations'], ]); $this->info('Migrations published!'); $this->line(''); $this->call('migrate'); $this->line(''); } /** * Creates a super admin role and 'access admin' * permission. * * @return void */ protected function addSuperAdminRole() { $this->info('Adding `Super Administrator` Role...'); $this->call('permission:create-role', [ 'name' => config('multitenancy.roles.super_admin'), 'permissions' => 'access admin', ]); $this->line(''); } /** * Creates the admin tenant model. * * @return void */ protected function addAdminTenant() { $this->info('Adding `admin` domain...'); $this->multitenancy->getTenantClass()::updateOrCreate([ 'name' => 'Admin Portal', 'domain' => 'admin', ]); $this->info('Admin domain added successfully!'); } } ================================================ FILE: src/Commands/MigrationMakeCommand.php ================================================ composer = $composer; } /** * Execute the console command. * * @return mixed */ public function handle() { parent::handle(); $this->composer->dumpAutoloads(); $this->info('Multitenancy migration created successfully.'); return 1; } /** * Get the stub file for the generator. * * @return string */ protected function getStub() { return __DIR__.'/stubs/add_tenancy_to_table.stub'; } /** * Build the class with the given name. * * @param string $name * * @throws \Illuminate\Contracts\Filesystem\FileNotFoundException * * @return string */ protected function buildClass($name) { $stub = parent::buildClass($name); return str_replace( ['DummyTable', 'DummyTenantTable'], [lcfirst($this->getNameInput()), config('multitenancy.table_names.tenants')], $stub ); } /** * Replace the class name for the given stub. * * @param string $stub * @param string $name * * @return string */ protected function replaceClass($stub, $name) { $class = 'AddTenantIDColumnTo'.Str::studly($this->getNameInput()).'Table'; return str_replace('DummyClass', $class, $stub); } /** * Get the destination class path. * * @param string $name * * @return string */ protected function getPath($name) { $timestamp = date('Y_m_d_His'); $table = lcfirst($this->getNameInput()); return $this->laravel->databasePath()."/migrations/{$timestamp}_add_tenant_id_column_to_{$table}_table.php"; } } ================================================ FILE: src/Commands/stubs/add_tenancy_to_table.stub ================================================ unsignedBigInteger('tenant_id')->nullable(); $table->foreign('tenant_id') ->references('id') ->on("DummyTenantTable") ->onDelete('cascade'); }); } /** * Reverse the migrations. * * @return void */ public function down() { Schema::table("DummyTable", function (Blueprint $table) { $table->dropColumn('tenant_id'); }); } } ================================================ FILE: src/Contracts/Tenant.php ================================================ multitenancy = $multitenancy; } /** * Handle an incoming request. * * @param \Illuminate\Http\Request $request * @param \Closure $next * @param string|null $guard * * @return mixed */ public function handle($request, Closure $next) { $tenant = $this->multitenancy->receiveTenantFromRequest(); $this->multitenancy->setTenant($tenant)->applyTenantScopeToDeferredModels(); return $next($request); } } ================================================ FILE: src/Middleware/TenantMiddleware.php ================================================ multitenancy = $multitenancy; } /** * Get the path the user should be redirected to when they are not authenticated. * * @param \Illuminate\Http\Request $request * * @return string */ protected function redirectTo($request) { if (! $request->expectsJson()) { return route(config('multitenancy.redirect_route')); } } /** * Handle an incoming request. * * @param \Illuminate\Http\Request $request * @param \Closure $next * @param string[] ...$guards * * @throws \RomegaDigital\Multitenancy\Exceptions\UnauthorizedException|\Illuminate\Auth\AuthenticationException * * @return mixed */ public function handle($request, Closure $next, ...$guards) { $this->authenticate($request, $guards); $tenant = $this->multitenancy->receiveTenantFromRequest(); if (! $this->authorizedToAccessTenant($tenant)) { throw UnauthorizedException::forDomain($tenant->domain); } $this->multitenancy->setTenant($tenant)->applyTenantScopeToDeferredModels(); $request->merge([Multitenancy::TENANT_SET_HEADER => true]); return $next($request); } /** * Check if user is authorized to access tenant's domain. * * @param \RomegaDigital\Multitenancy\Contracts\Tenant $tenant * * @return bool */ protected function authorizedToAccessTenant(Tenant $tenant) { return $tenant && $tenant->users->contains(auth()->user()->id); } } ================================================ FILE: src/Models/Tenant.php ================================================ setTable(config('multitenancy.table_names.tenants')); } /** * A Tenant belongs to many users. * * @return \Illuminate\Database\Eloquent\Relations\BelongsToMany */ public function users(): BelongsToMany { return $this->belongsToMany(config('multitenancy.user_model')) ->withTimestamps(); } /** * Find a Tenant by its domain. * * @param string $domain * * @throws \RomegaDigital\Multitenancy\Exceptions\TenantDoesNotExist * * @return \RomegaDigital\Multitenancy\Contracts\Tenant */ public static function findByDomain(string $domain): TenantContract { $tenant = static::where(['domain' => $domain])->first(); if (! $tenant) { throw TenantDoesNotExist::forDomain($domain); } return $tenant; } } ================================================ FILE: src/Multitenancy.php ================================================ tenantClass = config('multitenancy.tenant_model'); $this->deferredModels = collect(); } /** * Sets the Tenant to a Tenant Model. * * @param RomegaDigital\Multitenancy\Contracts\Tenant $tenant * * @return $this */ public function setTenant(Tenant $tenant) { $this->tenant = $tenant; return $this; } /** * Returns the current Tenant. * * @return \RomegaDigital\Multitenancy\Contracts\Tenant */ public function currentTenant(): Tenant { return $this->tenant ?? $this->receiveTenantFromRequest(); } /** * Applies applicable tenant scopes to model or if not booted yet * store for deferment. * * @param Illuminate\Database\Eloquent\Model $model * * @return void|null */ public function applyTenantScope(Model $model) { if (is_null($this->tenant)) { $this->deferredModels->push($model); return; } if ('admin' === $this->tenant->domain) { return; } $model->addGlobalScope('tenant', function (Builder $builder) use ($model) { $builder->where($model->qualifyColumn('tenant_id'), '=', $this->tenant->id); }); } /** * Applies applicable tenant id to model on create. * * @param Illuminate\Database\Eloquent\Model $model * * @return void|null */ public function newModel(Model $model) { if (is_null($this->tenant)) { $this->deferredModels->push($model); return; } if (! isset($model->tenant_id)) { $model->setAttribute('tenant_id', $this->tenant->id); } } /** * Applies applicable tenant scope to deferred model booted * before tenants setup. */ public function applyTenantScopeToDeferredModels() { $this->deferredModels->each(function ($model) { $this->applyTenantScope($model); }); $this->deferredModels = collect(); } /** * Get an instance of the tenant class. * * @return \RomegaDigital\Multitenancy\Contracts\Tenant */ public function getTenantClass(): Tenant { return app($this->tenantClass); } /** * Determines how best to process the URL based * on config and then returns the appropriate * subdomain text. * * @return string */ public function getCurrentSubDomain(): string { $baseURL = config('multitenancy.base_url'); if (null != $baseURL) { return $this->getSubDomainBasedOnBaseURL($baseURL); } else { return $this->getSubDomainBasedOnHTTPHost(); } } /** * Parses the request to pull out the first element separated * by `.` in the $_SERVER['HTTP_HOST']. * * ex: * test.domain.com returns test * test2.test.domain.com returns test2 * * @return string */ protected function getSubDomainBasedOnHTTPHost(): string { $currentDomain = app('request')->getHost(); // Get rid of the TLD and root domain // ex: masterdomain.test.example.com returns // [ masterdomain, test ] $subdomains = explode('.', $currentDomain, -2); // Combine multiple level of domains into 1 string // ex: back to masterdomain.test $subdomain = implode('.', $subdomains); return $subdomain; } /** * Parses the request and removes the portion of the URL * that matches the Base URL as defined in the config file. * * ex: * baseURL = app.domain.com * test2.app.domain.com returns test2 * * @return string */ protected function getSubDomainBasedOnBaseURL(string $baseURL): string { $currentDomain = app('request')->getHost(); //Remove the base domain from the currentDomain string $subdomain = str_replace($baseURL, '', $currentDomain); // If the last element is a period, remove it // Necessary to run this check, incase we're // processing the base domain. if ('.' == substr($subdomain, -1)) { $subdomain = substr($subdomain, 0, -1); } return $subdomain; } /** * Returns tenant from request subdomain. * * @return \RomegaDigital\Multitenancy\Contracts\Tenant */ public function receiveTenantFromRequest() { $domain = $this->getCurrentSubDomain(); return $this->getTenantClass()::findByDomain($domain); } } ================================================ FILE: src/MultitenancyFacade.php ================================================ loadMigrationsFrom(realpath(__DIR__ . '/../migrations')); if ($this->app->runningInConsole()) { $this->registerPublishing($filesystem); } $this->registerCommands(); $this->registerModelBindings(); Gate::before(function ($user, $ability) { if ($user->hasRole(config('multitenancy.roles.super_admin')) && 'admin' === app('multitenancy')->getCurrentSubDomain()) { return true; } }); } /** * Register the application services. */ public function register() { $this->mergeConfigFrom( __DIR__ . '/../config/multitenancy.php', 'multitenancy' ); $this->app->singleton(Multitenancy::class, function () { return new Multitenancy(); }); $this->app->alias(Multitenancy::class, 'multitenancy'); } /** * Register the package's publishable resources. * * @param Illuminate\Filesystem\Filesystem $filesystem */ protected function registerPublishing(Filesystem $filesystem) { $this->publishes([ __DIR__ . '/../migrations/create_'.config('multitenancy.table_names.tenants').'_table.php.stub' => $this->getMigrationFileName($filesystem), ], 'migrations'); $this->publishes([ __DIR__ . '/../config/multitenancy.php' => config_path('multitenancy.php'), ], 'config'); } /** * Registers all commands within the package. */ protected function registerCommands() { $this->commands([ InstallCommand::class, MigrationMakeCommand::class, AssignAdminPrivileges::class, ]); } /** * Register model bindings. */ protected function registerModelBindings() { $this->app->bind(TenantContract::class, $this->app->config['multitenancy.tenant_model']); } /** * Returns existing migration file if found, else uses the current timestamp. * * @param Illuminate\Filesystem\Filesystem $filesystem * * @return string */ protected function getMigrationFileName(Filesystem $filesystem): string { $timestamp = date('Y_m_d_His'); return Collection::make($this->app->databasePath() . DIRECTORY_SEPARATOR . 'migrations' . DIRECTORY_SEPARATOR) ->flatMap(function ($path) use ($filesystem) { return $filesystem->glob($path . '*_create_tenants_table.php'); })->push($this->app->databasePath() . "/migrations/{$timestamp}_create_tenants_table.php") ->first(); } } ================================================ FILE: src/Traits/BelongsToTenant.php ================================================ applyTenantScope(new static()); static::creating(function ($model) { resolve(Multitenancy::class)->newModel($model); }); } /** * The model belongs to a tenant. * * @return Illuminate\Database\Eloquent\Relations\BelongsTo */ public function tenant() { return $this->belongsTo(config('multitenancy.tenant_model')); } } ================================================ FILE: src/Traits/HasTenants.php ================================================ has(Multitenancy::TENANT_SET_HEADER) || $ignoreTenantOnUserCreation) { return; } $model->tenants()->save( resolve(Multitenancy::class)->currentTenant() ); }); } /** * The model belongs to many tenants. * * @return Illuminate\Database\Eloquent\Relations\BelongsToMany */ public function tenants() { return $this->belongsToMany(config('multitenancy.tenant_model')) ->withTimestamps(); } } ================================================ FILE: tests/Databases/MigrateDatabaseTest.php ================================================ assertEquals([ 'id', 'name', 'domain', 'deleted_at', 'created_at', 'updated_at', ], $columns); $columns = \Schema::getColumnListing('tenant_user'); $this->assertEquals([ 'id', 'tenant_id', 'user_id', 'created_at', 'updated_at', 'deleted_at', ], $columns); } } ================================================ FILE: tests/Feature/BelongsToTenantTest.php ================================================ resource('products', ProductController::class); } /** * Turn the given URI into a fully qualified URL. * * @param string $uri * * @return string */ protected function prepareUrlForRequest($uri) { $uri = "http://{$this->testTenant->domain}.localhost.com/{$uri}"; return trim($uri, '/'); } /** @test */ public function it_adds_current_tenant_id_to_model_on_create() { $this->actingAs($this->testUser); $this->testTenant->users()->save($this->testUser); $response = $this->post('products', [ 'name' => 'Another Tenants Product', ]); $response->assertStatus(201); $this->assertEquals(Product::first()->tenant_id, $this->testTenant->id); } /** @test */ public function it_only_retrieves_records_scoped_to_current_subdomain() { $this->actingAs($this->testUser); $this->testTenant->users()->save($this->testUser); Product::create([ 'name' => 'Another Tenants Product', 'tenant_id' => Tenant::create([ 'name' => 'Another Tenant', 'domain' => 'anotherdomain', ])->id, ]); $response = $this->get('products'); $response->assertStatus(200); $this->assertEquals(Product::where('tenant_id', $this->testTenant->id)->get(), $response->getContent()); } /** @test **/ public function it_retrieves_all_records_when_accessing_via_admin_subdomain() { $this->actingAs($this->testUser); $this->testAdminTenant->users()->save($this->testUser); $this->testTenant->domain = $this->testAdminTenant->domain; Product::create([ 'name' => 'Another Tenants Product', 'tenant_id' => Tenant::create([ 'name' => 'Another Tenant', 'domain' => 'anotherdomain', ])->id, ]); $response = $this->get('products'); $response->assertStatus(200); $this->assertEquals(Product::withoutGlobalScopes()->get(), $response->getContent()); } } ================================================ FILE: tests/Feature/Commands/AssignAdminPrivilegesTest.php ================================================ artisan('multitenancy:super-admin', [ 'identifier' => 'test@user.com', ]) ->expectsOutput('User model \App\Models\User can not be found!') ->assertExitCode(0); } /** @test */ public function it_throws_an_error_and_exits_if_no_user_model_is_found() { $this->artisan('multitenancy:super-admin', [ 'identifier' => 'fail@user.com', '--model' => config('multitenancy.user_model'), ]) ->expectsOutput('User with email `fail@user.com` can not be found!') ->assertExitCode(0); } /** @test */ public function it_throws_an_error_and_exits_if_no_super_adminitration_role_is_found() { $this->artisan('multitenancy:super-admin', [ 'identifier' => 'test@user.com', '--model' => config('multitenancy.user_model'), ]) ->expectsOutput('Role with name `Super Administrator` can not be found!') ->expectsOutput('* Did you already run `multitenancy:install` command? *') ->assertExitCode(0); } /** @test */ public function it_throws_an_error_and_exits_if_no_admin_tenant_is_found() { Role::create(['name' => 'Super Administrator']); $tenant = Tenant::findByDomain('admin'); $tenant->domain = 'testadmin'; $tenant->save(); $this->artisan('multitenancy:super-admin', [ 'identifier' => 'test@user.com', '--model' => config('multitenancy.user_model'), ]) ->expectsOutput('Tenant with domain `admin` can not be found!') ->expectsOutput('* Did you already run `multitenancy:install` command? *') ->assertExitCode(0); } /** @test */ public function it_assigns_super_administrator_role_and_admin_tenant_to_given_user() { Role::create(['name' => 'Super Administrator']); $this->artisan('multitenancy:super-admin', [ 'identifier' => 'test@user.com', '--model' => config('multitenancy.user_model'), ]) ->expectsOutput('User with email test@user.com granted Super-Administration rights.') ->assertExitCode(1); $user = User::whereEmail('test@user.com')->first(); $this->assertTrue($user->hasRole('Super Administrator')); } } ================================================ FILE: tests/Feature/Commands/InstallCommandTest.php ================================================ artisan('multitenancy:install') ->expectsOutput('Publishing required migrations...') ->expectsOutput('Migrations published!') ->expectsOutput('Adding `Super Administrator` Role...') ->expectsOutput('Role `Super Administrator` created') ->expectsOutput('Adding `admin` domain...') ->expectsOutput('Admin domain added successfully!') ->assertExitCode(1); } } ================================================ FILE: tests/Feature/Commands/MigrationMakeCommandTest.php ================================================ mock(\Illuminate\Filesystem\Filesystem::class) ->makePartial() ->shouldReceive('put') ->once(); $this->artisan('multitenancy:migration', ['name' => 'testproducts']) ->expectsOutput('Multitenancy migration created successfully.') ->assertExitCode(1); } /** @test **/ public function it_can_handle_multiword_names() { $this->mock(\Illuminate\Filesystem\Filesystem::class) ->makePartial() ->shouldReceive('put') ->with(\Mockery::any(), \Mockery::pattern('/AddTenantIDColumnToTestNameTable/')) ->once(); $this->artisan('multitenancy:migration', ['name' => 'test_name']); } } ================================================ FILE: tests/Feature/GateTest.php ================================================ resource('products', ProductController::class); Gate::policy(Product::class, ProductPolicy::class); } /** * Turn the given URI into a fully qualified URL. * * @param string $uri * * @return string */ protected function prepareUrlForRequest($uri) { $uri = "http://admin.localhost.com/{$uri}"; return trim($uri, '/'); } /** @test **/ public function it_does_not_allow_regular_user() { $this->actingAs($this->testUser); $this->testAdminTenant->users()->save($this->testUser); $product = Product::create([ 'name' => 'Another Tenants Product', 'tenant_id' => Tenant::create([ 'name' => 'Another Tenant', 'domain' => 'anotherdomain', ])->id, ]); $response = $this->get('products/' . $product->id); $response->assertForbidden(); } /** @test **/ public function it_does_not_allow_super_administrator_not_tied_to_admin_subdomain() { Role::create(['name' => 'Super Administrator']); $this->actingAs($this->testUser); $this->testUser->assignRole('Super Administrator'); $product = Product::create([ 'name' => 'Another Tenants Product', 'tenant_id' => Tenant::create([ 'name' => 'Another Tenant', 'domain' => 'anotherdomain', ])->id, ]); $response = $this->get('products/' . $product->id); $response->assertForbidden(); } /** @test **/ public function it_does_allow_super_administrator_tied_to_domain() { Role::create(['name' => 'Super Administrator']); $this->actingAs($this->testUser); $this->testUser->assignRole('Super Administrator'); $this->testAdminTenant->users()->save($this->testUser); $product = Product::create([ 'name' => 'Another Tenants Product', 'tenant_id' => Tenant::create([ 'name' => 'Another Tenant', 'domain' => 'anotherdomain', ])->id, ]); $response = $this->get('products/' . $product->id); $response->assertOK(); } } ================================================ FILE: tests/Feature/HasTenantTest.php ================================================ resource('users', UserController::class); } /** * Turn the given URI into a fully qualified URL. * * @param string $uri * * @return string */ protected function prepareUrlForRequest($uri) { $uri = "http://{$this->testTenant->domain}.localhost.com/{$uri}"; return trim($uri, '/'); } /** @test */ public function it_adds_current_tenant_id_to_user_model_on_create() { $this->actingAs($this->testUser); $this->testTenant->users()->save($this->testUser); $this->post('users', [ 'email' => $email = 'another@user.com', 'name' => 'UserName', 'password' => 'PassWord', ]) ->assertStatus(201); $this->assertContains($this->testTenant->id, User::whereEmail($email)->first()->tenants->pluck('id')); } /** @test */ public function it_does_not_add_a_tenant_if_the_the_ignore_tenant_on_user_creation_is_set() { config(['multitenancy.ignore_tenant_on_user_creation' => true]); $this->testTenant->users()->save($this->testUser); $otherTenant = resolve(Tenant::class)->create([ 'name' => 'Other', 'domain' => 'other', ]); $this->actingAs($this->testUser) ->post('users', [ 'email' => $email = 'with@tenant.com', 'name' => 'UserName', 'password' => 'PassWord', 'tenant' => $otherTenant, ]) ->assertStatus(201); $this->assertContains($otherTenant->id, $tenantIds = User::whereEmail($email)->first()->tenants->pluck('id')); $this->assertNotContains($this->testTenant->id, $tenantIds); } } ================================================ FILE: tests/Feature/Middleware/GuestMiddlewareTest.php ================================================ tenantMiddleware = new GuestTenantMiddleware(app('multitenancy')); } protected function buildRequest($domain) { app('request')->headers->set('HOST', $domain . '.example.com'); return $this->tenantMiddleware->handle(app('request'), function () { return (new Response())->setContent(''); }); } /** @test */ public function it_throws_error_if_domain_not_found() { try { $this->buildRequest('testdomain'); $this->fail('Expected exception not thrown'); } catch (TenantDoesNotExist $e) { //Not catching a generic Exception or the fail function is also catched $this->assertEquals('There is no tenant at domain `testdomain`.', $e->getMessage()); } } /** @test **/ public function it_allows_guest_users_to_access_tenant_scoped_requests() { $this->assertEquals( $this->buildRequest($this->testTenant->domain)->getStatusCode(), 200 ); } } ================================================ FILE: tests/Feature/Middleware/TenantMiddlewareTest.php ================================================ get('/login', function () { return 'login'; })->name(config('multitenancy.redirect_route')); } public function setUp(): void { parent::setUp(); $this->tenantMiddleware = new TenantMiddleware(app('auth'), app('multitenancy')); } protected function buildRequest($domain) { app('request')->headers->set('HOST', $domain . '.example.com'); return $this->tenantMiddleware->handle(app('request'), function () { return (new Response())->setContent(''); }); } /** @test */ public function it_throws_error_if_domain_not_found() { $this->actingAs($this->testUser); try { $this->buildRequest('testdomain'); $this->fail('Expected exception not thrown'); } catch (TenantDoesNotExist $e) { //Not catching a generic Exception or the fail function is also catched $this->assertEquals('There is no tenant at domain `testdomain`.', $e->getMessage()); } } /** @test **/ public function it_throws_error_if_user_is_not_part_of_tenant() { $this->actingAs($this->testUser); try { $this->buildRequest($this->testTenant->domain); $this->fail('Expected exception not thrown'); } catch (UnauthorizedException $e) { //Not catching a generic Exception or the fail function is also catched $this->assertEquals(403, $e->getStatusCode()); $this->assertEquals("The authenticated user does not have access to domain `{$this->testTenant->domain}`.", $e->getMessage()); } } /** @test **/ public function it_throws_error_if_user_is_not_logged_in() { try { $this->buildRequest($this->testTenant->domain); $this->fail('Expected exception not thrown'); } catch (AuthenticationException $e) { //Not catching a generic Exception or the fail function is also catched $this->assertEquals('Unauthenticated.', $e->getMessage()); } } /** @test **/ public function it_allows_users_who_are_associated_with_a_valid_domain() { $this->actingAs($this->testUser); $this->testTenant->users()->sync($this->testUser); $this->assertEquals( $this->buildRequest($this->testTenant->domain)->getStatusCode(), 200 ); } } ================================================ FILE: tests/Feature/MultitenancyTest.php ================================================ get('/login', function () { return 'login'; })->name(config('multitenancy.redirect_route')); } public function setUp(): void { parent::setUp(); $this->tenantMiddleware = new TenantMiddleware(app('auth'), app('multitenancy')); } protected function buildRequest($domain) { app('request')->headers->set('HOST', $domain.'.example.com'); return $this->tenantMiddleware->handle(app('request'), function () { return (new Response())->setContent(''); }); } /** @test */ public function it_returns_the_current_tenant_when_set_by_middleware() { $this->actingAs($this->testUser); $this->testTenant->users()->sync($this->testUser); $this->buildRequest($this->testTenant->domain); $this->assertEquals($this->testTenant->domain, app('multitenancy')->currentTenant()->domain); } /** @test */ public function it_throws_exception_when_tenant_not_set() { $this->actingAs($this->testUser); $this->testTenant->users()->sync($this->testUser); try { $this->buildRequest('testdomain'); app('multitenancy')->currentTenant(); $this->fail('Expected exception not thrown'); } catch (TenantDoesNotExist $e) { $this->assertEquals('There is no tenant at domain `testdomain`.', $e->getMessage()); } } /** @test */ public function it_throws_exception_when_tenant_not_set_never_touched_middleware() { $this->actingAs($this->testUser); $this->testTenant->users()->sync($this->testUser); try { app('multitenancy')->currentTenant(); $this->fail('Expected exception not thrown'); } catch (TenantDoesNotExist $e) { $this->assertEquals('There is no tenant at domain ``.', $e->getMessage()); } } } ================================================ FILE: tests/Feature/TenantTest.php ================================================ expectException(TenantDoesNotExist::class); app(Tenant::class)->findByDomain('nonexistentdomain'); } /** @test */ public function it_is_retrievable_by_domain() { $permission_by_domain = app(Tenant::class)->findByDomain($this->testTenant->domain); $this->assertEquals($this->testTenant->id, $permission_by_domain->id); } } ================================================ FILE: tests/Fixtures/Controllers/ProductController.php ================================================ middleware([ function ($request, Closure $next) { $route = app('router')->getCurrentRoute(); Assert::assertSame(ProductController::class, get_class($route->getController())); return $next($request); }, TenantMiddleware::class, ]); } public function index() { return Product::all(); } public function store(Request $request) { return Product::create([ 'name' => $request->name, ]); } public function show(Product $product) { app(Gate::class)->authorize('view', $product); return $product; } } ================================================ FILE: tests/Fixtures/Controllers/UserController.php ================================================ middleware([ function ($request, Closure $next) { $route = app('router')->getCurrentRoute(); Assert::assertSame(UserController::class, get_class($route->getController())); return $next($request); }, TenantMiddleware::class, ]); } public function store(Request $request) { if (! $request->has('tenant')) { return User::create([ 'email' => $request->email, 'name' => $request->name, 'password' => $request->password, ]); } $user = new User([ 'email' => $request->email, 'name' => $request->name, 'password' => $request->password, ]); resolve(Tenant::class)->find($request->tenant)->first()->users()->save($user); return $user; } } ================================================ FILE: tests/Fixtures/Policies/ProductPolicy.php ================================================ set('multitenancy.user_model', User::class); $app['config']->set('auth.providers.users.model', config('multitenancy.user_model')); $app['config']->set('auth.guards.web.provider', 'users'); } /** * Load package service provider. * * @param \Illuminate\Foundation\Application $app * * @return array */ protected function getPackageProviders($app) { return [ MultitenancyServiceProvider::class, PermissionServiceProvider::class, ]; } /** * Load package alias. * * @param \Illuminate\Foundation\Application $app * * @return array */ protected function getPackageAliases($app) { return [ 'Multitenancy' => MultitenancyFacade::class, ]; } public function setUp(): void { parent::setUp(); if ($this->setupTestDatabase) { $this->setUpDatabase($this->app); $this->testUser = User::first(); $this->testTenant = app(Tenant::class)->find(1); $this->testAdminTenant = app(Tenant::class)->find(2); $this->testProduct = Product::first(); } } /** * Define database migrations. * * @return void */ protected function defineDatabaseMigrations() { $this->loadLaravelMigrations(); } /** * Set up the database. * * @param \Illuminate\Foundation\Application $app */ protected function setUpDatabase($app) { $this->loadMigrationsFrom(realpath(__DIR__ . '/../migrations')); $this->artisan('migrate')->run(); $app[Tenant::class]->create([ 'name' => 'Tenant Name', 'domain' => 'masterdomain', ]); $app[Tenant::class]->create([ 'name' => 'Admin', 'domain' => 'admin', ]); User::create([ 'name' => "Test User", 'email' => 'test@user.com', 'password' => 'testPassword', ]); Schema::create('products', function (Blueprint $table) { $table->increments('id'); $table->string('name'); $table->unsignedInteger('tenant_id'); $table->foreign('tenant_id') ->references('id') ->on('tenants') ->onDelete('cascade'); $table->softDeletes(); }); Product::create([ 'name' => 'Product 1', 'tenant_id' => '1', ]); } }