diff --git a/db/init/init.sql b/db/init/init.sql index 0cb40eb..1de4c28 100644 --- a/db/init/init.sql +++ b/db/init/init.sql @@ -277,6 +277,16 @@ CREATE TABLE IF NOT EXISTS `role_permissions` ( CONSTRAINT `fk_role_permissions_permission` FOREIGN KEY (`permission_id`) REFERENCES `permissions` (`id`) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +CREATE TABLE IF NOT EXISTS `role_assignable_roles` ( + `role_id` INT UNSIGNED NOT NULL, + `assignable_role_id` INT UNSIGNED NOT NULL, + `created` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`role_id`, `assignable_role_id`), + KEY `idx_rar_assignable` (`assignable_role_id`), + CONSTRAINT `fk_rar_role` FOREIGN KEY (`role_id`) REFERENCES `roles` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_rar_assignable` FOREIGN KEY (`assignable_role_id`) REFERENCES `roles` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + CREATE TABLE IF NOT EXISTS `user_departments` ( `user_id` INT UNSIGNED NOT NULL, `department_id` INT UNSIGNED NOT NULL, @@ -768,6 +778,18 @@ INSERT INTO `roles` (`uuid`, `description`, `code`, `active`, `created`) SELECT UUID(), 'User', 'USER', 1, NOW() WHERE NOT EXISTS (SELECT 1 FROM roles WHERE code = 'USER'); +INSERT INTO `roles` (`uuid`, `description`, `code`, `active`, `created`) +SELECT UUID(), 'Manager', 'MANAGER', 1, NOW() +WHERE NOT EXISTS (SELECT 1 FROM roles WHERE code = 'MANAGER'); + +INSERT INTO `roles` (`uuid`, `description`, `code`, `active`, `created`) +SELECT UUID(), 'Auditor', 'AUDITOR', 1, NOW() +WHERE NOT EXISTS (SELECT 1 FROM roles WHERE code = 'AUDITOR'); + +INSERT INTO `roles` (`uuid`, `description`, `code`, `active`, `created`) +SELECT UUID(), 'Support', 'SUPPORT', 1, NOW() +WHERE NOT EXISTS (SELECT 1 FROM roles WHERE code = 'SUPPORT'); + INSERT INTO `users` ( `uuid`, `first_name`, @@ -989,7 +1011,8 @@ VALUES ('system_audit.view', 'Can view system audit logs', 1, 1), ('system_audit.purge', 'Can purge system audit logs', 1, 1), ('api_tokens.manage', 'Can manage user API tokens', 1, 1), - ('stats.view', 'Can view statistics', 1, 1) + ('stats.view', 'Can view statistics', 1, 1), + ('roles.assign_all', 'Can assign all roles (bypass assignable-roles check)', 1, 1) ON DUPLICATE KEY UPDATE `description` = VALUES(`description`), `active` = VALUES(`active`), @@ -1089,7 +1112,8 @@ JOIN permissions p ON p.`key` IN ( 'api_docs.view', 'docs.view', 'api_tokens.manage', 'mail_log.view', 'api_audit.view', 'system_audit.view', 'system_audit.purge', - 'stats.view' + 'stats.view', + 'roles.assign_all' ) WHERE r.description IN ('Admin', 'Administrator') OR r.id = 1 ON DUPLICATE KEY UPDATE role_id = role_id; @@ -1101,6 +1125,68 @@ JOIN permissions p ON p.`key` IN ('users.self_update', 'address_book.view') WHERE r.code = 'USER' OR r.description = 'User' ON DUPLICATE KEY UPDATE role_id = role_id; +-- Manager: user & department management +INSERT INTO `role_permissions` (`role_id`, `permission_id`, `created`) +SELECT r.id, p.id, NOW() +FROM roles r +JOIN permissions p ON p.`key` IN ( + 'users.view', 'users.view_meta', 'users.create', 'users.update', + 'users.update_assignments', 'users.access_pdf', 'users.self_update', + 'users.import', 'users.import_assignments', + 'address_book.view', + 'departments.view', 'departments.create', 'departments.update', + 'roles.view', + 'custom_fields.edit_values' +) +WHERE r.code = 'MANAGER' +ON DUPLICATE KEY UPDATE role_id = role_id; + +-- Auditor: read-only + audit logs +INSERT INTO `role_permissions` (`role_id`, `permission_id`, `created`) +SELECT r.id, p.id, NOW() +FROM roles r +JOIN permissions p ON p.`key` IN ( + 'users.view', 'users.view_meta', 'users.view_audit', 'users.self_update', + 'address_book.view', + 'tenants.view', 'departments.view', 'roles.view', 'permissions.view', + 'settings.view', + 'imports.view', 'imports.audit.view', + 'user_lifecycle_audit.view', + 'mail_log.view', 'api_audit.view', 'system_audit.view', + 'stats.view', 'jobs.view' +) +WHERE r.code = 'AUDITOR' +ON DUPLICATE KEY UPDATE role_id = role_id; + +-- Support: helpdesk / user support +INSERT INTO `role_permissions` (`role_id`, `permission_id`, `created`) +SELECT r.id, p.id, NOW() +FROM roles r +JOIN permissions p ON p.`key` IN ( + 'users.view', 'users.view_meta', 'users.view_audit', + 'users.update', 'users.self_update', + 'address_book.view', + 'departments.view', 'roles.view', + 'api_tokens.manage', + 'custom_fields.edit_values' +) +WHERE r.code = 'SUPPORT' +ON DUPLICATE KEY UPDATE role_id = role_id; + +-- Admin role can assign every role (also has roles.assign_all permission as fallback) +INSERT IGNORE INTO `role_assignable_roles` (`role_id`, `assignable_role_id`) +SELECT admin_role.id, all_roles.id +FROM roles admin_role +CROSS JOIN roles all_roles +WHERE admin_role.description IN ('Admin', 'Administrator') OR admin_role.id = 1; + +-- Manager can assign USER and SUPPORT roles +INSERT IGNORE INTO `role_assignable_roles` (`role_id`, `assignable_role_id`) +SELECT mgr.id, target.id +FROM roles mgr +JOIN roles target ON target.code IN ('USER', 'SUPPORT') +WHERE mgr.code = 'MANAGER'; + INSERT INTO `settings` (`key`, `value`, `description`) SELECT 'default_tenant_id', CAST(t.id AS CHAR), 'setting.default_tenant' FROM tenants t diff --git a/db/updates/2026-03-13-role-assignable-roles.sql b/db/updates/2026-03-13-role-assignable-roles.sql new file mode 100644 index 0000000..bc64c60 --- /dev/null +++ b/db/updates/2026-03-13-role-assignable-roles.sql @@ -0,0 +1,31 @@ +-- Idempotent update: introduce assignable-roles mapping for privilege-escalation prevention. +-- Each role defines which other roles its members may assign to users. + +CREATE TABLE IF NOT EXISTS `role_assignable_roles` ( + `role_id` INT UNSIGNED NOT NULL, + `assignable_role_id` INT UNSIGNED NOT NULL, + `created` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`role_id`, `assignable_role_id`), + KEY `idx_rar_assignable` (`assignable_role_id`), + CONSTRAINT `fk_rar_role` FOREIGN KEY (`role_id`) REFERENCES `roles` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_rar_assignable` FOREIGN KEY (`assignable_role_id`) REFERENCES `roles` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- Permission: bypass assignable-roles check (super-admin) +INSERT INTO `permissions` (`key`, `description`, `active`, `is_system`) +VALUES ('roles.assign_all', 'permission.roles.assign_all', 1, 1) +ON DUPLICATE KEY UPDATE `key` = `key`; + +-- Grant roles.assign_all to the ADMIN role +INSERT IGNORE INTO `role_permissions` (`role_id`, `permission_id`) +SELECT r.id, p.id +FROM roles r +JOIN permissions p ON p.`key` = 'roles.assign_all' +WHERE r.code = 'ADMIN'; + +-- Seed: ADMIN role can assign every existing role +INSERT IGNORE INTO `role_assignable_roles` (`role_id`, `assignable_role_id`) +SELECT admin_role.id, all_roles.id +FROM roles admin_role +CROSS JOIN roles all_roles +WHERE admin_role.code = 'ADMIN'; diff --git a/db/updates/2026-03-13-seed-default-roles.sql b/db/updates/2026-03-13-seed-default-roles.sql new file mode 100644 index 0000000..8d5d259 --- /dev/null +++ b/db/updates/2026-03-13-seed-default-roles.sql @@ -0,0 +1,81 @@ +-- ============================================================ +-- 2026-03-13 Seed default roles: MANAGER, AUDITOR, SUPPORT +-- Idempotent — safe to run multiple times. +-- ============================================================ + +-- 1. Create new roles (skip if already present) +INSERT INTO `roles` (`uuid`, `description`, `code`, `active`, `created`) +SELECT UUID(), 'Manager', 'MANAGER', 1, NOW() +WHERE NOT EXISTS (SELECT 1 FROM roles WHERE code = 'MANAGER'); + +INSERT INTO `roles` (`uuid`, `description`, `code`, `active`, `created`) +SELECT UUID(), 'Auditor', 'AUDITOR', 1, NOW() +WHERE NOT EXISTS (SELECT 1 FROM roles WHERE code = 'AUDITOR'); + +INSERT INTO `roles` (`uuid`, `description`, `code`, `active`, `created`) +SELECT UUID(), 'Support', 'SUPPORT', 1, NOW() +WHERE NOT EXISTS (SELECT 1 FROM roles WHERE code = 'SUPPORT'); + +-- 2. MANAGER permissions (15) +INSERT INTO `role_permissions` (`role_id`, `permission_id`, `created`) +SELECT r.id, p.id, NOW() +FROM roles r +JOIN permissions p ON p.`key` IN ( + 'users.view', 'users.view_meta', 'users.create', 'users.update', + 'users.update_assignments', 'users.access_pdf', 'users.self_update', + 'users.import', 'users.import_assignments', + 'address_book.view', + 'departments.view', 'departments.create', 'departments.update', + 'roles.view', + 'custom_fields.edit_values' +) +WHERE r.code = 'MANAGER' +ON DUPLICATE KEY UPDATE role_id = role_id; + +-- 3. AUDITOR permissions (18) +INSERT INTO `role_permissions` (`role_id`, `permission_id`, `created`) +SELECT r.id, p.id, NOW() +FROM roles r +JOIN permissions p ON p.`key` IN ( + 'users.view', 'users.view_meta', 'users.view_audit', 'users.self_update', + 'address_book.view', + 'tenants.view', 'departments.view', 'roles.view', 'permissions.view', + 'settings.view', + 'imports.view', 'imports.audit.view', + 'user_lifecycle_audit.view', + 'mail_log.view', 'api_audit.view', 'system_audit.view', + 'stats.view', 'jobs.view' +) +WHERE r.code = 'AUDITOR' +ON DUPLICATE KEY UPDATE role_id = role_id; + +-- 4. SUPPORT permissions (10) +INSERT INTO `role_permissions` (`role_id`, `permission_id`, `created`) +SELECT r.id, p.id, NOW() +FROM roles r +JOIN permissions p ON p.`key` IN ( + 'users.view', 'users.view_meta', 'users.view_audit', + 'users.update', 'users.self_update', + 'address_book.view', + 'departments.view', 'roles.view', + 'api_tokens.manage', + 'custom_fields.edit_values' +) +WHERE r.code = 'SUPPORT' +ON DUPLICATE KEY UPDATE role_id = role_id; + +-- 5. MANAGER can assign USER and SUPPORT roles +INSERT IGNORE INTO `role_assignable_roles` (`role_id`, `assignable_role_id`) +SELECT mgr.id, target.id +FROM roles mgr +JOIN roles target ON target.code IN ('USER', 'SUPPORT') +WHERE mgr.code = 'MANAGER'; + +-- 6. Ensure ADMIN assignable_roles includes the new roles +-- (previous migration only seeded roles that existed at that time) +INSERT IGNORE INTO `role_assignable_roles` (`role_id`, `assignable_role_id`) +SELECT admin_role.id, new_role.id +FROM roles admin_role +CROSS JOIN roles new_role +WHERE (admin_role.description IN ('Admin', 'Administrator') OR admin_role.id = 1) + AND new_role.code IN ('MANAGER', 'AUDITOR', 'SUPPORT'); diff --git a/i18n/default_de.json b/i18n/default_de.json index d2ff8de..d80a711 100644 --- a/i18n/default_de.json +++ b/i18n/default_de.json @@ -184,6 +184,7 @@ "perm.roles.create": "Rollen erstellen", "perm.roles.update": "Rollen bearbeiten", "perm.roles.delete": "Rollen löschen", + "perm.roles.assign_all": "Alle Rollen zuweisen", "perm.permissions.view": "Berechtigungen anzeigen", "perm.permissions.create": "Berechtigungen erstellen", "perm.permissions.update": "Berechtigungen bearbeiten", @@ -533,6 +534,7 @@ "Role": "Rolle", "Roles": "Rollen", "No roles available": "Keine Rollen verfügbar", + "Assignable roles": "Zuweisbare Rollen", "Create role": "Rolle anlegen", "Edit role": "Rolle bearbeiten", "Role created": "Rolle erstellt", diff --git a/i18n/default_en.json b/i18n/default_en.json index 583431a..8c2922e 100644 --- a/i18n/default_en.json +++ b/i18n/default_en.json @@ -184,6 +184,7 @@ "perm.roles.create": "Create roles", "perm.roles.update": "Update roles", "perm.roles.delete": "Delete roles", + "perm.roles.assign_all": "Assign all roles", "perm.permissions.view": "View permissions", "perm.permissions.create": "Create permissions", "perm.permissions.update": "Update permissions", @@ -533,6 +534,7 @@ "Role": "Role", "Roles": "Roles", "No roles available": "No roles available", + "Assignable roles": "Assignable roles", "Create role": "Create role", "Edit role": "Edit role", "Role created": "Role created", diff --git a/lib/App/Container/Registrars/AccessRegistrar.php b/lib/App/Container/Registrars/AccessRegistrar.php index c7dd390..fc6598d 100644 --- a/lib/App/Container/Registrars/AccessRegistrar.php +++ b/lib/App/Container/Registrars/AccessRegistrar.php @@ -5,13 +5,16 @@ namespace MintyPHP\App\Container\Registrars; use MintyPHP\App\AppContainer; use MintyPHP\App\Container\ContainerRegistrar; use MintyPHP\Repository\Access\PermissionRepository; +use MintyPHP\Repository\Access\RoleAssignableRoleRepository; use MintyPHP\Repository\Access\RolePermissionRepository; use MintyPHP\Repository\Access\RoleRepository; use MintyPHP\Service\Access\AccessServicesFactory; +use MintyPHP\Service\Access\AssignableRoleService; use MintyPHP\Service\Access\AuthorizationService; use MintyPHP\Service\Access\PermissionService; use MintyPHP\Service\Access\RoleService; use MintyPHP\Service\Access\UiAccessService; +use MintyPHP\Service\Audit\SystemAuditService; use MintyPHP\Service\Directory\DirectoryServicesFactory; final class AccessRegistrar implements ContainerRegistrar @@ -28,6 +31,14 @@ final class AccessRegistrar implements ContainerRegistrar $container->set(RoleService::class, static fn (AppContainer $c): RoleService => $c->get(DirectoryServicesFactory::class)->createRoleService()); $container->set(PermissionRepository::class, static fn (AppContainer $c): PermissionRepository => $c->get(AccessServicesFactory::class)->createPermissionRepository()); $container->set(RolePermissionRepository::class, static fn (AppContainer $c): RolePermissionRepository => $c->get(AccessServicesFactory::class)->createRolePermissionRepository()); + $container->set(RoleAssignableRoleRepository::class, static fn (AppContainer $c): RoleAssignableRoleRepository => $c->get(AccessServicesFactory::class)->createRoleAssignableRoleRepository()); $container->set(RoleRepository::class, static fn (AppContainer $c): RoleRepository => $c->get(DirectoryServicesFactory::class)->createRoleRepository()); + $container->set(AssignableRoleService::class, static fn (AppContainer $c): AssignableRoleService => new AssignableRoleService( + $c->get(RoleAssignableRoleRepository::class), + $c->get(AccessServicesFactory::class)->createUserRoleRepository(), + $c->get(PermissionService::class), + $c->get(RoleRepository::class), + $c->get(SystemAuditService::class) + )); } } diff --git a/lib/App/Container/Registrars/ServiceFactoryRegistrar.php b/lib/App/Container/Registrars/ServiceFactoryRegistrar.php index 3a5ad63..68cc7dd 100644 --- a/lib/App/Container/Registrars/ServiceFactoryRegistrar.php +++ b/lib/App/Container/Registrars/ServiceFactoryRegistrar.php @@ -38,6 +38,7 @@ use MintyPHP\Service\Settings\SettingServicesFactory; use MintyPHP\Service\Tenant\TenantRepositoryFactory; use MintyPHP\Service\Tenant\TenantScopeService; use MintyPHP\Service\Tenant\TenantServicesFactory; +use MintyPHP\Service\Access\AssignableRoleService; use MintyPHP\Service\User\UserGatewayFactory; use MintyPHP\Service\User\UserRepositoryFactory; use MintyPHP\Service\User\UserServicesFactory; @@ -130,7 +131,10 @@ final class ServiceFactoryRegistrar implements ContainerRegistrar $c->get(AuditServicesFactory::class), $c->get(UserRepositoryFactory::class), $c->get(UserGatewayFactory::class), - $c->get(DatabaseSessionRepository::class) + $c->get(DatabaseSessionRepository::class), + // Wrapped in a closure to break circular dependency: + // UserServicesFactory -> AssignableRoleService -> RoleRepository -> DirectoryServicesFactory -> UserServicesFactory + static fn (): AssignableRoleService => $c->get(AssignableRoleService::class) )); $container->set(AuthGatewayFactory::class, static fn (AppContainer $c): AuthGatewayFactory => new AuthGatewayFactory( $c->get(AuthRepositoryFactory::class), diff --git a/lib/Repository/Access/RoleAssignableRoleRepository.php b/lib/Repository/Access/RoleAssignableRoleRepository.php new file mode 100644 index 0000000..e5acf85 --- /dev/null +++ b/lib/Repository/Access/RoleAssignableRoleRepository.php @@ -0,0 +1,74 @@ + $id > 0)); + if (!$roleIds) { + return []; + } + + $placeholders = implode(',', array_fill(0, count($roleIds), '?')); + $rows = DB::select( + 'select distinct assignable_role_id from role_assignable_roles where role_id in (' . + $placeholders . + ')', + ...array_map('strval', $roleIds) + ); + + if (!is_array($rows)) { + return []; + } + + $ids = []; + foreach ($rows as $row) { + $data = $row['role_assignable_roles'] ?? $row; + if (is_array($data) && isset($data['assignable_role_id'])) { + $ids[] = (int) $data['assignable_role_id']; + } + } + return array_values(array_unique($ids)); + } + + public function replaceForRole(int $roleId, array $assignableRoleIds): bool + { + DB::delete('delete from role_assignable_roles where role_id = ?', (string) $roleId); + $ids = array_values(array_unique(array_filter(array_map('intval', $assignableRoleIds)))); + if (!$ids) { + return true; + } + foreach ($ids as $assignableRoleId) { + DB::insert( + 'insert into role_assignable_roles (role_id, assignable_role_id, created) values (?,?,NOW())', + (string) $roleId, + (string) $assignableRoleId + ); + } + return true; + } +} diff --git a/lib/Repository/Access/RoleAssignableRoleRepositoryInterface.php b/lib/Repository/Access/RoleAssignableRoleRepositoryInterface.php new file mode 100644 index 0000000..c94e011 --- /dev/null +++ b/lib/Repository/Access/RoleAssignableRoleRepositoryInterface.php @@ -0,0 +1,22 @@ +rolePermissionRepository ??= new RolePermissionRepository(); } + public function createRoleAssignableRoleRepository(): RoleAssignableRoleRepositoryInterface + { + return $this->roleAssignableRoleRepository ??= new RoleAssignableRoleRepository(); + } + public function createUserRoleRepository(): UserRoleRepositoryInterface { return $this->userRoleRepository ??= new UserRoleRepository(); diff --git a/lib/Service/Access/AccessServicesFactory.php b/lib/Service/Access/AccessServicesFactory.php index 2e5263b..338d6b8 100644 --- a/lib/Service/Access/AccessServicesFactory.php +++ b/lib/Service/Access/AccessServicesFactory.php @@ -3,6 +3,7 @@ namespace MintyPHP\Service\Access; use MintyPHP\Repository\Access\PermissionRepositoryInterface; +use MintyPHP\Repository\Access\RoleAssignableRoleRepositoryInterface; use MintyPHP\Repository\Access\RolePermissionRepositoryInterface; use MintyPHP\Repository\Access\UserRoleRepositoryInterface; @@ -34,6 +35,11 @@ class AccessServicesFactory return $this->accessRepositoryFactory->createUserRoleRepository(); } + public function createRoleAssignableRoleRepository(): RoleAssignableRoleRepositoryInterface + { + return $this->accessRepositoryFactory->createRoleAssignableRoleRepository(); + } + public function createPermissionService(): PermissionService { return $this->accessGatewayFactory->createPermissionService(); diff --git a/lib/Service/Access/AssignableRoleService.php b/lib/Service/Access/AssignableRoleService.php new file mode 100644 index 0000000..e7f6918 --- /dev/null +++ b/lib/Service/Access/AssignableRoleService.php @@ -0,0 +1,101 @@ +permissionService->userHas($actorUserId, PermissionService::ROLES_ASSIGN_ALL)) { + return $this->roleRepository->listActiveIds(); + } + + $actorRoleIds = $this->userRoleRepository->listRoleIdsByUserId($actorUserId); + if (!$actorRoleIds) { + return []; + } + + return $this->assignableRoleRepository->listAssignableRoleIdsByRoleIds($actorRoleIds); + } + + /** + * Computes the effective role set respecting the actor's assignable scope. + * + * Roles the actor cannot assign are "frozen" — they remain as-is. + * frozen = currentIds ∩ NOT-assignable + * touched = submittedIds ∩ assignable + * result = frozen ∪ touched + * + * @param int[] $submittedIds Role IDs the form submitted + * @param int[] $currentIds Role IDs the target user currently has + * @return int[] + */ + public function computeEffectiveRoleIds(int $actorUserId, array $submittedIds, array $currentIds): array + { + $assignable = $this->listAssignableRoleIdsForActor($actorUserId); + $assignableMap = array_fill_keys($assignable, true); + + // Roles the actor cannot touch — keep them as they are. + $frozen = array_values(array_filter( + $currentIds, + static fn (int $id): bool => !isset($assignableMap[$id]) + )); + + // From the submitted set, only keep roles the actor is allowed to assign. + $touched = array_values(array_filter( + $submittedIds, + static fn (int $id): bool => isset($assignableMap[$id]) + )); + + return array_values(array_unique(array_merge($frozen, $touched))); + } + + /** + * Saves which roles this role can assign (admin UI) and logs the change. + * + * @param int[] $assignableRoleIds + */ + public function replaceAssignableRoles(int $roleId, array $assignableRoleIds, int $actorUserId): bool + { + $beforeIds = $this->assignableRoleRepository->listAssignableRoleIdsByRoleId($roleId); + $result = $this->assignableRoleRepository->replaceForRole($roleId, $assignableRoleIds); + + $afterIds = $this->assignableRoleRepository->listAssignableRoleIdsByRoleId($roleId); + if ($beforeIds !== $afterIds) { + $this->systemAuditService->record('admin.roles.assignable_roles.update', 'success', [ + 'actor_user_id' => $actorUserId > 0 ? $actorUserId : null, + 'target_type' => 'role', + 'target_id' => $roleId, + 'before' => ['assignable_role_ids' => $beforeIds], + 'after' => ['assignable_role_ids' => $afterIds], + ]); + } + + return $result; + } +} diff --git a/lib/Service/Access/PermissionService.php b/lib/Service/Access/PermissionService.php index a330298..bedb56f 100644 --- a/lib/Service/Access/PermissionService.php +++ b/lib/Service/Access/PermissionService.php @@ -45,6 +45,7 @@ class PermissionService public const ROLES_CREATE = 'roles.create'; public const ROLES_UPDATE = 'roles.update'; public const ROLES_DELETE = 'roles.delete'; + public const ROLES_ASSIGN_ALL = 'roles.assign_all'; public const PERMISSIONS_VIEW = 'permissions.view'; public const PERMISSIONS_CREATE = 'permissions.create'; public const PERMISSIONS_UPDATE = 'permissions.update'; diff --git a/lib/Service/User/UserAssignmentService.php b/lib/Service/User/UserAssignmentService.php index a0452ec..8661c28 100644 --- a/lib/Service/User/UserAssignmentService.php +++ b/lib/Service/User/UserAssignmentService.php @@ -7,6 +7,7 @@ use MintyPHP\Repository\Org\UserDepartmentRepositoryInterface; use MintyPHP\Repository\Support\DatabaseSessionRepository; use MintyPHP\Repository\Tenant\UserTenantRepositoryInterface; use MintyPHP\Repository\User\UserWriteRepositoryInterface; +use MintyPHP\Service\Access\AssignableRoleService; use MintyPHP\Service\Access\PermissionService; class UserAssignmentService @@ -18,7 +19,8 @@ class UserAssignmentService private readonly UserDepartmentRepositoryInterface $userDepartmentRepository, private readonly UserDirectoryGateway $directoryGateway, private readonly PermissionService $permissionService, - private readonly DatabaseSessionRepository $databaseSessionRepository + private readonly DatabaseSessionRepository $databaseSessionRepository, + private readonly AssignableRoleService $assignableRoleService ) { } @@ -172,7 +174,7 @@ class UserAssignmentService return $result; } - public function syncRoles(int $userId, array $roleIds, bool $bumpAuthz = true): bool + public function syncRoles(int $userId, array $roleIds, bool $bumpAuthz = true, int $actorUserId = 0): bool { $ids = array_values(array_unique(array_map('intval', $roleIds))); $ids = array_values(array_filter($ids, static fn ($id) => $id > 0)); @@ -181,6 +183,14 @@ class UserAssignmentService $validMap = array_fill_keys($validIds, true); $ids = array_values(array_filter($ids, static fn ($id) => isset($validMap[$id]))); } + + // Enforce assignable-roles: the actor can only touch roles within their assignable set. + // Roles outside the set are "frozen" and remain as-is on the target user. + if ($actorUserId > 0) { + $currentRoleIds = $this->userRoleRepository->listRoleIdsByUserId($userId); + $ids = $this->assignableRoleService->computeEffectiveRoleIds($actorUserId, $ids, $currentRoleIds); + } + $result = $this->userRoleRepository->replaceForUser($userId, $ids); $this->permissionService->clearUserCache($userId); if ($result && $bumpAuthz) { @@ -228,7 +238,7 @@ class UserAssignmentService $this->userWriteRepository->bumpAuthzVersion($userId); } - public function syncAllAssignments(int $userId, array $tenantIds, array $roleIds, array $departmentIds): bool + public function syncAllAssignments(int $userId, array $tenantIds, array $roleIds, array $departmentIds, int $actorUserId = 0): bool { if ($userId <= 0) { return false; @@ -241,7 +251,7 @@ class UserAssignmentService // bumpAuthz=false on each sync — bump once at the end to avoid redundant DB writes. $ok = $this->syncTenants($userId, $tenantIds, false) - && $this->syncRoles($userId, $roleIds, false) + && $this->syncRoles($userId, $roleIds, false, $actorUserId) && $this->syncDepartments($userId, $departmentIds, false); if (!$ok) { $this->rollbackQuietly(); diff --git a/lib/Service/User/UserServicesFactory.php b/lib/Service/User/UserServicesFactory.php index 1467492..896a05e 100644 --- a/lib/Service/User/UserServicesFactory.php +++ b/lib/Service/User/UserServicesFactory.php @@ -10,6 +10,7 @@ use MintyPHP\Repository\User\UserListQueryRepositoryInterface; use MintyPHP\Repository\User\UserReadRepositoryInterface; use MintyPHP\Repository\User\UserSavedFilterRepositoryInterface; use MintyPHP\Repository\User\UserWriteRepositoryInterface; +use MintyPHP\Service\Access\AssignableRoleService; use MintyPHP\Service\Audit\AuditServicesFactory; use MintyPHP\Service\Audit\UserLifecycleAuditService; @@ -26,11 +27,15 @@ class UserServicesFactory private ?UserLifecycleRestoreService $userLifecycleRestoreService = null; private ?UserLifecycleAuditService $userLifecycleAuditService = null; + /** + * @param \Closure(): AssignableRoleService $assignableRoleServiceFactory Lazy resolver to break circular dependency + */ public function __construct( private readonly AuditServicesFactory $auditServicesFactory, private readonly UserRepositoryFactory $userRepositoryFactory, private readonly UserGatewayFactory $userGatewayFactory, - private readonly DatabaseSessionRepository $databaseSessionRepository + private readonly DatabaseSessionRepository $databaseSessionRepository, + private readonly \Closure $assignableRoleServiceFactory ) { } @@ -59,7 +64,8 @@ class UserServicesFactory $this->createUserDepartmentRepository(), $this->createUserDirectoryGateway(), $this->userGatewayFactory->createPermissionService(), - $this->databaseSessionRepository + $this->databaseSessionRepository, + ($this->assignableRoleServiceFactory)() ); } diff --git a/pages/admin/roles/_form.phtml b/pages/admin/roles/_form.phtml index ea39a6e..be5a52d 100644 --- a/pages/admin/roles/_form.phtml +++ b/pages/admin/roles/_form.phtml @@ -26,6 +26,8 @@ $readonlyAttr = $isReadOnly ? 'readonly' : ''; $disabledAttr = $isReadOnly ? 'disabled' : ''; $permissions = is_array($permissions ?? null) ? $permissions : []; $selectedPermissionIds = is_array($selectedPermissionIds ?? null) ? $selectedPermissionIds : []; +$allRolesForAssignable = is_array($allRolesForAssignable ?? null) ? $allRolesForAssignable : []; +$selectedAssignableRoleIds = is_array($selectedAssignableRoleIds ?? null) ? $selectedAssignableRoleIds : []; ?>