ACL APIs Implementation Plan for Multifacility System (Final v2) Overview Implement comprehensive ACL APIs for a multifacility system where: Roles can be facility-specific (roles table has facility_code) Permissions are mapped to roles per facility (role_has_permissions has facility_code) Users get permissions from role-based + direct sources Uses existing AuditService for all audit logging Architecture Permission Flow User → user_assignments (facility_code, role_id) → Role (facility_code) → role_has_permissions (facility_code, permission_id) → Permission User → permission_users (facility_code, permission_id) → Permission (direct permissions) Effective Permissions = Union of (1) + (2) for a given facility Key Tables roles - with facility_code (role can belong to specific facility or be global) role_has_permissions - with facility_code (permission can be facility-scoped) permission_users - with facility_code (direct user permissions, facility-scoped) user_assignments - existing (user → facility → role) permissions - existing (system permissions) Database Schema Updates 1. Create role_has_permissions pivot table CREATE TABLE role_has_permissions ( id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, role_id BIGINT UNSIGNED NOT NULL, permission_id BIGINT UNSIGNED NOT NULL, facility_code VARCHAR(255) NULL, created_by BIGINT UNSIGNED NULL, updated_by BIGINT UNSIGNED NULL, created_at TIMESTAMP, updated_at TIMESTAMP, FOREIGN KEY (role_id) REFERENCES roles(id) ON DELETE CASCADE, FOREIGN KEY (permission_id) REFERENCES permissions(id) ON DELETE CASCADE, FOREIGN KEY (facility_code) REFERENCES facilities(code) ON DELETE CASCADE, FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL, FOREIGN KEY (updated_by) REFERENCES users(id) ON DELETE SET NULL, UNIQUE KEY unique_role_permission_facility (role_id, permission_id, facility_code), INDEX idx_role_facility (role_id, facility_code), INDEX idx_permission_facility (permission_id, facility_code) ); 2. Update permission_users table ALTER TABLE permission_users ADD COLUMN facility_code VARCHAR(255) NULL AFTER user_id, ADD COLUMN created_by BIGINT UNSIGNED NULL, ADD COLUMN updated_by BIGINT UNSIGNED NULL; ALTER TABLE permission_users ADD CONSTRAINT fk_permission_users_facility FOREIGN KEY (facility_code) REFERENCES facilities(code) ON DELETE CASCADE, ADD CONSTRAINT fk_permission_users_created_by FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL, ADD CONSTRAINT fk_permission_users_updated_by FOREIGN KEY (updated_by) REFERENCES users(id) ON DELETE SET NULL; DROP INDEX IF EXISTS permission_users_permission_id_user_id_unique ON permission_users; ALTER TABLE permission_users ADD UNIQUE KEY unique_user_permission_facility (user_id, permission_id, facility_code); 3. Update roles table ALTER TABLE roles ADD COLUMN facility_code VARCHAR(255) NULL AFTER description, ADD COLUMN is_default BOOLEAN DEFAULT FALSE AFTER status, ADD COLUMN is_locked BOOLEAN DEFAULT FALSE AFTER is_default, ADD COLUMN status TINYINT DEFAULT 1 AFTER updated_by; ALTER TABLE roles ADD CONSTRAINT fk_roles_facility FOREIGN KEY (facility_code) REFERENCES facilities(code) ON DELETE CASCADE; ALTER TABLE roles ADD INDEX idx_facility_code (facility_code); Note: facility_code = NULL → Global role (available across all facilities) facility_code = 'FAC-0001' → Facility-specific role (only for FAC-0001) API Endpoints A. Role Management (/api/v1/setting/acl/roles) All routes require superadmin middleware. 1. GET / - List all roles Query params: ?facility_code= (filter by facility) ?search= (search in name, description) ?is_default= (filter default roles) ?status= (filter by status) ?page=, ?per_page= Response: { "success": true, "data": { "roles": [ { "id": 1, "name": "physician", "display_name": "Physician", "description": "...", "facility_code": null, "is_global": true, "is_default": true, "is_locked": true, "status": 1, "permissions_count": 45, "users_count": 12, "created_at": "...", "updated_at": "..." }, { "id": 10, "name": "cardiology-specialist", "display_name": "Cardiology Specialist", "description": "...", "facility_code": "FAC-0001", "facility_name": "Main Hospital", "is_global": false, "is_default": false, "is_locked": false, "status": 1, "permissions_count": 20, "users_count": 3, "created_at": "...", "updated_at": "..." } ], "pagination": {...} } } 2. GET /{id} - Get role details with permissions Response: { "success": true, "data": { "role": { "id": 1, "name": "physician", "display_name": "Physician", "description": "...", "facility_code": null, "is_global": true, "is_default": true, "is_locked": true, "status": 1 }, "permissions_by_module": [ { "module": {"id": 1, "name": "settings", "display_name": "Settings"}, "permissions": [ { "id": 1, "name": "settings.facilities.view", "display_name": "View Facilities", "description": "...", "enabled": true, "facility_code": null, "applies_to": "all_facilities" }, { "id": 5, "name": "settings.facilities.edit", "display_name": "Edit Facilities", "description": "...", "enabled": true, "facility_code": "FAC-0001", "applies_to": "FAC-0001 only" } ] } ] } } 3. POST / - Create role Body: { "name": "custom-role-name", "display_name": "Custom Role Name", "description": "Role description", "facility_code": "FAC-0001", // NULL = global role "global_permission_ids": [1, 2, 3], // Permissions applied globally (facility_code=null) "facility_permissions": [ {"permission_id": 5, "facility_code": "FAC-0001"}, {"permission_id": 6, "facility_code": "FAC-0002"} ] } Validation: - If role.facility_code is set, role is facility-specific - If role.facility_code is null, role is global - Default roles (is_default=true) must be global (facility_code=null) Response: Created role with ID 4. PUT /{id} - Update role Body: { "name": "updated-name", // ignored for default/locked roles "display_name": "Updated Display Name", "description": "Updated description", "facility_code": "FAC-0001", // ignored for default/locked roles "global_permission_ids": [1, 2, 3], "facility_permissions": [...] } Validation: - Cannot change name/facility_code for roles where is_locked=true - Can update description and permissions for all roles Response: Updated role 5. DELETE /{id} - Delete role Body: {"password": "user_password"} Validation: - Cannot delete if is_locked=true - Cannot delete if users assigned (via user_assignments) - Requires password confirmation Response: {"success": true, "message": "Role deleted"} 6. GET /{id}/users - Get users assigned to role Query params: ?facility_code= (optional) Response: Users via user_assignments B. Permission Management (/api/v1/setting/acl/permissions) 1. GET / - List all permissions Query params: ?module_id=, ?search=, ?status= Response: { "success": true, "data": { "permissions": [ { "id": 1, "name": "settings.facilities.view", "display_name": "View Facilities", "description": "...", "module": {"id": 1, "name": "settings", "display_name": "Settings"}, "status": 1 } ] } } 2. GET /modules - Get permissions grouped by modules Response: { "success": true, "data": [ { "module": {"id": 1, "name": "settings", "display_name": "Settings", "description": "..."}, "permissions": [...] } ] } C. Role-Permission Assignment (/api/v1/setting/acl/roles/{roleId}/permissions) 1. POST /toggle - Toggle single permission Body: { "permission_id": 5, "enabled": true, "facility_code": "FAC-0001" // NULL = applies globally } Logic: - If enabled=true: create entry in role_has_permissions - If enabled=false: delete entry from role_has_permissions - facility_code can be null (global) or specific facility Response: Updated permissions list 2. POST /bulk-update - Bulk toggle permissions Body: { "toggles": [ {"permission_id": 1, "enabled": true, "facility_code": null}, {"permission_id": 2, "enabled": false, "facility_code": "FAC-0001"} ] } Response: Updated permissions list 3. GET / - Get role permissions Query params: ?facility_code= (optional filter) Response: All permissions for this role with facility scope 4. DELETE /{permissionId} - Remove permission Query params: ?facility_code= (required if permission is facility-scoped) Response: Success message D. User Permission Management (/api/v1/setting/acl/users/{userId}) 1. GET /permissions - Get user's direct permissions Query params: ?facility_code= (optional filter) Response: Direct permissions from permission_users table 2. POST /permissions - Assign direct permission Body: { "permission_id": 5, "facility_code": "FAC-0001" // Optional } Response: Success with updated permissions 3. DELETE /permissions/{permissionId} - Remove direct permission Query params: ?facility_code= (optional) Response: Success message 4. GET /permissions/effective - Get effective permissions Query params: ?facility_code= (required) Logic: 1. Get user's assignment for facility (user_assignments where facility_code=X) 2. Get role from assignment 3. Get role permissions (role_has_permissions where role_id=Y and (facility_code=X or facility_code is null)) 4. Get direct permissions (permission_users where user_id=Z and (facility_code=X or facility_code is null)) 5. Merge and return Response: { "success": true, "data": { "facility_code": "FAC-0001", "assignment": { "role": {"id": 2, "name": "physician", "facility_code": null, "is_global": true}, "department": {"id": 5, "name": "Cardiology"} }, "permissions": [ { "id": 1, "name": "settings.facilities.view", "display_name": "View Facilities", "source": "role", "facility_scope": "global" }, { "id": 5, "name": "settings.facilities.edit", "display_name": "Edit Facilities", "source": "direct", "facility_scope": "FAC-0001" } ] } } E. Permission Checking (/api/v1/acl) 1. POST /check - Check user permission Body: { "permission_name": "settings.facilities.edit", "facility_code": "FAC-0001", "user_id": 5 // Optional, defaults to auth user } Response: { "allowed": true, "source": "role", "details": { "role": {"id": 2, "name": "physician", "facility_code": null}, "facility": "FAC-0001" } } 2. GET /me/permissions - Get current user's permissions Query params: ?facility_code= (required) Response: Effective permissions (cached for performance) F. Audit Logs (/api/v1/setting/acl/audit) 1. GET / - Get ACL audit logs Query params: ?user_id=, ?action=, ?model_type=, ?from_date=, ?to_date=, ?facility_code= Response: Paginated activity_logs filtered by ACL model types (Role, Permission, UserPermission) 2. GET /export - Export audit logs Same filters as GET / Response: CSV download Service Layer 1. RoleService namespace App\Services; class RoleService { public function __construct( protected AuditService $auditService ) {} public function getAllRoles(array $filters = []): Collection public function getRoleById(int $roleId): array public function createRole(array $data, int $userId): Role public function updateRole(int $roleId, array $data, int $userId): Role public function deleteRole(int $roleId, string $password, int $userId): bool public function togglePermission(int $roleId, int $permissionId, bool $enabled, ?string $facilityCode, int $userId): void public function bulkUpdatePermissions(int $roleId, array $toggles, int $userId): void public function getRoleUsers(int $roleId, ?string $facilityCode = null): Collection protected function syncRolePermissions(int $roleId, array $globalPermissionIds, array $facilityPermissions, int $userId): void protected function validateRoleDeletion(int $roleId): void } 2. PermissionService namespace App\Services; class PermissionService { public function getAllPermissions(?int $moduleId = null, ?string $search = null): Collection public function getPermissionsByModule(): Collection public function getUserDirectPermissions(int $userId, ?string $facilityCode = null): Collection public function assignDirectPermission(int $userId, int $permissionId, ?string $facilityCode, int $actorId): void public function revokeDirectPermission(int $userId, int $permissionId, ?string $facilityCode, int $actorId): void } 3. AclService namespace App\Services; class AclService { public function checkPermission(int $userId, string $permissionName, ?string $facilityCode): bool public function getUserEffectivePermissions(int $userId, string $facilityCode): Collection public function getUserPermissionSources(int $userId, string $facilityCode): array protected function getRolePermissions(int $userId, string $facilityCode): Collection protected function getDirectPermissions(int $userId, string $facilityCode): Collection protected function mergePermissions(Collection $rolePerms, Collection $directPerms): Collection } 4. Update Existing AuditService // Add to App\Services\AuditService public function logRoleChange( int $userId, string $action, int $roleId, array $oldData, array $newData, ?string $roleName = null, ?string $facilityCode = null ): ActivityLog { return $this->log($action, 'Role', [ 'userId' => $userId, 'modelId' => $roleId, 'oldData' => $oldData, 'newData' => $newData, 'targetName' => $roleName ?? "Role #{$roleId}", 'facilityCode' => $facilityCode, 'context' => [ 'role_id' => $roleId, 'facility_code' => $facilityCode, 'timestamp' => now()->toIso8601String(), ], ]); } public function logPermissionToggle( int $userId, int $roleId, int $permissionId, bool $enabled, ?string $facilityCode, string $roleName, string $permissionName ): ActivityLog { $action = $enabled ? 'granted' : 'revoked'; return $this->log($action, 'RolePermission', [ 'userId' => $userId, 'modelId' => $permissionId, 'oldData' => ['enabled' => !$enabled, 'facility_code' => $facilityCode], 'newData' => ['enabled' => $enabled, 'facility_code' => $facilityCode], 'targetName' => "{$permissionName} for {$roleName}", 'facilityCode' => $facilityCode, 'context' => [ 'role_id' => $roleId, 'role_name' => $roleName, 'permission_id' => $permissionId, 'permission_name' => $permissionName, 'facility_code' => $facilityCode, 'timestamp' => now()->toIso8601String(), ], ]); } public function logDirectPermissionChange( int $actorId, string $action, int $userId, int $permissionId, ?string $facilityCode, string $permissionName ): ActivityLog { return $this->log($action, 'UserPermission', [ 'userId' => $actorId, 'modelId' => $permissionId, 'oldData' => $action === 'revoked' ? ['user_id' => $userId, 'permission_id' => $permissionId, 'facility_code' => $facilityCode] : [], 'newData' => $action === 'granted' ? ['user_id' => $userId, 'permission_id' => $permissionId, 'facility_code' => $facilityCode] : [], 'targetName' => "{$permissionName} for User #{$userId}", 'facilityCode' => $facilityCode, 'context' => [ 'target_user_id' => $userId, 'permission_id' => $permissionId, 'permission_name' => $permissionName, 'facility_code' => $facilityCode, 'timestamp' => now()->toIso8601String(), ], ]); } Model Updates Role Model (app/Models/Role.php) protected $fillable = ['name', 'display_name', 'description', 'facility_code', 'is_default', 'is_locked', 'status', 'created_by', 'updated_by']; // Relationships public function facility() { return $this->belongsTo(Facility::class, 'facility_code', 'code'); } public function permissions() { return $this->belongsToMany(Permission::class, 'role_has_permissions') ->withPivot('facility_code', 'created_by', 'updated_by') ->withTimestamps(); } public function users() { return $this->belongsToMany(User::class, 'user_assignments'); } // Scopes public function scopeGlobal($query) { return $query->whereNull('facility_code'); } public function scopeForFacility($query, ?string $facilityCode) { return $query->where(function($q) use ($facilityCode) { $q->whereNull('facility_code') ->orWhere('facility_code', $facilityCode); }); } public function scopeDefault($query) { return $query->where('is_default', true); } public function scopeCustom($query) { return $query->where('is_default', false); } // Accessors public function getIsGlobalAttribute(): bool { return $this->facility_code === null; } public function getPermissionsForFacility(?string $facilityCode) { return $this->permissions() ->where(function($query) use ($facilityCode) { $query->whereNull('role_has_permissions.facility_code') ->orWhere('role_has_permissions.facility_code', $facilityCode); })->get(); } Permission Model (app/Models/Permission.php) public function roles() { return $this->belongsToMany(Role::class, 'role_has_permissions') ->withPivot('facility_code', 'created_by', 'updated_by') ->withTimestamps(); } public function users() { return $this->belongsToMany(User::class, 'permission_users') ->withPivot('facility_code', 'created_by', 'updated_by') ->withTimestamps(); } User Model (app/Models/User.php) public function directPermissions() { return $this->belongsToMany(Permission::class, 'permission_users') ->withPivot('facility_code', 'created_by', 'updated_by') ->withTimestamps(); } public function hasPermission(string $permissionName, ?string $facilityCode = null): bool { return app(AclService::class)->checkPermission($this->id, $permissionName, $facilityCode); } public function getEffectivePermissions(string $facilityCode): Collection { return app(AclService::class)->getUserEffectivePermissions($this->id, $facilityCode); } Seeders 1. DefaultRolesSeeder $roles = [ ['name' => 'superadmin', 'display_name' => 'Superadmin', 'description' => 'Full system access', 'facility_code' => null, 'is_default' => true, 'is_locked' => true, 'status' => 1], ['name' => 'administrator', 'display_name' => 'Administrator', 'description' => 'Administrative access', 'facility_code' => null, 'is_default' => true, 'is_locked' => true, 'status' => 1], ['name' => 'physician', 'display_name' => 'Physician', 'description' => 'Medical provider', 'facility_code' => null, 'is_default' => true, 'is_locked' => true, 'status' => 1], ['name' => 'clinician', 'display_name' => 'Clinician', 'description' => 'Clinical staff', 'facility_code' => null, 'is_default' => true, 'is_locked' => true, 'status' => 1], ['name' => 'billing-officer', 'display_name' => 'Billing Officer', 'description' => 'Billing and finance', 'facility_code' => null, 'is_default' => true, 'is_locked' => true, 'status' => 1], ['name' => 'pharmacist', 'display_name' => 'Pharmacist', 'description' => 'Pharmacy staff', 'facility_code' => null, 'is_default' => true, 'is_locked' => true, 'status' => 1], ]; // Note: All default roles are global (facility_code = null) 2. AclPermissionsSeeder // Check existing permissions first, add only new ones $settingsModule = DB::table('modules')->where('name', 'settings')->first(); $aclPermissions = [ ['name' => 'settings.acl.roles.view', 'display_name' => 'View ACL Roles', 'description' => 'View roles and permissions', 'module_id' => $settingsModule->id], ['name' => 'settings.acl.roles.create', 'display_name' => 'Create ACL Roles', 'description' => 'Create new roles', 'module_id' => $settingsModule->id], ['name' => 'settings.acl.roles.edit', 'display_name' => 'Edit ACL Roles', 'description' => 'Edit existing roles', 'module_id' => $settingsModule->id], ['name' => 'settings.acl.roles.delete', 'display_name' => 'Delete ACL Roles', 'description' => 'Delete custom roles', 'module_id' => $settingsModule->id], ['name' => 'settings.acl.permissions.view', 'display_name' => 'View Permissions', 'description' => 'View all permissions', 'module_id' => $settingsModule->id], ['name' => 'settings.acl.permissions.assign', 'display_name' => 'Assign Permissions', 'description' => 'Assign permissions to roles', 'module_id' => $settingsModule->id], ['name' => 'settings.acl.users.manage', 'display_name' => 'Manage User Permissions', 'description' => 'Assign direct permissions to users', 'module_id' => $settingsModule->id], ]; foreach ($aclPermissions as $perm) { if (!DB::table('permissions')->where('name', $perm['name'])->exists()) { DB::table('permissions')->insert($perm + [ 'status' => 1, 'created_at' => now(), 'updated_at' => now(), 'created_by' => 1, 'updated_by' => 1 ]); } } Validation Role Validation - name: required|unique:roles,name,{id}|max:100 - description: required|max:500 - facility_code: nullable|exists:facilities,code - global_permission_ids: array - global_permission_ids.*: exists:permissions,id - facility_permissions: array - facility_permissions.*.permission_id: exists:permissions,id - facility_permissions.*.facility_code: exists:facilities,code Custom rules: - Default roles (is_default=true) must have facility_code=null - Locked roles (is_locked=true) cannot change name or facility_code - Cannot delete role with active user_assignments Controllers AclRolesController - Role CRUD & permission management AclPermissionsController - Permission listing AclUserPermissionsController - User direct permissions AclCheckController - Permission checking AclAuditController - Audit log viewing/export (reuses AuditService) Routes (add to routes/api.php) // ACL routes (superadmin only) Route::middleware(['auth:sanctum', 'superadmin'])->prefix('setting/acl')->group(function () { // Roles Route::prefix('roles')->group(function () { Route::get('/', [AclRolesController::class, 'index']); Route::get('{id}', [AclRolesController::class, 'show']); Route::post('/', [AclRolesController::class, 'store']); Route::put('{id}', [AclRolesController::class, 'update']); Route::delete('{id}', [AclRolesController::class, 'destroy']); Route::get('{id}/users', [AclRolesController::class, 'users']); // Role permissions Route::post('{id}/permissions/toggle', [AclRolesController::class, 'togglePermission']); Route::post('{id}/permissions/bulk-update', [AclRolesController::class, 'bulkUpdatePermissions']); Route::get('{id}/permissions', [AclRolesController::class, 'permissions']); Route::delete('{id}/permissions/{permissionId}', [AclRolesController::class, 'removePermission']); }); // Permissions Route::prefix('permissions')->group(function () { Route::get('/', [AclPermissionsController::class, 'index']); Route::get('modules', [AclPermissionsController::class, 'byModule']); }); // User permissions Route::prefix('users/{userId}')->group(function () { Route::get('permissions', [AclUserPermissionsController::class, 'index']); Route::get('permissions/effective', [AclUserPermissionsController::class, 'effective']); Route::post('permissions', [AclUserPermissionsController::class, 'assign']); Route::delete('permissions/{permissionId}', [AclUserPermissionsController::class, 'revoke']); }); // Audit Route::prefix('audit')->group(function () { Route::get('/', [AclAuditController::class, 'index']); Route::get('export', [AclAuditController::class, 'export']); }); }); // Permission checking (all authenticated users) Route::middleware('auth:sanctum')->prefix('acl')->group(function () { Route::post('check', [AclCheckController::class, 'check']); Route::get('me/permissions', [AclCheckController::class, 'myPermissions']); }); Implementation Checklist Create migration: role_has_permissions table Create migration: update permission_users (add facility_code, created_by, updated_by) Create migration: update roles (add facility_code, is_default, is_locked, status) Update Role model (add facility_code, relationships, scopes) Update Permission model (relationships) Update User model (helper methods) Create RoleService Create PermissionService Create AclService Update AuditService (add logRoleChange, logPermissionToggle, logDirectPermissionChange) Create AclRolesController Create AclPermissionsController Create AclUserPermissionsController Create AclCheckController Create AclAuditController Create FormRequest validators Add routes to api.php Create DefaultRolesSeeder Create AclPermissionsSeeder Update ACL middleware (if needed) Write comprehensive API tests Stayed in plan mode I'll save the plan to a docs folder. Let me first check if there's a docs directory: Globpattern: "docs/**" ⎿ Found 1 file Perfect! I'll save the ACL API plan as a markdown file in the docs folder. However, I'm still in plan mode and cannot make file changes yet. Let me present this via ExitPlanMode so you can approve it: Claude’s Plan Implementation Plan Task Create comprehensive ACL (Access Control List) APIs documentation for the multifacility VEHR system What will be done Save a detailed API specification document to /docs/acl-api-implementation-plan.md that includes: Document Contents Architecture Overview - Permission flow, key tables, and data relationships Database Schema Updates - Migrations for role_has_permissions, permission_users, and roles tables API Endpoints (Full specifications for): Role Management APIs (CRUD + permissions) Permission Management APIs Role-Permission Assignment APIs User Permission Management APIs Permission Checking APIs Audit Log APIs Service Layer - RoleService, PermissionService, AclService, AuditService updates Model Updates - Role, Permission, User models with relationships Seeders - DefaultRolesSeeder, AclPermissionsSeeder Validation Rules - All request validation Controllers - List of required controllers Routes - Complete API route definitions Implementation Checklist - 21-step implementation guide Key Features Multifacility Support: Roles can be facility-specific or global (facility_code nullable) Two Permission Sources: Role-based + Direct user permissions Facility-scoped Permissions: Both role permissions and user permissions support facility_code Audit Trail: Integrated with existing AuditService VEHR MVP 1 Compliance: Follows all requirements from the PDF document