refactor: rename lib/ to core/ for clearer core-module separation
Rename the top-level lib/ directory to core/ so the project root immediately communicates which code is core platform and which lives in modules/. PHP namespaces (MintyPHP\*) are unchanged — only the Composer PSR-4 path mapping moves from lib/ to core/. Module-internal lib/ directories (modules/*/lib/) are untouched. Updated across the full stack: - composer.json PSR-4 mapping - bootstrap entry points (web/index.php, bin/*, tests/bootstrap.php) - tooling configs (phpstan.neon, phpunit.xml, php-cs-fixer) - 26 architecture contract tests - enforcement-policy, guard-catalog, quality-gates - all documentation (CLAUDE.md, README, 25 docs/, .agents/skills/) - bin/qa-extended.sh search paths - .claude/settings.local.json permission paths Workflow: .agents/runs/CORE-LIB-RENAME-001/ (Analyst → Planner → Executor → Code Review (4 findings fixed) → Security Review → Acceptance Test → Finalizer) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
331
core/Service/User/UserAssignmentService.php
Normal file
331
core/Service/User/UserAssignmentService.php
Normal file
@@ -0,0 +1,331 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Service\User;
|
||||
|
||||
use MintyPHP\Repository\Access\UserRoleRepositoryInterface;
|
||||
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
|
||||
{
|
||||
public function __construct(
|
||||
private readonly UserWriteRepositoryInterface $userWriteRepository,
|
||||
private readonly UserTenantRepositoryInterface $userTenantRepository,
|
||||
private readonly UserRoleRepositoryInterface $userRoleRepository,
|
||||
private readonly UserDepartmentRepositoryInterface $userDepartmentRepository,
|
||||
private readonly UserDirectoryGateway $directoryGateway,
|
||||
private readonly PermissionService $permissionService,
|
||||
private readonly DatabaseSessionRepository $databaseSessionRepository,
|
||||
private readonly AssignableRoleService $assignableRoleService
|
||||
) {
|
||||
}
|
||||
|
||||
public function buildAssignmentsForUser(int $userId): array
|
||||
{
|
||||
if ($userId <= 0) {
|
||||
return [
|
||||
'tenants' => [],
|
||||
'departments' => [],
|
||||
'roles' => [],
|
||||
];
|
||||
}
|
||||
|
||||
$tenantIds = $this->userTenantRepository->listTenantIdsByUserId($userId);
|
||||
$departmentIds = $this->userDepartmentRepository->listDepartmentIdsByUserId($userId);
|
||||
$roleIds = $this->userRoleRepository->listRoleIdsByUserId($userId);
|
||||
|
||||
$tenantsById = [];
|
||||
foreach ($this->directoryGateway->listTenantsByIds($tenantIds) as $tenant) {
|
||||
$tenantId = (int) ($tenant['id'] ?? 0);
|
||||
if ($tenantId <= 0) {
|
||||
continue;
|
||||
}
|
||||
$tenantsById[$tenantId] = [
|
||||
'id' => $tenantId,
|
||||
'uuid' => (string) ($tenant['uuid'] ?? ''),
|
||||
'description' => (string) ($tenant['description'] ?? ''),
|
||||
'status' => (string) ($tenant['status'] ?? ''),
|
||||
];
|
||||
}
|
||||
|
||||
$departmentsById = [];
|
||||
foreach ($this->directoryGateway->listDepartmentsByIds($departmentIds, true) as $department) {
|
||||
$departmentId = (int) ($department['id'] ?? 0);
|
||||
if ($departmentId <= 0) {
|
||||
continue;
|
||||
}
|
||||
$departmentTenantId = (int) ($department['tenant_id'] ?? 0);
|
||||
$departmentsById[$departmentId] = [
|
||||
'id' => $departmentId,
|
||||
'uuid' => (string) ($department['uuid'] ?? ''),
|
||||
'description' => (string) ($department['description'] ?? ''),
|
||||
'active' => (bool) ($department['active'] ?? 0),
|
||||
'tenant_id' => $departmentTenantId,
|
||||
'tenant_uuid' => (string) (($tenantsById[$departmentTenantId]['uuid'] ?? '')),
|
||||
];
|
||||
}
|
||||
|
||||
$rolesById = [];
|
||||
foreach ($this->directoryGateway->listRolesByIds($roleIds) as $role) {
|
||||
$roleId = (int) ($role['id'] ?? 0);
|
||||
if ($roleId <= 0) {
|
||||
continue;
|
||||
}
|
||||
$rolesById[$roleId] = [
|
||||
'id' => $roleId,
|
||||
'uuid' => (string) ($role['uuid'] ?? ''),
|
||||
'description' => (string) ($role['description'] ?? ''),
|
||||
'active' => (bool) ($role['active'] ?? 0),
|
||||
];
|
||||
}
|
||||
|
||||
$tenants = [];
|
||||
foreach ($tenantIds as $tenantId) {
|
||||
if (isset($tenantsById[$tenantId])) {
|
||||
$tenants[] = $tenantsById[$tenantId];
|
||||
}
|
||||
}
|
||||
|
||||
$departments = [];
|
||||
foreach ($departmentIds as $departmentId) {
|
||||
if (isset($departmentsById[$departmentId])) {
|
||||
$departments[] = $departmentsById[$departmentId];
|
||||
}
|
||||
}
|
||||
|
||||
$roles = [];
|
||||
foreach ($roleIds as $roleId) {
|
||||
if (isset($rolesById[$roleId])) {
|
||||
$roles[] = $rolesById[$roleId];
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'tenants' => $tenants,
|
||||
'departments' => $departments,
|
||||
'roles' => $roles,
|
||||
];
|
||||
}
|
||||
|
||||
public function findTenantSummaryInAssignments(array $assignments, int $tenantId): ?array
|
||||
{
|
||||
if ($tenantId <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
foreach (($assignments['tenants'] ?? []) as $tenant) {
|
||||
$currentTenantId = (int) ($tenant['id'] ?? 0);
|
||||
if ($currentTenantId !== $tenantId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
return [
|
||||
'uuid' => (string) ($tenant['uuid'] ?? ''),
|
||||
'description' => (string) ($tenant['description'] ?? ''),
|
||||
'status' => (string) ($tenant['status'] ?? ''),
|
||||
];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public function mapAssignmentsToPublic(array $assignments): array
|
||||
{
|
||||
return [
|
||||
'tenants' => array_values(array_map(
|
||||
static fn (array $tenant): array => [
|
||||
'uuid' => (string) ($tenant['uuid'] ?? ''),
|
||||
'description' => (string) ($tenant['description'] ?? ''),
|
||||
'status' => (string) ($tenant['status'] ?? ''),
|
||||
],
|
||||
$assignments['tenants'] ?? []
|
||||
)),
|
||||
'departments' => array_values(array_map(
|
||||
static fn (array $department): array => [
|
||||
'uuid' => (string) ($department['uuid'] ?? ''),
|
||||
'description' => (string) ($department['description'] ?? ''),
|
||||
'active' => (bool) ($department['active'] ?? 0),
|
||||
'tenant_uuid' => (string) ($department['tenant_uuid'] ?? ''),
|
||||
],
|
||||
$assignments['departments'] ?? []
|
||||
)),
|
||||
'roles' => array_values(array_map(
|
||||
static fn (array $role): array => [
|
||||
'uuid' => (string) ($role['uuid'] ?? ''),
|
||||
'description' => (string) ($role['description'] ?? ''),
|
||||
'active' => (bool) ($role['active'] ?? 0),
|
||||
],
|
||||
$assignments['roles'] ?? []
|
||||
)),
|
||||
];
|
||||
}
|
||||
|
||||
public function syncTenants(int $userId, array $tenantIds, bool $bumpAuthz = true): bool
|
||||
{
|
||||
$ids = $this->normalizeTenantIds($tenantIds);
|
||||
$result = $this->userTenantRepository->replaceForUser($userId, $ids);
|
||||
if ($result && $bumpAuthz) {
|
||||
$this->bumpAuthzVersion($userId);
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
|
||||
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));
|
||||
$validIds = $this->directoryGateway->listActiveRoleIds();
|
||||
if ($validIds) {
|
||||
$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) {
|
||||
$this->bumpAuthzVersion($userId);
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
|
||||
public function syncDepartments(int $userId, array $departmentIds, bool $bumpAuthz = true): bool
|
||||
{
|
||||
$ids = array_values(array_unique(array_map('intval', $departmentIds)));
|
||||
$ids = array_values(array_filter($ids, static fn ($id) => $id > 0));
|
||||
// Important: use the user's assigned tenants directly (no permission bypass),
|
||||
// otherwise privileged users (e.g. admins) would incorrectly allow departments
|
||||
// from tenants that were just removed from their assignments.
|
||||
$userTenantIds = array_values(array_unique(array_map(
|
||||
'intval',
|
||||
$this->userTenantRepository->listTenantIdsByUserId($userId)
|
||||
)));
|
||||
$userTenantIds = array_values(array_filter($userTenantIds, static fn ($id) => $id > 0));
|
||||
if (!$userTenantIds) {
|
||||
$ids = [];
|
||||
} else {
|
||||
$allowedIds = $this->directoryGateway->listActiveDepartmentIdsByTenantIds($userTenantIds);
|
||||
if ($allowedIds) {
|
||||
$allowedMap = array_fill_keys($allowedIds, true);
|
||||
$ids = array_values(array_filter($ids, static fn ($id) => isset($allowedMap[$id])));
|
||||
} else {
|
||||
$ids = [];
|
||||
}
|
||||
}
|
||||
$result = $this->userDepartmentRepository->replaceForUser($userId, $ids);
|
||||
if ($result && $bumpAuthz) {
|
||||
$this->bumpAuthzVersion($userId);
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
|
||||
// Incrementing authz_version causes the session to be re-checked on the user's next request.
|
||||
public function bumpAuthzVersion(int $userId): void
|
||||
{
|
||||
if ($userId <= 0) {
|
||||
return;
|
||||
}
|
||||
$this->userWriteRepository->bumpAuthzVersion($userId);
|
||||
}
|
||||
|
||||
public function syncAllAssignments(int $userId, array $tenantIds, array $roleIds, array $departmentIds, int $actorUserId = 0): bool
|
||||
{
|
||||
if ($userId <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$transactionStarted = false;
|
||||
try {
|
||||
$this->databaseSessionRepository->beginTransaction();
|
||||
$transactionStarted = true;
|
||||
|
||||
// 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, $actorUserId)
|
||||
&& $this->syncDepartments($userId, $departmentIds, false);
|
||||
if (!$ok) {
|
||||
$this->rollbackQuietly();
|
||||
return false;
|
||||
}
|
||||
|
||||
$this->bumpAuthzVersion($userId);
|
||||
$this->databaseSessionRepository->commitTransaction();
|
||||
$transactionStarted = false;
|
||||
|
||||
return true;
|
||||
} catch (\Throwable $exception) {
|
||||
if ($transactionStarted) {
|
||||
$this->rollbackQuietly();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Accepts arrays, comma-separated strings, and nested arrays — normalizes to a flat list of IDs.
|
||||
public function normalizeIdInput($value): array
|
||||
{
|
||||
$raw = is_array($value) ? $value : [$value];
|
||||
$flat = [];
|
||||
|
||||
$collect = static function ($item) use (&$collect, &$flat): void {
|
||||
if (is_array($item)) {
|
||||
foreach ($item as $nested) {
|
||||
$collect($nested);
|
||||
}
|
||||
return;
|
||||
}
|
||||
$text = trim((string) $item);
|
||||
if ($text === '') {
|
||||
return;
|
||||
}
|
||||
if (str_contains($text, ',')) {
|
||||
foreach (explode(',', $text) as $part) {
|
||||
$part = trim($part);
|
||||
if ($part !== '') {
|
||||
$flat[] = $part;
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
$flat[] = $text;
|
||||
};
|
||||
|
||||
foreach ($raw as $item) {
|
||||
$collect($item);
|
||||
}
|
||||
|
||||
$ids = array_values(array_unique(array_map('intval', $flat)));
|
||||
return array_values(array_filter($ids, static fn ($id) => $id > 0));
|
||||
}
|
||||
|
||||
public function normalizeTenantIds(array $tenantIds): array
|
||||
{
|
||||
$ids = array_values(array_unique(array_map('intval', $tenantIds)));
|
||||
$ids = array_filter($ids, static fn ($id) => $id > 0);
|
||||
$validIds = $this->directoryGateway->listTenantIds();
|
||||
if ($validIds) {
|
||||
$validMap = array_fill_keys($validIds, true);
|
||||
$ids = array_values(array_filter($ids, static fn ($id) => isset($validMap[$id])));
|
||||
}
|
||||
return $ids;
|
||||
}
|
||||
|
||||
private function rollbackQuietly(): void
|
||||
{
|
||||
try {
|
||||
$this->databaseSessionRepository->rollbackTransaction();
|
||||
} catch (\Throwable $exception) {
|
||||
// no-op
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user