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:
- 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.
- 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).
- 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:
App\Policies\BasePolicy— abstract base. Readsauth_useranduser_policiesoffrequest(); delegates theevaluate()call toApp\Services\PolicyEvaluator. Identical between services except for thetenant.{tenant_name}.{service}.{group}.{action}prefix.App\Policies\{Domain}\{Resource}Policy— one class per Eloquent model. ~10 lines: declarenamespacePrefix()and override only the non-CRUD verbs (e.g.picking.pick,procurements.submit).App\Providers\AuthServiceProvider—Gate::policy()registry +Gate::before()system-admin short-circuit. Mirrors identity'sAppServiceProvider::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:
| Service | Namespace prefix | Example child policies |
|---|---|---|
| crm | crm.{group} | TaskPolicy, ProjectPolicy, CompanyPolicy, ContactPolicy, … |
| warehouse | warehouse.{group} | InventoryPolicy, WarehousePolicy, ProcurementPolicy, AuditPolicy, … |
| webshop | webshop.{group} | ProductPolicy, CategoryPolicy, StorePolicy, AttributePolicy, … |
| webhook | webhook.{group} | WebhookPolicy |
| analytics | analytics.{group} | EventPolicy, ApiKeyPolicy |
email.{group} | AccountPolicy, MessagePolicy, TemplatePolicy, … | |
| ws_proxy | ws_proxy.{group} | ChannelPolicy, ConnectionPolicy |
For each, the migration steps are:
- Copy the BasePolicy template (adjust
namespacePrefix()defaults if the service has unusual non-CRUD verbs). - Create
App\Providers\AuthServiceProvider, register the policies, and add theGate::beforesystem-admin short-circuit. Add it tobootstrap/providers.php. - Add
use AuthorizesRequeststoApp\Http\Controllers\Controller. - Drop in
$this->authorize('action', $resource)calls incrementally — start with the highest-blast-radius destructive verbs (delete,scrap,revoke). - Unit-test each new policy class with the request-fixture pattern.