Skip to content

Multi-tenant Authorization System

Difficulty: Advanced | Time: 2+ hours | Integration: Policies, Custom Gates, Multi-tenancy, DI

Build a sophisticated authorization system supporting multiple tenant types with complex permission hierarchies, role inheritance, and specification-driven access control.

The Challenge: Complex Multi-tenant Authorization

Your SaaS application serves multiple tenant types (organizations, teams, personal accounts) with complex permission structures, role hierarchies, and resource-specific access rules that traditional Laravel authorization cannot handle elegantly.

🔍 The Problem: Authorization Spaghetti

Here's the nightmarish authorization code scattered across your application:

php
<?php

namespace App\Http\Controllers;

use App\Models\User;
use App\Models\Document;
use App\Models\Tenant;
use Illuminate\Http\Request;

class DocumentController extends Controller
{
    public function show(Request $request, Document $document)
    {
        $user = $request->user();
        $tenant = $request->tenant(); // Custom middleware sets this
        
        // Complex authorization logic mixed with business logic
        if (!$tenant) {
            abort(403, 'No tenant context');
        }
        
        // Check if user belongs to tenant
        $userTenant = $user->tenants()
            ->where('tenant_id', $tenant->id)
            ->first();
            
        if (!$userTenant) {
            abort(403, 'User not member of tenant');
        }
        
        // Check if document belongs to tenant
        if ($document->tenant_id !== $tenant->id) {
            abort(403, 'Document not in tenant');
        }
        
        // Role-based permissions (getting complex!)
        $userRole = $userTenant->pivot->role;
        
        if ($document->is_confidential) {
            // Only admins and managers can see confidential docs
            if (!in_array($userRole, ['admin', 'manager'])) {
                abort(403, 'Insufficient permissions for confidential document');
            }
            
            // But in enterprise tenants, senior members can also access
            if ($tenant->type === 'enterprise' && $userRole === 'senior_member') {
                // Allow access
            } elseif ($tenant->type === 'enterprise' && $userRole !== 'admin' && $userRole !== 'manager') {
                abort(403, 'Enterprise confidential access denied');
            }
        }
        
        // Document status permissions
        if ($document->status === 'draft') {
            // Only document owner or admins can see drafts
            if ($document->created_by !== $user->id && $userRole !== 'admin') {
                abort(403, 'Cannot access draft document');
            }
        }
        
        // Time-based access (getting ridiculous!)
        if ($document->access_expires_at && $document->access_expires_at < now()) {
            if ($userRole !== 'admin') {
                abort(403, 'Document access has expired');
            }
        }
        
        // Department-based access for enterprise tenants
        if ($tenant->type === 'enterprise' && $document->department_id) {
            $userDepartments = $user->departments()
                ->where('tenant_id', $tenant->id)
                ->pluck('department_id')
                ->toArray();
                
            if (!in_array($document->department_id, $userDepartments) && $userRole !== 'admin') {
                abort(403, 'Department access denied');
            }
        }
        
        return view('documents.show', compact('document'));
    }
    
    public function update(Request $request, Document $document)
    {
        // DUPLICATE authorization logic again! 😱
        $user = $request->user();
        $tenant = $request->tenant();
        
        // ... repeat all the same logic with slight variations for editing
        
        if ($document->is_locked && !in_array($userRole, ['admin'])) {
            abort(403, 'Document is locked');
        }
        
        // ... more duplicate code
    }
    
    public function delete(Request $request, Document $document)
    {
        // EVEN MORE duplicate logic with delete-specific rules
        // ... this is getting out of hand
    }
}

🎯 Authorization Requirements Analysis

Let's identify the complex authorization rules:

  1. Tenant Membership: User must belong to the tenant
  2. Resource Ownership: Resources must belong to the correct tenant
  3. Role Hierarchies: Admin > Manager > Senior Member > Member > Guest
  4. Tenant Type Rules: Enterprise tenants have different permissions than Team/Personal
  5. Resource-Specific Rules: Confidential, draft, expired, locked documents
  6. Department-Based Access: Enterprise tenants support department restrictions
  7. Time-Based Access: Temporary access permissions and expiration
  8. Action-Specific Permissions: View vs Edit vs Delete vs Admin actions

Step 1: Generate Authorization Specifications

Using the Artisan command to create our authorization specifications:

bash
# Core tenant specifications
php artisan make:specification Tenant/TenantMembershipSpecification --model=User
php artisan make:specification Tenant/TenantResourceOwnershipSpecification --model=Resource
php artisan make:specification Tenant/RoleHierarchySpecification --model=User
php artisan make:specification Tenant/TenantTypePermissionSpecification --model=User

# Document-specific specifications  
php artisan make:specification Document/ConfidentialDocumentAccessSpecification --model=Document
php artisan make:specification Document/DraftDocumentAccessSpecification --model=Document
php artisan make:specification Document/TimeBasedAccessSpecification --model=Document
php artisan make:specification Document/DepartmentAccessSpecification --model=Document
php artisan make:specification Document/DocumentStatusSpecification --model=Document

# Composite specifications for actions
php artisan make:specification Authorization/DocumentViewSpecification --composite
php artisan make:specification Authorization/DocumentEditSpecification --composite
php artisan make:specification Authorization/DocumentDeleteSpecification --composite
php artisan make:specification Authorization/DocumentAdminSpecification --composite

Step 2: Implement Core Tenant Specifications

TenantMembershipSpecification

php
<?php

namespace App\Specifications\Tenant;

use App\Models\User;
use App\Models\Tenant;
use DangerWayne\LaravelSpecifications\AbstractSpecification;
use Illuminate\Database\Eloquent\Builder;

class TenantMembershipSpecification extends AbstractSpecification
{
    public function __construct(
        private readonly Tenant $tenant,
        private readonly ?array $allowedStatuses = ['active', 'pending']
    ) {}
    
    public function isSatisfiedBy(mixed $candidate): bool
    {
        if (!$candidate instanceof User) {
            return false;
        }
        
        $membership = $candidate->tenants()
            ->where('tenant_id', $this->tenant->id)
            ->first();
            
        if (!$membership) {
            return false;
        }
        
        return in_array($membership->pivot->status, $this->allowedStatuses);
    }
    
    public function toQuery(Builder $query): Builder
    {
        return $query->whereHas('tenants', function ($q) {
            $q->where('tenant_id', $this->tenant->id)
              ->whereIn('status', $this->allowedStatuses);
        });
    }
    
    public function getUserRole(User $user): ?string
    {
        $membership = $user->tenants()
            ->where('tenant_id', $this->tenant->id)
            ->first();
            
        return $membership?->pivot->role;
    }
    
    public function getMembershipDetails(User $user): ?array
    {
        $membership = $user->tenants()
            ->where('tenant_id', $this->tenant->id)
            ->first();
            
        if (!$membership) {
            return null;
        }
        
        return [
            'role' => $membership->pivot->role,
            'status' => $membership->pivot->status,
            'joined_at' => $membership->pivot->created_at,
            'permissions' => $membership->pivot->permissions ?? [],
        ];
    }
}

RoleHierarchySpecification

php
<?php

namespace App\Specifications\Tenant;

use App\Models\User;
use App\Models\Tenant;
use DangerWayne\LaravelSpecifications\AbstractSpecification;
use Illuminate\Database\Eloquent\Builder;

class RoleHierarchySpecification extends AbstractSpecification
{
    private array $roleHierarchy = [
        'admin' => 100,
        'manager' => 80,
        'senior_member' => 60,
        'member' => 40,
        'guest' => 20,
    ];
    
    public function __construct(
        private readonly Tenant $tenant,
        private readonly string $minimumRole
    ) {}
    
    public function isSatisfiedBy(mixed $candidate): bool
    {
        if (!$candidate instanceof User) {
            return false;
        }
        
        $membershipSpec = new TenantMembershipSpecification($this->tenant);
        
        if (!$membershipSpec->isSatisfiedBy($candidate)) {
            return false;
        }
        
        $userRole = $membershipSpec->getUserRole($candidate);
        
        return $this->getRoleWeight($userRole) >= $this->getRoleWeight($this->minimumRole);
    }
    
    public function toQuery(Builder $query): Builder
    {
        $minimumWeight = $this->getRoleWeight($this->minimumRole);
        $allowedRoles = [];
        
        foreach ($this->roleHierarchy as $role => $weight) {
            if ($weight >= $minimumWeight) {
                $allowedRoles[] = $role;
            }
        }
        
        return $query->whereHas('tenants', function ($q) use ($allowedRoles) {
            $q->where('tenant_id', $this->tenant->id)
              ->whereIn('role', $allowedRoles);
        });
    }
    
    private function getRoleWeight(string $role): int
    {
        return $this->roleHierarchy[$role] ?? 0;
    }
    
    public function hasHigherRole(User $user, string $comparisonRole): bool
    {
        $membershipSpec = new TenantMembershipSpecification($this->tenant);
        $userRole = $membershipSpec->getUserRole($user);
        
        return $this->getRoleWeight($userRole) > $this->getRoleWeight($comparisonRole);
    }
    
    public function canDelegate(User $user, string $targetRole): bool
    {
        $membershipSpec = new TenantMembershipSpecification($this->tenant);
        $userRole = $membershipSpec->getUserRole($user);
        
        // Users can only delegate to roles lower than their own
        return $this->getRoleWeight($userRole) > $this->getRoleWeight($targetRole);
    }
}

TenantTypePermissionSpecification

php
<?php

namespace App\Specifications\Tenant;

use App\Models\User;
use App\Models\Tenant;
use DangerWayne\LaravelSpecifications\AbstractSpecification;
use Illuminate\Database\Eloquent\Builder;

class TenantTypePermissionSpecification extends AbstractSpecification
{
    private array $tenantTypePermissions = [
        'enterprise' => [
            'department_access' => true,
            'advanced_reporting' => true,
            'bulk_operations' => true,
            'api_access' => true,
            'custom_roles' => true,
        ],
        'team' => [
            'department_access' => false,
            'advanced_reporting' => true,
            'bulk_operations' => false,
            'api_access' => true,
            'custom_roles' => false,
        ],
        'personal' => [
            'department_access' => false,
            'advanced_reporting' => false,
            'bulk_operations' => false,
            'api_access' => false,
            'custom_roles' => false,
        ],
    ];
    
    public function __construct(
        private readonly Tenant $tenant,
        private readonly string $permission
    ) {}
    
    public function isSatisfiedBy(mixed $candidate): bool
    {
        if (!$candidate instanceof User) {
            return false;
        }
        
        $membershipSpec = new TenantMembershipSpecification($this->tenant);
        
        if (!$membershipSpec->isSatisfiedBy($candidate)) {
            return false;
        }
        
        return $this->tenantHasPermission($this->permission);
    }
    
    public function toQuery(Builder $query): Builder
    {
        $allowedTenantTypes = [];
        
        foreach ($this->tenantTypePermissions as $type => $permissions) {
            if ($permissions[$this->permission] ?? false) {
                $allowedTenantTypes[] = $type;
            }
        }
        
        return $query->whereHas('tenants', function ($q) use ($allowedTenantTypes) {
            $q->where('tenant_id', $this->tenant->id)
              ->whereIn('type', $allowedTenantTypes);
        });
    }
    
    private function tenantHasPermission(string $permission): bool
    {
        $tenantType = $this->tenant->type;
        
        return $this->tenantTypePermissions[$tenantType][$permission] ?? false;
    }
    
    public function getAllowedPermissions(): array
    {
        $tenantType = $this->tenant->type;
        
        return array_keys(
            array_filter($this->tenantTypePermissions[$tenantType] ?? [])
        );
    }
}

Step 3: Document-Specific Specifications

ConfidentialDocumentAccessSpecification

php
<?php

namespace App\Specifications\Document;

use App\Models\User;
use App\Models\Document;
use App\Models\Tenant;
use App\Specifications\Tenant\RoleHierarchySpecification;
use App\Specifications\Tenant\TenantTypePermissionSpecification;
use DangerWayne\LaravelSpecifications\AbstractSpecification;
use Illuminate\Database\Eloquent\Builder;

class ConfidentialDocumentAccessSpecification extends AbstractSpecification
{
    public function __construct(
        private readonly User $user,
        private readonly Tenant $tenant
    ) {}
    
    public function isSatisfiedBy(mixed $candidate): bool
    {
        if (!$candidate instanceof Document) {
            return false;
        }
        
        // Non-confidential documents are accessible to all tenant members
        if (!$candidate->is_confidential) {
            return true;
        }
        
        // Admins and managers always have access
        $roleSpec = new RoleHierarchySpecification($this->tenant, 'manager');
        if ($roleSpec->isSatisfiedBy($this->user)) {
            return true;
        }
        
        // Enterprise tenants: senior members can access confidential docs
        if ($this->tenant->type === 'enterprise') {
            $seniorRoleSpec = new RoleHierarchySpecification($this->tenant, 'senior_member');
            if ($seniorRoleSpec->isSatisfiedBy($this->user)) {
                return true;
            }
        }
        
        // Document owner can always access their own confidential documents
        if ($candidate->created_by === $this->user->id) {
            return true;
        }
        
        // Check if user has explicit permission for this document
        if ($candidate->explicitPermissions()->where('user_id', $this->user->id)->exists()) {
            return true;
        }
        
        return false;
    }
    
    public function toQuery(Builder $query): Builder
    {
        return $query->where(function ($q) {
            // Non-confidential documents
            $q->where('is_confidential', false)
              // OR user has sufficient role
              ->orWhereHas('tenant', function ($tenantQuery) {
                  // This is complex - in practice, you might handle this at the collection level
                  $tenantQuery->where('id', $this->tenant->id);
              })
              // OR user is document owner
              ->orWhere('created_by', $this->user->id)
              // OR user has explicit permission
              ->orWhereHas('explicitPermissions', function ($permQuery) {
                  $permQuery->where('user_id', $this->user->id);
              });
        });
    }
    
    public function getAccessReason(Document $document): string
    {
        if (!$document->is_confidential) {
            return 'Document is not confidential';
        }
        
        $roleSpec = new RoleHierarchySpecification($this->tenant, 'manager');
        if ($roleSpec->isSatisfiedBy($this->user)) {
            return 'User has management role';
        }
        
        if ($this->tenant->type === 'enterprise') {
            $seniorRoleSpec = new RoleHierarchySpecification($this->tenant, 'senior_member');
            if ($seniorRoleSpec->isSatisfiedBy($this->user)) {
                return 'Senior member in enterprise tenant';
            }
        }
        
        if ($document->created_by === $this->user->id) {
            return 'Document owner';
        }
        
        if ($document->explicitPermissions()->where('user_id', $this->user->id)->exists()) {
            return 'Explicit permission granted';
        }
        
        return 'Access denied';
    }
}

DepartmentAccessSpecification

php
<?php

namespace App\Specifications\Document;

use App\Models\User;
use App\Models\Document;
use App\Models\Tenant;
use App\Specifications\Tenant\RoleHierarchySpecification;
use DangerWayne\LaravelSpecifications\AbstractSpecification;
use Illuminate\Database\Eloquent\Builder;

class DepartmentAccessSpecification extends AbstractSpecification
{
    public function __construct(
        private readonly User $user,
        private readonly Tenant $tenant
    ) {}
    
    public function isSatisfiedBy(mixed $candidate): bool
    {
        if (!$candidate instanceof Document) {
            return false;
        }
        
        // Documents without department restrictions are accessible to all
        if (!$candidate->department_id) {
            return true;
        }
        
        // Only enterprise tenants support departments
        if ($this->tenant->type !== 'enterprise') {
            return true; // No department restrictions for non-enterprise
        }
        
        // Admins bypass department restrictions
        $adminSpec = new RoleHierarchySpecification($this->tenant, 'admin');
        if ($adminSpec->isSatisfiedBy($this->user)) {
            return true;
        }
        
        // Check if user belongs to the document's department
        $userDepartments = $this->user->departments()
            ->where('tenant_id', $this->tenant->id)
            ->pluck('department_id')
            ->toArray();
            
        return in_array($candidate->department_id, $userDepartments);
    }
    
    public function toQuery(Builder $query): Builder
    {
        // For enterprise tenants with department restrictions
        if ($this->tenant->type === 'enterprise') {
            $userDepartments = $this->user->departments()
                ->where('tenant_id', $this->tenant->id)
                ->pluck('department_id')
                ->toArray();
                
            return $query->where(function ($q) use ($userDepartments) {
                $q->whereNull('department_id') // No department restriction
                  ->orWhereIn('department_id', $userDepartments); // User's departments
            });
        }
        
        return $query; // No department restrictions for non-enterprise
    }
    
    public function getUserDepartments(): array
    {
        return $this->user->departments()
            ->where('tenant_id', $this->tenant->id)
            ->pluck('name', 'department_id')
            ->toArray();
    }
}

Step 4: Composite Authorization Specifications

DocumentViewSpecification

php
<?php

namespace App\Specifications\Authorization;

use App\Models\User;
use App\Models\Document;
use App\Models\Tenant;
use App\Specifications\Tenant\TenantMembershipSpecification;
use App\Specifications\Tenant\TenantResourceOwnershipSpecification;
use App\Specifications\Document\ConfidentialDocumentAccessSpecification;
use App\Specifications\Document\DraftDocumentAccessSpecification;
use App\Specifications\Document\TimeBasedAccessSpecification;
use App\Specifications\Document\DepartmentAccessSpecification;
use DangerWayne\LaravelSpecifications\AbstractSpecification;

class DocumentViewSpecification extends AbstractSpecification
{
    private $compositeSpec;
    
    public function __construct(User $user, Tenant $tenant)
    {
        // Build composite specification for document viewing
        $this->compositeSpec = (new TenantMembershipSpecification($tenant))
            ->and(new TenantResourceOwnershipSpecification($tenant))
            ->and(new ConfidentialDocumentAccessSpecification($user, $tenant))
            ->and(new DraftDocumentAccessSpecification($user, $tenant))
            ->and(new TimeBasedAccessSpecification($user, $tenant))
            ->and(new DepartmentAccessSpecification($user, $tenant));
    }
    
    public function isSatisfiedBy(mixed $candidate): bool
    {
        return $this->compositeSpec->isSatisfiedBy($candidate);
    }
    
    public function toQuery($query)
    {
        return $this->compositeSpec->toQuery($query);
    }
    
    public function getFailureReasons(User $user, Document $document): array
    {
        $failures = [];
        $specs = [
            'membership' => new TenantMembershipSpecification($this->tenant),
            'ownership' => new TenantResourceOwnershipSpecification($this->tenant),
            'confidential' => new ConfidentialDocumentAccessSpecification($user, $this->tenant),
            'draft' => new DraftDocumentAccessSpecification($user, $this->tenant),
            'time_based' => new TimeBasedAccessSpecification($user, $this->tenant),
            'department' => new DepartmentAccessSpecification($user, $this->tenant),
        ];
        
        foreach ($specs as $name => $spec) {
            if (!$spec->isSatisfiedBy($document)) {
                $failures[] = $name;
            }
        }
        
        return $failures;
    }
}

Step 5: Custom Gate Integration

Register specifications as Laravel Gates:

php
<?php

namespace App\Providers;

use App\Models\User;
use App\Models\Document;
use App\Models\Tenant;
use App\Specifications\Authorization\DocumentViewSpecification;
use App\Specifications\Authorization\DocumentEditSpecification;
use App\Specifications\Authorization\DocumentDeleteSpecification;
use Illuminate\Foundation\Support\Providers\AuthServiceProvider as ServiceProvider;
use Illuminate\Support\Facades\Gate;

class AuthServiceProvider extends ServiceProvider
{
    public function boot(): void
    {
        Gate::define('view-document', function (User $user, Document $document, ?Tenant $tenant = null) {
            $tenant = $tenant ?? $this->getCurrentTenant();
            
            if (!$tenant) {
                return false;
            }
            
            $spec = new DocumentViewSpecification($user, $tenant);
            return $spec->isSatisfiedBy($document);
        });
        
        Gate::define('edit-document', function (User $user, Document $document, ?Tenant $tenant = null) {
            $tenant = $tenant ?? $this->getCurrentTenant();
            
            if (!$tenant) {
                return false;
            }
            
            $spec = new DocumentEditSpecification($user, $tenant);
            return $spec->isSatisfiedBy($document);
        });
        
        Gate::define('delete-document', function (User $user, Document $document, ?Tenant $tenant = null) {
            $tenant = $tenant ?? $this->getCurrentTenant();
            
            if (!$tenant) {
                return false;
            }
            
            $spec = new DocumentDeleteSpecification($user, $tenant);
            return $spec->isSatisfiedBy($document);
        });
        
        // Dynamic permission checking
        Gate::define('tenant-permission', function (User $user, string $permission, ?Tenant $tenant = null) {
            $tenant = $tenant ?? $this->getCurrentTenant();
            
            if (!$tenant) {
                return false;
            }
            
            $spec = new TenantTypePermissionSpecification($tenant, $permission);
            return $spec->isSatisfiedBy($user);
        });
    }
    
    private function getCurrentTenant(): ?Tenant
    {
        return app('current.tenant');
    }
}

Step 6: Policy Integration

Create a comprehensive document policy:

php
<?php

namespace App\Policies;

use App\Models\User;
use App\Models\Document;
use App\Models\Tenant;
use App\Specifications\Authorization\DocumentViewSpecification;
use App\Specifications\Authorization\DocumentEditSpecification;
use App\Specifications\Authorization\DocumentDeleteSpecification;

class DocumentPolicy
{
    public function __construct()
    {
        // Dependencies injected automatically by Laravel
    }
    
    public function view(User $user, Document $document): bool
    {
        $tenant = $this->getCurrentTenant();
        
        if (!$tenant) {
            return false;
        }
        
        $spec = new DocumentViewSpecification($user, $tenant);
        return $spec->isSatisfiedBy($document);
    }
    
    public function update(User $user, Document $document): bool
    {
        $tenant = $this->getCurrentTenant();
        
        if (!$tenant) {
            return false;
        }
        
        $spec = new DocumentEditSpecification($user, $tenant);
        return $spec->isSatisfiedBy($document);
    }
    
    public function delete(User $user, Document $document): bool
    {
        $tenant = $this->getCurrentTenant();
        
        if (!$tenant) {
            return false;
        }
        
        $spec = new DocumentDeleteSpecification($user, $tenant);
        return $spec->isSatisfiedBy($document);
    }
    
    public function viewAny(User $user): bool
    {
        $tenant = $this->getCurrentTenant();
        
        if (!$tenant) {
            return false;
        }
        
        $membershipSpec = new TenantMembershipSpecification($tenant);
        return $membershipSpec->isSatisfiedBy($user);
    }
    
    public function create(User $user): bool
    {
        $tenant = $this->getCurrentTenant();
        
        if (!$tenant) {
            return false;
        }
        
        $roleSpec = new RoleHierarchySpecification($tenant, 'member');
        return $roleSpec->isSatisfiedBy($user);
    }
    
    private function getCurrentTenant(): ?Tenant
    {
        return app('current.tenant');
    }
}

Step 7: Clean Controller Implementation

Now your controller becomes incredibly clean:

php
<?php

namespace App\Http\Controllers;

use App\Models\Document;
use Illuminate\Http\Request;

class DocumentController extends Controller
{
    public function show(Request $request, Document $document)
    {
        // Authorization handled by policy/gate
        $this->authorize('view', $document);
        
        return view('documents.show', compact('document'));
    }
    
    public function update(Request $request, Document $document)
    {
        $this->authorize('update', $document);
        
        $validated = $request->validate([
            'title' => 'required|string|max:255',
            'content' => 'required|string',
        ]);
        
        $document->update($validated);
        
        return redirect()
            ->route('documents.show', $document)
            ->with('success', 'Document updated successfully');
    }
    
    public function destroy(Request $request, Document $document)
    {
        $this->authorize('delete', $document);
        
        $document->delete();
        
        return redirect()
            ->route('documents.index')
            ->with('success', 'Document deleted successfully');
    }
    
    // Bulk operations with specification-powered queries
    public function index(Request $request)
    {
        $this->authorize('viewAny', Document::class);
        
        $user = $request->user();
        $tenant = app('current.tenant');
        
        // Use specification to filter documents the user can access
        $spec = new DocumentViewSpecification($user, $tenant);
        $documents = $spec->toQuery(Document::query())
            ->with(['creator', 'department'])
            ->paginate(20);
        
        return view('documents.index', compact('documents'));
    }
}

Step 8: Advanced Testing

Integration Testing

php
<?php

namespace Tests\Feature;

use App\Models\User;
use App\Models\Document;
use App\Models\Tenant;
use App\Models\Department;
use Tests\TestCase;
use Illuminate\Foundation\Testing\RefreshDatabase;

class MultiTenantAuthorizationTest extends TestCase
{
    use RefreshDatabase;
    
    /** @test */
    public function enterprise_senior_members_can_access_confidential_documents()
    {
        // Setup enterprise tenant
        $tenant = Tenant::factory()->create(['type' => 'enterprise']);
        
        // Create departments
        $hrDept = Department::factory()->create(['tenant_id' => $tenant->id, 'name' => 'HR']);
        $itDept = Department::factory()->create(['tenant_id' => $tenant->id, 'name' => 'IT']);
        
        // Create users with different roles
        $admin = User::factory()->create();
        $seniorMember = User::factory()->create();
        $member = User::factory()->create();
        
        // Setup tenant memberships
        $admin->tenants()->attach($tenant->id, ['role' => 'admin', 'status' => 'active']);
        $seniorMember->tenants()->attach($tenant->id, ['role' => 'senior_member', 'status' => 'active']);
        $member->tenants()->attach($tenant->id, ['role' => 'member', 'status' => 'active']);
        
        // Setup department memberships
        $seniorMember->departments()->attach($hrDept->id);
        
        // Create confidential document in HR department
        $confidentialDoc = Document::factory()->create([
            'tenant_id' => $tenant->id,
            'department_id' => $hrDept->id,
            'is_confidential' => true,
            'created_by' => $admin->id,
        ]);
        
        // Test access
        $this->actingAs($admin);
        $response = $this->get(route('documents.show', $confidentialDoc));
        $response->assertOk(); // Admin can access
        
        $this->actingAs($seniorMember);
        $response = $this->get(route('documents.show', $confidentialDoc));
        $response->assertOk(); // Senior member in enterprise can access confidential docs
        
        $this->actingAs($member);
        $response = $this->get(route('documents.show', $confidentialDoc));
        $response->assertForbidden(); // Regular member cannot access
    }
    
    /** @test */
    public function team_tenant_senior_members_cannot_access_confidential_documents()
    {
        // Setup team tenant (not enterprise)
        $tenant = Tenant::factory()->create(['type' => 'team']);
        
        $seniorMember = User::factory()->create();
        $seniorMember->tenants()->attach($tenant->id, ['role' => 'senior_member', 'status' => 'active']);
        
        $confidentialDoc = Document::factory()->create([
            'tenant_id' => $tenant->id,
            'is_confidential' => true,
        ]);
        
        $this->actingAs($seniorMember);
        $response = $this->get(route('documents.show', $confidentialDoc));
        $response->assertForbidden(); // Senior member in team tenant cannot access confidential docs
    }
    
    /** @test */
    public function department_restrictions_work_correctly()
    {
        $tenant = Tenant::factory()->create(['type' => 'enterprise']);
        
        $hrDept = Department::factory()->create(['tenant_id' => $tenant->id]);
        $itDept = Department::factory()->create(['tenant_id' => $tenant->id]);
        
        $hrMember = User::factory()->create();
        $hrMember->tenants()->attach($tenant->id, ['role' => 'member', 'status' => 'active']);
        $hrMember->departments()->attach($hrDept->id);
        
        $hrDoc = Document::factory()->create([
            'tenant_id' => $tenant->id,
            'department_id' => $hrDept->id,
        ]);
        
        $itDoc = Document::factory()->create([
            'tenant_id' => $tenant->id,
            'department_id' => $itDept->id,
        ]);
        
        $this->actingAs($hrMember);
        
        $response = $this->get(route('documents.show', $hrDoc));
        $response->assertOk(); // Can access HR document
        
        $response = $this->get(route('documents.show', $itDoc));
        $response->assertForbidden(); // Cannot access IT document
    }
}

Specification Unit Tests

php
<?php

namespace Tests\Unit\Specifications;

use App\Models\User;
use App\Models\Tenant;
use App\Models\Document;
use App\Specifications\Tenant\RoleHierarchySpecification;
use App\Specifications\Document\ConfidentialDocumentAccessSpecification;
use Tests\TestCase;

class TenantAuthorizationSpecificationTest extends TestCase
{
    /** @test */
    public function role_hierarchy_specification_validates_permissions_correctly()
    {
        $tenant = Tenant::factory()->create();
        
        $admin = User::factory()->create();
        $manager = User::factory()->create();
        $member = User::factory()->create();
        
        $admin->tenants()->attach($tenant->id, ['role' => 'admin', 'status' => 'active']);
        $manager->tenants()->attach($tenant->id, ['role' => 'manager', 'status' => 'active']);
        $member->tenants()->attach($tenant->id, ['role' => 'member', 'status' => 'active']);
        
        $managerRoleSpec = new RoleHierarchySpecification($tenant, 'manager');
        
        $this->assertTrue($managerRoleSpec->isSatisfiedBy($admin)); // Admin > Manager
        $this->assertTrue($managerRoleSpec->isSatisfiedBy($manager)); // Manager = Manager
        $this->assertFalse($managerRoleSpec->isSatisfiedBy($member)); // Member < Manager
    }
    
    /** @test */
    public function confidential_document_access_respects_tenant_type()
    {
        $enterpriseTenant = Tenant::factory()->create(['type' => 'enterprise']);
        $teamTenant = Tenant::factory()->create(['type' => 'team']);
        
        $seniorMember = User::factory()->create();
        $seniorMember->tenants()->attach($enterpriseTenant->id, ['role' => 'senior_member', 'status' => 'active']);
        $seniorMember->tenants()->attach($teamTenant->id, ['role' => 'senior_member', 'status' => 'active']);
        
        $confidentialDoc = Document::factory()->create(['is_confidential' => true]);
        
        // Enterprise tenant: senior member can access
        $enterpriseSpec = new ConfidentialDocumentAccessSpecification($seniorMember, $enterpriseTenant);
        $this->assertTrue($enterpriseSpec->isSatisfiedBy($confidentialDoc));
        
        // Team tenant: senior member cannot access
        $teamSpec = new ConfidentialDocumentAccessSpecification($seniorMember, $teamTenant);
        $this->assertFalse($teamSpec->isSatisfiedBy($confidentialDoc));
    }
}

Key Learnings

This advanced example demonstrates:

Complex Authorization Logic: Multi-layered permission systems
Role Hierarchies: Sophisticated role-based access control
Tenant-Aware Logic: Different rules for different tenant types
Composite Specifications: Building complex authorization from simple rules
Laravel Integration: Gates, policies, and middleware working together
Performance Optimization: Query-level filtering with specifications
Comprehensive Testing: Integration and unit testing of complex scenarios

Next Steps

With your multi-tenant authorization system:

  • Add audit logging to track authorization decisions
  • Implement permission caching for high-traffic applications
  • Create admin interfaces for managing roles and permissions
  • Add API authentication with the same specification-driven logic

Ready for the ultimate challenge? Tackle Financial Risk Assessment with external API integration.

View Risk Assessment Example →

Released under the MIT License.