Skip to main content

Tenant-service policies

This page is the recipe for porting the identity-backend IdentityPolicy + 5 child pattern to a tenant-service. Identity introduced a Laravel Gate::policy + Gate::before registry in F6 and migrated 12 controllers to Gate::authorize($ability, $resource) in F-future-2; tenant-services follow the same pattern, with one adjustment: the principal isn't a local Eloquent User — it lives on the request as the auth_user payload that VerifyOAuthToken merges in.

The CRM service is the canonical port (cross-repo audit pattern, F-future-7); the same recipe applies verbatim to warehouse, webshop, webhook, analytics, email, and ws_proxy.

Why per-action policies

Up to F-future-7 every tenant-service authorized via the CheckPermission route middleware:

Route::get('/tasks/{id}', [TaskController::class, 'show'])
->middleware('permission:tasks.view');

That has three drawbacks identity already solved:

  1. Permission knowledge lives in routes, not next to the controller logic that needs it. Adding a guard inside an action requires a second hop into a service or another middleware.
  2. Defense-in-depth is awkward: you can't easily re-check the same permission later in the same request (e.g., per-row in a list).
  3. Tests have to spin a route + middleware to exercise authorization logic instead of unit-testing a Policy class.

The Policy + Gate registry replaces all three: policies are unit-tested PHP classes, controllers call $this->authorize('view', $task) declaratively, and the route-level middleware can stay as defense-in-depth or be retired entirely once the controller-level checks land everywhere.

The shape

Three pieces per service:

  1. App\Policies\BasePolicy — abstract base. Reads auth_user and user_policies off request(); delegates the evaluate() call to App\Services\PolicyEvaluator. Identical between services except for the tenant.{tenant_name}.{service}.{group}.{action} prefix.
  2. App\Policies\{Domain}\{Resource}Policy — one class per Eloquent model. ~10 lines: declare namespacePrefix() and override only the non-CRUD verbs (e.g. picking.pick, procurements.submit).
  3. App\Providers\AuthServiceProviderGate::policy() registry + Gate::before() system-admin short-circuit. Mirrors identity's AppServiceProvider::registerGates().

Reference port (CRM)

App\Policies\BasePolicy

abstract class BasePolicy
{
public function __construct(protected PolicyEvaluator $evaluator) {}

abstract protected function namespacePrefix(): string;

protected function abilityFor(string $action): string
{
$tenantName = (string) config('app.tenant_name');
return "tenant.{$tenantName}.{$this->namespacePrefix()}.{$action}";
}

protected function userCan(string $action, mixed $resource = null): bool
{
$authUser = $this->principal();
if ($authUser === null) return false;
if (!empty($authUser['is_system_admin']) || !empty($authUser['is_admin'])) return true;

$policies = $this->loadedPolicies();
if ($policies === null) return false;

return $this->evaluator->evaluate(
$policies,
$this->abilityFor($action),
$this->toResourceDTO($resource),
['tenant_id' => (string) config('app.tenant_id'), 'user_id' => $authUser['id'] ?? null],
);
}

public function viewAny(mixed $_user = null): bool { return $this->userCan('list'); }
public function view(mixed $_user = null, mixed $r = null): bool { return $this->userCan('view', $r); }
public function create(mixed $_user = null): bool { return $this->userCan('create'); }
public function update(mixed $_user = null, mixed $r = null): bool { return $this->userCan('update', $r); }
public function delete(mixed $_user = null, mixed $r = null): bool { return $this->userCan('delete', $r); }

private function principal(): ?array { /* request()->get('auth_user') */ }
private function loadedPolicies(): ?array { /* request()->attributes->get('user_policies') */ }
protected function toResourceDTO(mixed $r): ResourceDTO { /* coerce model → DTO */ }
}

Full source: tenant-backend-crm/app/Policies/BasePolicy.php.

A child policy (TaskPolicy)

