Dynamic Global Scope
I have an application which usually handles, on the same instance, different groups of informations isolated by context: each user is assigned to one of the contexts, many users act inside the same context, when a user logs in all the managed data are relative to his own context. This has been practically implemented with Laravel's native global scoping, including on key Model classes something like:
static::addGlobalScope('context', function (Builder $builder) use ($context_id) {
$builder->whereHas('context', function($query) use ($context_id) {
$query->where('context_id', $context_id);
});
});
So, all the queries conveniently include a filter for the proper context.
How I have to display, to some user, some information from a different context. I had no intention to rewrite all the logic to access the data and compose the queries, so I've extracted global scoping into a dedicated class which dynamically adapts the scope given a set of conditions.
The new class looks like:
<?php
namespace App\Scopes;
use Illuminate\Database\Eloquent\Scope;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Builder;
use App;
class RestrictedContext implements Scope
{
private $key = null;
public function __construct($key = 'context')
{
$this->key = $key;
}
public function apply(Builder $builder, Model $model)
{
$hub = App::make('GlobalScopeHub');
if ($hub->enabled()) {
$context_id = $hub->getContext();
if ($context_id) {
$builder->whereHas($this->key, function($query) use ($context_id) {
$query->where('context_id', $context_id);
});
}
}
}
}
where "key" is the name of the relationship, relative to the model, to query to filter only data from the desidered context. For example: User
are directly assigned to the context, so I directly query for it, and Notifications
are assigned to users, so I have to query those having users
with the desidered context assigned.
GlobalScopeHub
is a singleton, and looks like:
<?php
namespace App\Singletons;
class GlobalScopeHub
{
private $enabled_global_scopes = true;
private $context_id = null;
public function enable($active)
{
$this->enabled_global_scopes = $active;
}
public function enabled()
{
return $this->enabled_global_scopes;
}
public function setContext($context_id)
{
$this->context_id = $context_id;
}
public function getContext()
{
return $this->context_id;
}
}
This holds the state of the current global scoping: it may be disabled, and inhibit any scope (so: all queries are not dependent by the context), or it may target a specific context.
But... Wait a minute! Where is the default context defined? You should expect GlobalScopeHub::context_id
to be initializated such as
$context_id = Auth::user()->context_id;
But it is not the case. Auth::user()
executes a query to retrieve the current User
, which is subject to the RestrictedScope
global scoping class, which depends to the result of GlobalScopingHub
, which would be initialized by Auth::user()
... and you obtain a loop of classes calling each other until you exaust the memory.
The whole thing is initialized by a Middleware, injected into the default middleware group:
<?php
namespace App\Http\Middleware;
use App;
use Auth;
use Closure;
class ActIntoContext
{
public function handle($request, Closure $next)
{
$user = Auth::user();
if ($user) {
$managed_context = $request->input('managed_context');
$hub = App::make('GlobalScopeHub');
if ($managed_context == null) {
$managed_context = $user->context->id;
$hub->setContext($managed_context);
}
else if ($managed_context == 0) {
$hub->enable(false);
}
else {
$hub->setContext($managed_context);
}
}
return $next($request);
}
}
This set ups the global scoping status given a special optional parameter attached to the request, managed_context
: if it is not defined, the code acts as always done (using the context of the current user as a filter); if it is 0, the whole context global scope is disabled; otherwise, the defined context is applied. This is of course to be fixed with a bit of access control, to verify the user has actual access to the required context, but you got the point.
This permits me to fix the few HTTP requests acting on a cross-context environment, adding the managed_context
parameter, and in any sensitive portion of the code I can add
App::make('GlobalScopeHub')->setContext($context_id);
to change the context on the fly in very particular conditions, without having to change all other existing code handling complex relationships across the data model.