Files
breadcrumb-the-shire/lib/Service/Access/AssignableRoleService.php
fs 0c351f6aff refactor(audit): extract audit domain into self-contained module
Move the entire audit subsystem (system audit, API audit, import audit,
user lifecycle audit, frontend telemetry) from core into modules/audit/.

Core decoupling via interface-based injection:
- AuditRecorderInterface replaces SystemAuditService in 10+ core services
- UserLifecycleAuditInterface / ImportAuditInterface for specialized flows
- NullAuditRecorder fallback when audit module is disabled
- ApiBootstrap/ApiResponse use null-safe callable resolvers

Module structure (modules/audit/):
- Manifest with routes, permissions, scheduler jobs, authorization policy
- 9 services, 8 repositories, 6 domain enums, 4 job handlers
- 33 page files, 4 JS files, 8 test files, migration scripts, i18n

Core cleanup:
- OperationsAuthorizationPolicy, UiCapabilityMap, PermissionService
  surgically cleaned of audit-specific constants
- Sidebar template cleared of hardcoded audit navigation
- AuditModuleIsolationContractTest ensures no future core→module coupling

All quality gates pass: 1346 tests (19,276 assertions), PHPStan level 5
clean, architecture contracts verified.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 21:12:49 +01:00

102 lines
3.7 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
namespace MintyPHP\Service\Access;
use MintyPHP\Repository\Access\RoleAssignableRoleRepositoryInterface;
use MintyPHP\Repository\Access\RoleRepositoryInterface;
use MintyPHP\Repository\Access\UserRoleRepositoryInterface;
use MintyPHP\Service\Audit\AuditRecorderInterface;
class AssignableRoleService
{
public function __construct(
private readonly RoleAssignableRoleRepositoryInterface $assignableRoleRepository,
private readonly UserRoleRepositoryInterface $userRoleRepository,
private readonly PermissionService $permissionService,
private readonly RoleRepositoryInterface $roleRepository,
private readonly AuditRecorderInterface $systemAuditService
) {
}
/**
* Returns role IDs the given actor is allowed to assign.
* Users with 'roles.assign_all' get all active role IDs.
* Otherwise: union of assignable_role_ids from all of the actor's roles.
*
* @return int[]
*/
public function listAssignableRoleIdsForActor(int $actorUserId): array
{
if ($actorUserId <= 0) {
return [];
}
if ($this->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;
}
}