class TaskPolicy extends BasePolicy
{
protected function namespacePrefix(): string
{
return 'crm.tasks';
}
}

The action view resolves to tenant.{tenant_name}.crm.tasks.view. Override abilityFor() only when the policy serves multiple non-CRUD verbs that don't follow {prefix}.{verb}.

App\Providers\AuthServiceProvider

class AuthServiceProvider extends ServiceProvider
{
protected $policies = [
Task::class => TaskPolicy::class,
];

public function boot(): void
{
$this->registerPolicies();

Gate::before(function ($user, $ability) {
$authUser = request()?->get('auth_user');
if (is_array($authUser) && (!empty($authUser['is_system_admin']) || !empty($authUser['is_admin']))) {
return true;
}
return null;
});
}
}

Register the provider in bootstrap/providers.php.

Controller hookup

Add use AuthorizesRequests to the base Controller:

abstract class Controller
{
use AuthorizesRequests;
}

Then call $this->authorize in the action:

public function destroy(Request $request, string $id): JsonResponse
{
$task = Task::find($id);
if (! $task) return response()->json(['message' => 'Task not found.'], 404);

$this->authorize('delete', $task);

$task->delete();
return response()->json(['message' => 'Task deleted.']);
}

The CheckPermission route middleware can stay attached as defense-in-depth — the dual check costs ~50µs and catches typos in either layer.

Pre-loaded policies

For Gate::authorize to work, request()->attributes->get('user_policies') must be populated. Today the CheckPermission middleware loads them when it runs; this works fine as long as every authorized route has the permission:X.Y.Z middleware attached.

If you want to drop the middleware entirely (mirroring identity's F-future-2 purge), extract a standalone LoadUserPolicies middleware that runs after VerifyOAuthToken and always populates user_policies. CRM hasn't done this yet — it's the next step in the F-future-7 follow-up.

Tests

Unit-test the Policy class directly — no HTTP needed:

public function test_user_with_matching_policy_can_delete_tasks(): void
{
request()->merge(['auth_user' => ['id' => 'u1', 'tenant_id' => 't1']]);
request()->attributes->set('user_policies', [
new PolicyDTO(/* … */, actions: ['tenant.test.crm.tasks.*'], /* … */),
]);

$this->assertTrue(app(TaskPolicy::class)->delete(null, $task));
}

See tenant-backend-crm/tests/Unit/Policies/TaskPolicyTest.php for the full set of fixtures (matching policy, no policy, system-admin bypass, no request context).

Porting the other 6 tenant-services

The recipe is mechanical — copy the BasePolicy file verbatim into each tenant-service, then add per-resource child policies as you need controller-level checks. The full domain map:

ServiceNamespace prefixExample child policies
crmcrm.{group}TaskPolicy, ProjectPolicy, CompanyPolicy, ContactPolicy, …
warehousewarehouse.{group}InventoryPolicy, WarehousePolicy, ProcurementPolicy, AuditPolicy, …
webshopwebshop.{group}ProductPolicy, CategoryPolicy, StorePolicy, AttributePolicy, …
webhookwebhook.{group}WebhookPolicy
analyticsanalytics.{group}EventPolicy, ApiKeyPolicy
emailemail.{group}AccountPolicy, MessagePolicy, TemplatePolicy, …
ws_proxyws_proxy.{group}ChannelPolicy, ConnectionPolicy

For each, the migration steps are:

  1. Copy the BasePolicy template (adjust namespacePrefix() defaults if the service has unusual non-CRUD verbs).
  2. Create App\Providers\AuthServiceProvider, register the policies, and add the Gate::before system-admin short-circuit. Add it to bootstrap/providers.php.
  3. Add use AuthorizesRequests to App\Http\Controllers\Controller.
  4. Drop in $this->authorize('action', $resource) calls incrementally — start with the highest-blast-radius destructive verbs (delete, scrap, revoke).
  5. Unit-test each new policy class with the request-fixture pattern.