-
Notifications
You must be signed in to change notification settings - Fork 0
Description
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_rolespivot 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
- Add scope columns to
-
Scope Validation
- Middleware:
EnsureScopeAccess - Checks if user's assigned scope matches requested resource
- Example: User with
branch_id=5can only accessemployees.readfor branch 5
- Middleware:
-
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 } }
- Role assignment with scope:
-
Query Scoping
- Global scope:
ScopeableScopetrait - Automatically filters queries based on user's scope
- Example:
Employee::all()only returns branch 5 employees for branch manager
- Global scope:
Authorization Updates
-
Policy Changes
- Update
EmployeePolicyto check scope - Update
ShiftPolicyto check scope - Update
WorkInstructionPolicyto check scope - New method:
canAccessScope(User $user, Model $resource): bool
- Update
-
Permission Resolution
- Update permission checking to include scope:
// Old: $user->can('employees.read'); // New: $user->can('employees.read', ['branch_id' => 5]);
- Update permission checking to include scope:
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
- New guide:
docs/guides/scope-based-access.md - Update
docs/rbac-architecture.md- add scope section - Update
docs/api/rbac-endpoints.md- add scope parameters - Update Issue 🔐 Implement RBAC System (Role-Based Access Control) #5 - check off scope-based access checkbox
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
- Requires: Issue refactor: improve DRY and add pre-PR checklist #68 (Employee Management) - Must implement first to have
branchestable - Requires: Branch/Location/Division tables in database
- Blocked by: Issue refactor: improve DRY and add pre-PR checklist #68 (not yet started)
Implementation Strategy
Phase 1: Database Schema (Week 1)
- Migration to add scope columns
- Update seeders
Phase 2: Core Logic (Week 2)
- Implement
Scopeabletrait - 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
-
User transferred to different branch
- Solution: Revoke old scope, assign new scope (or update existing)
-
Branch deleted while user has scope
- Solution: Cascade delete via FK constraint removes role assignment
-
User needs temporary access to different branch
- Solution: Assign temporal role with new branch scope
-
Regional Manager promoted to Admin
- Solution: Assign Admin role with NULL scope (global access)
-
Direct permission conflicts with role scope
- Solution: Direct permission scope takes precedence (more specific)
Breaking Changes
- New required
scopeparameter 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:
- Run migration to add scope columns (nullable)
- Existing assignments keep NULL scope = global access
- 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
- Issue 🔐 Implement RBAC System (Role-Based Access Control) #5 (RBAC System) - Parent epic
- Issue refactor: improve DRY and add pre-PR checklist #68 (Employee Management) - Dependency (⏸️ blocked until this is done)
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
Labels
Type
Projects
Status