Dynamic Global Scope

Dynamic Global Scope
Photo by Markus Spiske / Unsplash

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.