Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Select an option

  • Save nhalstead/118ffb74fe92e83edac285284b698cdc to your computer and use it in GitHub Desktop.

Select an option

Save nhalstead/118ffb74fe92e83edac285284b698cdc to your computer and use it in GitHub Desktop.
How to properly setup tenant scoping for routes and models (by centralizing the logic for scoping) in Laravel v11.x+, v12.x+ and v13.x+
<?php
namespace Parlie\Traits\Routing\Middleware\Guard;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
trait ApplyTenantRouteGuard {
abstract public function guardCallback (Request $request): void;
/**
* Handle an incoming request
*
* @param \Illuminate\Http\Request $request
* @param \Closure(Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next
*/
final protected function handle (Request $request, Closure $next): Response {
if (!auth()->check()) {
$errorResponse = $request->accepts(['application/json'])
? response()->json(config('palie.route_guard.json_error'), 403);
: response(config('palie.route_guard.text_error'), 403);
return $errorResponse;
}
$loggedInUser = auth()->user();
/* @NOTE: HTTP request cookie MUST be Http-Only and Same-Site: `strict` */
/* @HINT: This specific cookie MUST also be encrypted before including it in a HTTP response */
if ($loggedInUser->getTenantId() === $request->cookie($loggedInUser->getTenantColumnKey())) {
$this->guardCallback($request);
return $next($request);
}
return $errorResponse;
}
}
/*
namespace App\Http\Middleware;
use Parlie\Traits\Routing\Middleware\Guard\ApplyTenantRouteGuard;
use Illuminate\Http\Request;
use App\Models\Scopes\MyCustomScope;
use Illuminate\Database\Eloquent\Builder;
use App\Models\Organization;
class MyMiddleware {
use ApplyTenantRouteGuard;
public function guardCallback (Request $request): void {
Organization::addGlobalScope(new MyCustomScope($request));
Organization::addGlobalScope('country', function(Builder $builder) {
$country = session('location.from.ip.as.country');
$builder->where('country', $country);
});
}
}
*/
?>
<?php
namespace Parlie\Traits\Routing\ServiceProviderBindings;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
//use Illuminate\Support\Facades\Route;
use Illuminate\Routing\Router;
/* @NOTE: Add this trait to the `RouteServiceProvider` class */
trait ApplyTenantRouteModelBinding {
//private const MODELS_MAP = config('palie.models_map');
private function fallbackExitCallback () {
throw new NotFoundHTTPException;
}
public function bootRouteModelBindings(Router $router): void {
$MODELS_MAP = config('palie.models_map', []);
/*Route::pattern(
config('palie.tenants.mirrors.id_name'),
config('palie.tenants.mirrors.id_type') === 'cuid_v2'
? '[a-z0-9]+'
: '[-a-fA-F0-9]+'
);*/
$router->pattern(
config('palie.tenants.mirrors.id_name'),
config('palie.tenants.mirrors.id_type', 'cuid_v2') === 'cuid_v2'
? '[a-z0-9]+'
: '[-a-fA-F0-9]+'
);
foreach ($MODELS_MAP as $parameter => $fullQualifiedClassNameOrCallback) {
/*Route::model(
$parameter,
$fullQualifiedClassNameOrCallback,
array($this, 'fallbackExitCall')
);*/
$router->model(
$parameter,
$fullQualifiedClassNameOrCallback,
array($this, 'fallbackExitCallback')
);
}
}
}
?>
<?php
namespace Parlie\Models\Scopes;
use Parlie\Models\Base\TenantModel;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Scope;
use Illiminate\Http\Request;
//use Illuminate\Support\Facades\Auth;
class TenantScope implements Scope {
protected $request;
public function __construct (Request $request = null) {
/* @CHECK: https://laravel.com/docs/5.4/helpers#method-request */
$this->request = $request !== null ? $request : request();
}
public function __destruct () {
$this->request = null;
}
public function applyTenantFilterClause (string $tenantColumnName, string $tenantTableName, Builer $builder):void {
/* if (!Auth::hasUser()) {*/
if (auth()->guest()) {
return;
}
$tenantId = $this->request->cookie($tenantColumnName);
if ($tenantId) {
$builder->where($tenantColumnName, $tenantId);
return;
}
throw new \Exception("'tenant id' not found to scope model query for table: `" . $tenantTableName . "`");
}
public function apply (Builder $builder, TenantModel $model): void {
$tenantColumnName = $model->getTenantColumnKey();
$tenantTableName = $model->getTable();
$this->applyTenantFilterClause($tenantColumnName, $tenantTableName, $builder);
}
}
?>
<?php
namespace Parlie\Models\Base;
use Illuminate\Database\Eloquent\Model;
use Visus\Cuid2\Cuid2;
trait HasTenantHelpers {
abstract public function getTenantColumnKey(): string;
abstract public function getTenantOwnerColumnKey(): string;
final protected function getTenantOwnerColumnName(): string {
return $this->getTenantOwnerColumnKey();
}
final protected function getTenantColumnName (): string {
return $this->getTenantColumnKey();
}
public function getTenantId(): string {
$tenantId = $this->{$this->getTenantColumnName()};
$tenantId = (string) $tenantId;
return $tenantId;
}
}
abstract class TenantModel extends Model {
use HasTenantHelpers;
protected static function boot(): void {
parent::boot();
}
protected static function booting(): void {
/* @TODO: ... */
}
protected static function booted(): void {
if (!static::hasGlobalScope('Parlie\Models\Scopes\TenantScope')) {
/* @NOTE:
The below won't work in Laravel 5.x+, 6.x+, 7.x+ and 8.x as
it is required to pass the instance of the `Scope`interface
in these versions of Laravel.
We can't pass the instance of `Scope` interface here because
it is going to be a circular dependency!!!
*/
static::addGlobalScope('Parlie\Models\Scopes\TenantScope');
}
static::creating(function (self $tenantModel) {
$tenantOwnerIdColumnName = $tenantModel->getTenantOwnerColumnName();
if ($tenantOwnerIdColumnName !== '_') {
if (!array_key_exists($tenantOwnerIdColumnName, $tenantModel->attributes)) {
$tenantModel->setAuthUserId(auth()->id());
}
}
});
static::saving(function (self $tenantModel) {
$tenantIdColumnName = $tenantModel->getTenantColumnName();
$tenantSlugIdColumnName = $tenantModel->getRouteKeyName();
if (!isset($tenantModel->{$tenantIdColumnName})) {
$tenantId = auth()-user()->getTenantId();
$tenantModel->attributes[$tenantIdColumnName] = $tenantId;
}
if (!isset($tenantModel->{$tenantSlugIdColumnName})) {
$cuid = new Cuid2(20);
$tenantModel->attributes[$tenantSlugIdColumnName] = $cuid->toString();
}
});
// @TODO: do things with Laravel config/stuff here...
}
final public static function builderWithoutTenantScoping () {
return static::withoutGlobalScope('Parlie\Models\Scopes\TenantScope');
}
final public function setAuthUserId($id): bool {
$ownerColumnName = $this->getTenantOwnerColumnName();
if (isset($id) and $ownerColumnName !== '_') {
$this->attributes[$ownerColumnName] = $id;
return true;
}
return false;
}
/* @Override (from Laravels' `Model` abstract class) */
public function getRouteKeyName () {
/* @HINT: Value is generated using either cuid_v2 or uuid_v4 */
return config('palie.tenants.mirrors.id_name');
}
public function relatedToTenantWithId($id): bool {
return isset($id) and is_string($id)
? $id === $this->{$this->getTenantColumnName()}
: false;
}
/* @Override (from Laravels' `Model` abstract class) */
public function resolveRouteBinding($value, $field = null) {
return $this->where($this->getRouteKeyName(), $value)->firstOrFail();
}
public function getTenantOwnerColumnKey(): string {
return '_';
}
}
?>
<?php
use Illuminate\Support\Facades\Route;
Route::middleware('tenant_guard:api')->domain('my.{account}.saavylytics.com')->prefix('/api/{tenant}')->group(function() {
Route::get('/students', function(Organization $org) {})->name('')->withoutScopedBindings();
Route::get('/courses', function(Organization $org) {})->name('');
});
?>
<?php
namespace App\Traits;
trait HasSuspension {
abstract public function getUserActiveColumnName(): string;
abstract public function getUserActiveColumnType(): string;
final protected function scopeAsSuspended (Builder $builder) {
return $builder->where($this->getUserActiveColumnName(), $this->getValue());
}
final protected function scopeAsNotSuspended (Builder $builder) {
return $builder->where($this->getUserActiveColumnName(), $this->getValue(true));
}
private function getValue ($switchOff = false) {
$value = $switchOff ? 0 : 1;
$flagDataType = $this->getUserActiveColumnType();
if ($dataType === "bool") {
$value = $switchOff ? false : true;
}
return $value;
}
public function suspend(): bool {
$this->{$this->getUserActiveColumnType()} = $this->getValue();
return $this->save();
}
public function restoreFromSuspension(): bool {
$this->{$this->getUserActiveColumnType()} = $this->getValue(true);
return $this->save();
}
public function isSuspended() {
return $this->{$this->getUserActiveColumnName()};
}
}
?>
<?php
namespace App\Models;
/* composer require dyrynda/laravel-model-uuid v8.2.0 */
/* composer require robinvdvleuten/ulid v5.0.0 */
use Dyrynda\Database\Support\Casts\EfficientUuid;
use Dyrynda\Database\Support\GeneratesUuid;
use Parlie\Models\Base\TenantModel;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use App\Models\Organisation;
use App\Traits\HasSuspension;
class User extends TenantModel {
use GeneratesUuid, HasSuspension;
public $incrementing = false;
protected $keyType = /* \ulid */ 'binary';
protected $casts = [
'slug_id' => EfficientUuid::class,
'tenant_id' => custom::BinaryToUlidString::class,
'id' => custom::BinaryToUlidString::class
];
public function getTenantColumnKey(): string {
return config('palie.tenants.column_key');
}
public function getTenantOwnerColumnKey(): string {
return $this->getKeyName();
}
public function getUserActiveColumnName(): string {
return 'is_active';
}
public function getUserActiveColumnType(): string {
return 'bool';
}
public function uuidColumn(): string {
return config('palie.tenants.mirrors.id_name');
}
public function tenant(): BelongsTo {
return $this->belongsTo(Organization::class);
}
}
?>
<?php
namespace Palie\Relations;
use Illuminate\Database\Eloquent\Relations\HasMany;
/* @CHECK: https://github.com/laravel/framework/issues/54867#issuecomment-2694103119 */
class BinaryHasMany extends HasMany {
public function addEagerConstraints(array $models) {
$keys = $this->getEagerModelKeys($models);
if ($this->parent->getKeyType() === 'binary') {
$keys = array_map(function($key) {
/* @NOTE:
each `$key` is already binary here from `->getRawOriginal(..)`
> within `->getEagerModelKeys(..)`
*/
return $key;
}, $keys);
}
$whereIn = $this->whereInMethod($this->parent, $this->localKey);
if (!empty($keys)) {
$this->whereInEager($whereIn, $this->foreignKey, $keys, $this->getRelationQuery());
}
}
protected function getEagerModelKeys(array $models) {
$keys = [];
foreach ($models as $model) {
if (!is_null($value = $model->getRawOriginal($this->localKey))) {
/* @HINT: Fetch raw binary value before casting */
$keys[] = $value;
}
}
return array_values(array_unique($keys));
}
}
?>
<?php
namespace App\Models;
use Parlie\Models\Base\TenantModel;
use Palie\Relations\BinaryHasMany;
use App\Models\User;
class Organization extends TenantModel {
public $incrementing = false;
protected $keyType = /* \ulid */ 'binary';
protected $casts = [
'slug_id' => EfficientUuid::class,
'id' => custom::BinaryToUlidString::class
];
public function getTenantColumnKey(): string {
return $this->getKeyName();
}
public function users(): BinaryHasMany {
return $this->hasMany(User::class, config('palie.tenants.column_key'), $this->getKeyName());
}
}
?>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment