Skip to content

Implement Scope-Based Access Control (Branch/Location/Division) #167

@kevalyq

Description

@kevalyq

Feature Description

Implement scope-based access control to restrict permissions by organizational unit (branch, location, division). This allows fine-grained control where users only see/manage data relevant to their assigned scope.

Extracted from: Issue #5 (RBAC System) - Optional Feature

User Stories

  • As a Branch Manager, I want to only see employees from my branch so I don't accidentally access other branches' data
  • As a Regional Manager, I want to manage multiple branches but not others outside my region
  • As a Client, I want to only view shifts at locations I'm contracted for
  • As an Admin, I want to assign scope constraints when assigning roles so access is properly limited

Acceptance Criteria

Core Scope Implementation

  • Database Schema

    • Add scope columns to model_has_roles pivot table:
      • branch_id (BIGINT, nullable, FK to branches)
      • location_id (BIGINT, nullable, FK to locations)
      • division_id (BIGINT, nullable, FK to divisions)
    • Add scope columns to model_has_permissions (direct permissions):
      • Same columns as above
    • Add indexes for performance
  • Scope Validation

    • Middleware: EnsureScopeAccess
    • Checks if user's assigned scope matches requested resource
    • Example: User with branch_id=5 can only access employees.read for branch 5
  • API Support

    • Role assignment with scope:
      POST /v1/users/{id}/roles
      {
        "role": "Manager",
        "scope": {
          "branch_id": 5
        }
      }
    • Direct permission with scope:
      POST /v1/users/{id}/permissions
      {
        "permissions": ["employees.read"],
        "scope": {
          "branch_id": 5,
          "location_id": 12
        }
      }
  • Query Scoping

    • Global scope: ScopeableScope trait
    • Automatically filters queries based on user's scope
    • Example: Employee::all() only returns branch 5 employees for branch manager

Authorization Updates

  • Policy Changes

    • Update EmployeePolicy to check scope
    • Update ShiftPolicy to check scope
    • Update WorkInstructionPolicy to check scope
    • New method: canAccessScope(User $user, Model $resource): bool
  • Permission Resolution

    • Update permission checking to include scope:
      // Old: $user->can('employees.read');
      // New: $user->can('employees.read', ['branch_id' => 5]);

Edge Cases

  • Multiple Scopes

    • User assigned to multiple branches: Access union of all branches
    • Example: Regional Manager with branches [5, 7, 9]
  • Scope Inheritance

    • Division scope includes all branches in that division
    • Region scope includes all branches/divisions in region
  • Null Scope = Global Access

    • Admin role with no scope constraints = access all
    • Document explicitly in role assignment

Testing

  • Feature test: Branch manager can only read employees in their branch
  • Feature test: Branch manager cannot read employees from other branches
  • Feature test: Regional manager can read employees from multiple assigned branches
  • Feature test: Admin with no scope can read all employees
  • Feature test: Direct permission respects scope constraints
  • Feature test: Multiple scopes (branch 5 + branch 7) work correctly
  • Unit test: canAccessScope() policy method logic
  • Integration test: Scope + temporal roles combined

Documentation

Technical Notes

Database Schema

// Migration: Add scope columns to model_has_roles
Schema::table('model_has_roles', function (Blueprint $table) {
    $table->unsignedBigInteger('branch_id')->nullable()->after('tenant_id');
    $table->unsignedBigInteger('location_id')->nullable()->after('branch_id');
    $table->unsignedBigInteger('division_id')->nullable()->after('location_id');
    
    $table->foreign('branch_id')->references('id')->on('branches')->onDelete('cascade');
    $table->foreign('location_id')->references('id')->on('locations')->onDelete('cascade');
    $table->foreign('division_id')->references('id')->on('divisions')->onDelete('cascade');
    
    $table->index(['branch_id', 'model_id']);
    $table->index(['location_id', 'model_id']);
    $table->index(['division_id', 'model_id']);
});

// Same for model_has_permissions (direct permissions)

Middleware Implementation

// Middleware/EnsureScopeAccess.php
class EnsureScopeAccess
{
    public function handle(Request $request, Closure $next, string $scopeType = 'branch')
    {
        $user = $request->user();
        $resourceId = $request->route($scopeType . '_id');
        
        // Get user's allowed scopes
        $allowedScopes = $user->roles()
            ->pluck($scopeType . '_id')
            ->merge($user->permissions()->pluck($scopeType . '_id'))
            ->filter()
            ->unique()
            ->toArray();
        
        // If no scopes assigned = global access (Admin)
        if (empty($allowedScopes)) {
            return $next($request);
        }
        
        // Check if requested resource is in allowed scopes
        if (!in_array($resourceId, $allowedScopes)) {
            abort(403, 'You do not have access to this ' . $scopeType);
        }
        
        return $next($request);
    }
}

