2026-09-01 20:43:24 +02:00
|
|
|
<?php
|
|
|
|
|
declare(strict_types=1);
|
|
|
|
|
|
|
|
|
|
namespace App\Domain\User;
|
|
|
|
|
|
|
|
|
|
use LogicException;
|
|
|
|
|
|
|
|
|
|
/** Framework-free normalization, validation, and safety rules for roles. */
|
|
|
|
|
final class RoleRecord
|
|
|
|
|
{
|
|
|
|
|
private const ADMINISTRATOR = 'administrator';
|
|
|
|
|
|
|
|
|
|
/** @var list<string> */
|
|
|
|
|
private const DISPLAY_FIELDS = ['id', 'name', 'description', 'created_at', 'permissions'];
|
|
|
|
|
|
|
|
|
|
/** @return array{name: string, description: string|null} */
|
|
|
|
|
public function normalize(array $record): array
|
|
|
|
|
{
|
|
|
|
|
$name = $record['name'] ?? null;
|
|
|
|
|
$description = $record['description'] ?? null;
|
|
|
|
|
|
|
|
|
|
return [
|
|
|
|
|
'name' => is_scalar($name) ? trim((string) $name) : '',
|
|
|
|
|
'description' => $this->normalizeDescription($description),
|
|
|
|
|
];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** @return array{name: string, description: string|null, valid: bool, errors: array<string, string>} */
|
|
|
|
|
public function validate(array $record): array
|
|
|
|
|
{
|
|
|
|
|
$normalized = $this->normalize($record);
|
|
|
|
|
$errors = [];
|
|
|
|
|
|
|
|
|
|
if ($normalized['name'] === '') {
|
|
|
|
|
$errors['name'] = 'Role name is required.';
|
|
|
|
|
} elseif (mb_strlen($normalized['name']) > 80) {
|
|
|
|
|
$errors['name'] = 'Role name must be 80 characters or fewer.';
|
|
|
|
|
} elseif (preg_match('/[\x00-\x1F\x7F]/', $normalized['name']) === 1) {
|
|
|
|
|
$errors['name'] = 'Role name contains invalid control characters.';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if ($normalized['description'] !== null && mb_strlen($normalized['description']) > 255) {
|
|
|
|
|
$errors['description'] = 'Role description must be 255 characters or fewer.';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return [...$normalized, 'valid' => $errors === [], 'errors' => $errors];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public function isAdministrator(array $record): bool
|
|
|
|
|
{
|
2026-09-01 21:47:35 +02:00
|
|
|
if (isset($record['id']) && (int) $record['id'] === 1) return true;
|
2026-09-01 20:43:24 +02:00
|
|
|
return $this->canonicalName($record['name'] ?? null) === self::ADMINISTRATOR;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public function canRename(array $record, mixed $newName): bool
|
|
|
|
|
{
|
|
|
|
|
return !$this->isAdministrator($record) && $this->canonicalName($newName) !== self::ADMINISTRATOR;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public function canDelete(array $record): bool
|
|
|
|
|
{
|
|
|
|
|
return !$this->isAdministrator($record);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public function canChangePermissions(array $record): bool
|
|
|
|
|
{
|
|
|
|
|
return !$this->isAdministrator($record);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public function assertCanRename(array $record, mixed $newName): void
|
|
|
|
|
{
|
|
|
|
|
if (!$this->canRename($record, $newName)) {
|
|
|
|
|
throw new LogicException('The protected Administrator role cannot be renamed.');
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public function assertCanDelete(array $record): void
|
|
|
|
|
{
|
|
|
|
|
if (!$this->canDelete($record)) {
|
|
|
|
|
throw new LogicException('The protected Administrator role cannot be deleted.');
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public function assertCanChangePermissions(array $record): void
|
|
|
|
|
{
|
|
|
|
|
if (!$this->canChangePermissions($record)) {
|
|
|
|
|
throw new LogicException('The protected Administrator role permissions cannot be changed.');
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** @return array<string, mixed> */
|
|
|
|
|
public function display(array $record): array
|
|
|
|
|
{
|
|
|
|
|
$safe = [];
|
|
|
|
|
foreach (self::DISPLAY_FIELDS as $field) {
|
|
|
|
|
if (array_key_exists($field, $record)) {
|
|
|
|
|
$safe[$field] = $field === 'permissions' && is_array($record[$field])
|
|
|
|
|
? (new PermissionMatrix())->normalize($record[$field])
|
|
|
|
|
: $record[$field];
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return $safe;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** @return array<string, mixed> */
|
|
|
|
|
public function toDisplay(array $record): array
|
|
|
|
|
{
|
|
|
|
|
return $this->display($record);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private function normalizeDescription(mixed $value): ?string
|
|
|
|
|
{
|
|
|
|
|
if (!is_scalar($value)) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
$value = trim((string) $value);
|
|
|
|
|
return $value === '' ? null : $value;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private function canonicalName(mixed $value): string
|
|
|
|
|
{
|
|
|
|
|
return is_scalar($value) ? strtolower(trim((string) $value)) : '';
|
|
|
|
|
}
|
|
|
|
|
}
|