feat: add administration reporting and correction services
This commit is contained in:
@@ -0,0 +1,102 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Domain\User;
|
||||
|
||||
require_once __DIR__ . '/PermissionMatrix.php';
|
||||
require_once __DIR__ . '/RoleRecord.php';
|
||||
|
||||
/** PDO-independent contracts for custom-role permission assignment. */
|
||||
final class RolePermissionService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ?RoleRecord $roles = null,
|
||||
private readonly ?PermissionMatrix $permissions = null,
|
||||
) {
|
||||
}
|
||||
|
||||
/** @return array{role_id: int, permissions: list<string>, valid: bool, errors: array<string, string>} */
|
||||
public function validate(array $role, array $selected, array $available = []): array
|
||||
{
|
||||
return $this->validateAssignment($role, $selected, $available);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate and normalize a role's selected permissions. When an available
|
||||
* list is supplied, selections outside that list are rejected rather than
|
||||
* silently discarded.
|
||||
*
|
||||
* @return array{role_id: int, permissions: list<string>, valid: bool, errors: array<string, string>}
|
||||
*/
|
||||
public function validateAssignment(array $role, array $selected, array $available = []): array
|
||||
{
|
||||
$roleId = $this->positiveId($role['id'] ?? $role['role_id'] ?? null);
|
||||
$normalized = ($this->permissions ?? new PermissionMatrix())->normalize($selected);
|
||||
$errors = [];
|
||||
if ($roleId === null) $errors['role_id'] = 'Role ID must be a positive integer.';
|
||||
if (($this->roles ?? new RoleRecord())->isAdministrator($role)) {
|
||||
$errors['role'] = 'The protected Administrator role permissions cannot be changed.';
|
||||
}
|
||||
|
||||
if ($available !== []) {
|
||||
$allowed = ($this->permissions ?? new PermissionMatrix())->normalize($available);
|
||||
$unknown = array_values(array_diff($normalized, $allowed));
|
||||
if ($unknown !== []) {
|
||||
$errors['permissions'] = 'Unknown permissions cannot be assigned: ' . implode(', ', $unknown) . '.';
|
||||
}
|
||||
}
|
||||
|
||||
return ['role_id' => $roleId ?? 0, 'permissions' => $normalized, 'valid' => $errors === [], 'errors' => $errors];
|
||||
}
|
||||
|
||||
/** Alias matching controller command terminology. */
|
||||
public function validateForAssignment(array $role, array $selected, array $available = []): array
|
||||
{
|
||||
return $this->validateAssignment($role, $selected, $available);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a role edit and its optional permission set in one safe result.
|
||||
* Administrator cannot be renamed or have permissions changed.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function validateForEdit(int $id, array $input, array $available = []): array
|
||||
{
|
||||
$record = $this->roles ?? new RoleRecord();
|
||||
$result = $record->validate($input);
|
||||
$result['id'] = $id;
|
||||
if ($id < 1) $result['errors']['id'] = 'Role ID must be a positive integer.';
|
||||
$current = ['id' => $id, 'name' => $id === 1 ? 'Administrator' : ($input['current_name'] ?? ($input['name'] ?? null))];
|
||||
if (array_key_exists('current_name', $input)) {
|
||||
if (!$record->canRename($current, $result['name'])) $result['errors']['role'] = 'The protected Administrator role cannot be renamed.';
|
||||
}
|
||||
if (array_key_exists('permissions', $input)) {
|
||||
$assignment = $this->validateAssignment(['id' => $id, 'name' => $current['name']], is_array($input['permissions']) ? $input['permissions'] : [], $available);
|
||||
$result['permissions'] = $assignment['permissions'];
|
||||
$result['errors'] = [...$result['errors'], ...$assignment['errors']];
|
||||
}
|
||||
$result['valid'] = $result['errors'] === [];
|
||||
return $result;
|
||||
}
|
||||
|
||||
public function canAssignPermissions(array $role): bool
|
||||
{
|
||||
return !($this->roles ?? new RoleRecord())->isAdministrator($role);
|
||||
}
|
||||
|
||||
public function assertCanAssignPermissions(array $role): void
|
||||
{
|
||||
($this->roles ?? new RoleRecord())->assertCanChangePermissions($role);
|
||||
}
|
||||
|
||||
private function positiveId(mixed $value): ?int
|
||||
{
|
||||
if (is_int($value) && $value > 0) return $value;
|
||||
if (is_string($value) && preg_match('/^[1-9]\d*$/', trim($value)) === 1) {
|
||||
$id = filter_var(trim($value), FILTER_VALIDATE_INT);
|
||||
return $id === false ? null : $id;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Domain\User;
|
||||
|
||||
require_once __DIR__ . '/PasswordPolicy.php';
|
||||
require_once __DIR__ . '/UserRecord.php';
|
||||
|
||||
/**
|
||||
* PDO-independent validation contracts for user administration actions.
|
||||
* Persistence and authorization middleware remain the caller's responsibility.
|
||||
*/
|
||||
final class UserAdminService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ?UserRecord $users = null,
|
||||
private readonly ?PasswordPolicy $passwordPolicy = null,
|
||||
) {
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
public function validate(array $input, array $existingUsers = [], ?int $currentId = null): array
|
||||
{
|
||||
$result = ($this->users ?? new UserRecord())->validate($input);
|
||||
$email = $result['email'] ?? '';
|
||||
if ($currentId !== null && is_string($email) && $email !== '' && $this->hasDuplicateEmail($email, $existingUsers, $currentId)) {
|
||||
$result['errors']['email'] = 'Email address is already in use.';
|
||||
}
|
||||
$result['valid'] = $result['errors'] === [];
|
||||
return $result;
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
public function validateForEdit(int $id, array $input, array $existingUsers = []): array
|
||||
{
|
||||
$errors = [];
|
||||
if ($id < 1) {
|
||||
$errors['id'] = 'User ID must be a positive integer.';
|
||||
}
|
||||
|
||||
$result = ($this->users ?? new UserRecord())->validate($input);
|
||||
$existing = $existingUsers[array_search($id, array_map(static fn($row) => is_array($row) ? (int)($row['id'] ?? 0) : 0, $existingUsers), true)] ?? [];
|
||||
if ($this->isProtectedAdministrator($existing) && array_key_exists('role_id', $input) && (int)$input['role_id'] !== (int)($existing['role_id'] ?? 1)) $result['errors']['role_id'] = 'The protected Administrator account cannot be reassigned.';
|
||||
$email = $result['email'] ?? '';
|
||||
if (is_string($email) && $email !== '' && $this->hasDuplicateEmail($email, $existingUsers, $id)) {
|
||||
$result['errors']['email'] = 'Email address is already in use.';
|
||||
}
|
||||
|
||||
$result['id'] = $id;
|
||||
$result['errors'] = [...$errors, ...$result['errors']];
|
||||
$result['valid'] = $result['errors'] === [];
|
||||
return $result;
|
||||
}
|
||||
|
||||
/** @return array{valid: bool, id: int, is_active: bool, errors: array<string, string>} */
|
||||
public function validateDeactivate(array $user): array
|
||||
{
|
||||
return $this->validateTransition($user, true, false, 'deactivated');
|
||||
}
|
||||
|
||||
/** @return array{valid: bool, id: int, is_active: bool, errors: array<string, string>} */
|
||||
public function validateReactivate(array $user): array
|
||||
{
|
||||
return $this->validateTransition($user, false, true, 'reactivated');
|
||||
}
|
||||
|
||||
/** @return array{valid: bool, id: int, is_active: bool, errors: array<string, string>} */
|
||||
public function deactivate(array $user): array
|
||||
{
|
||||
return $this->validateDeactivate($user);
|
||||
}
|
||||
|
||||
/** @return array{valid: bool, id: int, is_active: bool, errors: array<string, string>} */
|
||||
public function reactivate(array $user): array
|
||||
{
|
||||
return $this->validateReactivate($user);
|
||||
}
|
||||
|
||||
/** Validate reset input without ever returning the plaintext password. */
|
||||
/** @return array{valid: bool, errors: array<string, string>} */
|
||||
public function validatePasswordReset(array $user, mixed $password = null): array
|
||||
{
|
||||
$payload = $password === null && array_key_exists('password', $user);
|
||||
if ($payload) $password = $user['password'];
|
||||
|
||||
$errors = [];
|
||||
if (!$payload && $this->positiveId($user['id'] ?? null) === null) {
|
||||
$errors['id'] = 'User ID must be a positive integer.';
|
||||
}
|
||||
$check = ($this->passwordPolicy ?? new PasswordPolicy())->validateReset($password);
|
||||
if (!$check['valid']) {
|
||||
$errors['password'] = 'Password requires: ' . implode(', ', $check['errors']) . '.';
|
||||
}
|
||||
return ['valid' => $errors === [], 'errors' => $errors];
|
||||
}
|
||||
|
||||
/** Aliases useful to controllers accepting a reset command payload. */
|
||||
/** @return array{valid: bool, errors: array<string, string>} */
|
||||
public function validateReset(array $user, mixed $password = null): array
|
||||
{
|
||||
return $this->validatePasswordReset($user, $password);
|
||||
}
|
||||
|
||||
/** @return array{valid: bool, errors: array<string, string>} */
|
||||
public function validateResetPassword(array $user, mixed $password = null): array
|
||||
{
|
||||
return $this->validatePasswordReset($user, $password);
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
public function display(array $user): array
|
||||
{
|
||||
return ($this->users ?? new UserRecord())->display($user);
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
public function toDisplay(array $user): array
|
||||
{
|
||||
return $this->display($user);
|
||||
}
|
||||
|
||||
public function isProtectedAdministrator(array $user): bool
|
||||
{
|
||||
if (isset($user['role_id']) && (int)$user['role_id'] === 1) return true;
|
||||
$role = $user['role_name'] ?? $user['role'] ?? null;
|
||||
return is_scalar($role) && strtolower(trim((string) $role)) === 'administrator';
|
||||
}
|
||||
|
||||
private function validateTransition(array $user, bool $from, bool $to, string $action): array
|
||||
{
|
||||
$id = $this->positiveId($user['id'] ?? null);
|
||||
$active = $this->asBool($user['is_active'] ?? $user['active'] ?? null);
|
||||
$errors = [];
|
||||
if ($id === null) {
|
||||
$errors['id'] = 'User ID must be a positive integer.';
|
||||
}
|
||||
if ($this->isProtectedAdministrator($user)) {
|
||||
$errors['role'] = 'The protected Administrator account cannot be deactivated.';
|
||||
} elseif ($active !== $from) {
|
||||
$errors['is_active'] = "Only {$this->stateName($from)} users can be {$action}.";
|
||||
}
|
||||
return ['valid' => $errors === [], 'id' => $id ?? 0, 'is_active' => $to, 'errors' => $errors];
|
||||
}
|
||||
|
||||
private function hasDuplicateEmail(string $email, array $rows, int $currentId): bool
|
||||
{
|
||||
foreach ($rows as $row) {
|
||||
if (!is_array($row) || $this->positiveId($row['id'] ?? null) === $currentId) continue;
|
||||
$other = $row['email'] ?? null;
|
||||
if (is_scalar($other) && strtolower(trim((string) $other)) === $email) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private function stateName(bool $active): string
|
||||
{
|
||||
return $active ? 'active' : 'inactive';
|
||||
}
|
||||
|
||||
private function positiveId(mixed $value): ?int
|
||||
{
|
||||
if (is_int($value) && $value > 0) return $value;
|
||||
if (is_string($value) && preg_match('/^[1-9]\d*$/', trim($value)) === 1) {
|
||||
$id = filter_var(trim($value), FILTER_VALIDATE_INT);
|
||||
return $id === false ? null : $id;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private function asBool(mixed $value): ?bool
|
||||
{
|
||||
if (is_bool($value)) return $value;
|
||||
if (is_int($value) && ($value === 0 || $value === 1)) return $value === 1;
|
||||
if (is_string($value)) {
|
||||
$value = strtolower(trim($value));
|
||||
if (in_array($value, ['1', 'true', 'yes', 'on'], true)) return true;
|
||||
if (in_array($value, ['0', 'false', 'no', 'off'], true)) return false;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user