// Usage in routes/api.php
Route::middleware(['auth:sanctum', 'scope:branch'])->group(function () {
    Route::get('/branches/{branch_id}/employees', [EmployeeController::class, 'index']);
});

Global Query Scope

// Traits/Scopeable.php
trait Scopeable
{
    protected static function bootScopeable()
    {
        static::addGlobalScope('scope', function (Builder $builder) {
            $user = auth()->user();
            
            if (!$user) {
                return;
            }
            
            // Get user's allowed branch IDs
            $branchIds = $user->roles()
                ->pluck('branch_id')
                ->merge($user->permissions()->pluck('branch_id'))
                ->filter()
                ->unique()
                ->toArray();
            
            // If no branches assigned = global access
            if (empty($branchIds)) {
                return;
            }
            
            // Filter by user's branches
            $builder->whereIn('branch_id', $branchIds);
        });
    }
}

// Usage in models
class Employee extends Model
{
    use Scopeable;
    
    // Automatically filters by branch when querying:
    // Employee::all() -> only returns user's branch employees
}

Permission Checking with Scope

// EmployeePolicy.php
public function view(User $user, Employee $employee): bool
{
    // Check permission
    if (!$user->can('employees.read')) {
        return false;
    }
    
    // Check scope
    return $this->canAccessScope($user, $employee);
}

protected function canAccessScope(User $user, Employee $employee): bool
{
    // Get user's allowed branch IDs from roles + direct permissions
    $allowedBranches = $user->roles()
        ->pluck('branch_id')
        ->merge($user->permissions()->pluck('branch_id'))
        ->filter()
        ->unique()
        ->toArray();
    
    // No scope = global access (Admin)
    if (empty($allowedBranches)) {
        return true;
    }
    
    // Check if employee's branch is in allowed scopes
    return in_array($employee->branch_id, $allowedBranches);
}

API Request/Response Examples

Assign Role with Branch Scope:

POST /v1/users/123/roles
{
  "role": "Manager",
  "scope": {
    "branch_id": 5
  },
  "valid_until": "2025-12-31T23:59:59Z"
}

Response:
{
  "data": {
    "role": "Manager",
    "scope": {
      "branch_id": 5,
      "branch_name": "Berlin Hauptbahnhof"
    },
    "valid_until": "2025-12-31T23:59:59Z",
    "assigned_at": "2025-11-15T10:00:00Z"
  }
}

Assign Role with Multiple Branch Scopes (Regional Manager):

POST /v1/users/123/roles
{
  "role": "Regional Manager",
  "scope": {
    "branch_ids": [5, 7, 9]  // Array for multi-branch
  }
}

Dependencies

Implementation Strategy

Phase 1: Database Schema (Week 1)

  • Migration to add scope columns
  • Update seeders

Phase 2: Core Logic (Week 2)

  • Implement Scopeable trait
  • Update permission checking
  • Add middleware

Phase 3: Policy Updates (Week 2)

  • Update all existing policies
  • Add canAccessScope() methods

Phase 4: API Updates (Week 3)

  • Update role assignment endpoints
  • Update permission endpoints
  • Add validation

Phase 5: Testing & Documentation (Week 3)

  • Comprehensive feature tests
  • Update documentation
  • Integration tests

Edge Cases to Consider

  1. User transferred to different branch

    • Solution: Revoke old scope, assign new scope (or update existing)
  2. Branch deleted while user has scope

    • Solution: Cascade delete via FK constraint removes role assignment
  3. User needs temporary access to different branch

    • Solution: Assign temporal role with new branch scope
  4. Regional Manager promoted to Admin

    • Solution: Assign Admin role with NULL scope (global access)
  5. Direct permission conflicts with role scope

    • Solution: Direct permission scope takes precedence (more specific)

Breaking Changes

⚠️ BREAKING CHANGES ALLOWED (v0.x.x):

  • New required scope parameter in role assignment API
  • Existing role assignments will have NULL scope (global access) after migration
  • Queries may return fewer results due to scope filtering

Migration Path:

  1. Run migration to add scope columns (nullable)
  2. Existing assignments keep NULL scope = global access
  3. New assignments require explicit scope (or NULL for Admin)

Milestone

🎯 v0.4.0 - Advanced RBAC features (after Employee Management)

Labels

  • type: feature
  • priority: medium 🟡
  • area: rbac 🔐
  • effort: L (3 weeks)
  • depends-on: #68 🔗

Related Issues


Note: This feature was originally listed in Issue #5 acceptance criteria but not implemented in Phases 1-4. Extracted to separate issue for better tracking. Implementation should begin AFTER Issue #68 (Employee Management) is complete.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    Status

    💡 Ideas

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions