corentin_wakdo/src/app/Controllers/RoleController.php
Corentin JOGUET d880f2512a
All checks were successful
CI / php-lint (push) Successful in 21s
CI / js-tests (push) Successful in 21s
CI / secret-scan (push) Successful in 10s
CI / static-tests (push) Successful in 44s
CI / auto-merge (push) Has been skipped
feat(admin): RBAC - matrice roles/permissions + roles custom (PIN+audit diff) (P3) (#39)
2026-06-17 14:25:42 +02:00

459 lines
16 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Controllers;
use PDOException;
use App\Auth\Csrf;
use App\Auth\GuardResult;
use App\Auth\PasswordHasher;
use App\Auth\PinThrottle;
use App\Auth\PinVerifier;
use App\Auth\RoleRepository;
use App\Core\DatabaseInterface;
use App\Core\Response;
/**
* Gestion RBAC (mlt 10.4 MANAGE_RBAC), permission `role.manage`. Operations a fort
* impact (escalade de privileges) : PIN equipier + ligne `audit_log` dans la meme
* transaction que l'effet (RG-T13/RG-T14), throttle PIN par acteur (RG-T22). Le
* `details` d'audit enregistre le DIFF de permissions (codes ajoutes/retires, RG-6),
* calcule AVANT la reecriture delete-and-reinsert.
*
* Le catalogue de permissions est fige au seed (lecture seule). Le `code` d'un role
* est immuable apres creation. Garde-fou anti-lockout : le role `admin` conserve
* toujours `role.manage` et reste actif.
*
* Les cases de la matrice sont soumises en champs SCALAIRES (`perm_<id>`,
* `source_<enum>`) et non en tableaux `name[]` : Request::formBody ne conserve que
* les scalaires (pas de JS requis, pas de champ JSON cache).
*
* Non `final` : les tests sous-classent (seam db()/sessionManager()).
*/
class RoleController extends AdminController
{
private const ENTITY = 'role';
private const ADMIN_CODE = 'admin';
/** @var list<string> ENUM role_visible_source.source / customer_order.source */
private const SOURCES = ['kiosk', 'counter', 'drive'];
/**
* @param array<string, string> $params
*/
public function index(array $params = []): Response
{
$guard = $this->guard('role.manage');
if ($guard instanceof Response) {
return $guard;
}
return $this->adminView('admin/roles/index', [
'title' => 'Roles et permissions - Wakdo Admin',
'activeNav' => 'roles',
'roles' => $this->roleRepository()->allRoles(),
], $guard);
}
/**
* @param array<string, string> $params
*/
public function create(array $params = []): Response
{
$guard = $this->guard('role.manage');
if ($guard instanceof Response) {
return $guard;
}
return $this->renderForm($guard, 0, [], [], [], []);
}
/**
* @param array<string, string> $params
*/
public function store(array $params = []): Response
{
$guard = $this->guard('role.manage');
if ($guard instanceof Response) {
return $guard;
}
$form = $this->request->formBody();
if (!Csrf::validate($this->sessionManager(), $form['_csrf'] ?? null)) {
return $this->invalidCsrf();
}
[$data, $errors] = $this->validate($form, true);
$permIds = $this->selectedPermissionIds($form);
$sources = $this->selectedSources($form);
if ($errors !== []) {
return $this->renderForm($guard, 0, $form, $errors, $permIds, $sources, 422);
}
if ($this->roleRepository()->codeExists((string) $data['code'])) {
return $this->renderForm($guard, 0, $form, ['code' => 'Ce code de role existe deja.'], $permIds, $sources, 409);
}
[$actor, $errorMsg] = $this->resolvePin($guard, $form, 0);
if ($actor === null) {
return $this->renderForm($guard, 0, $form, ['pin' => $errorMsg], $permIds, $sources, 422);
}
$addedCodes = $this->codesForIds($permIds);
try {
$this->db()->transaction(function (DatabaseInterface $db) use ($data, $permIds, $sources, $actor, $addedCodes): void {
$repo = new RoleRepository($db);
$newId = $repo->createRole([
'code' => (string) $data['code'],
'label' => (string) $data['label'],
'description' => $data['description'],
'default_route' => $data['default_route'],
'order_source' => $data['order_source'],
]);
$repo->replacePermissions($db, $newId, $permIds);
$repo->replaceVisibleSources($db, $newId, $sources);
$this->writeAudit($db, $actor['id'], $actor['role_id'], $newId, 'Creation role ' . (string) $data['code'], ['added' => $addedCodes, 'removed' => []]);
});
} catch (PDOException $exception) {
if ((string) $exception->getCode() === '23000') {
return $this->renderForm($guard, 0, $form, ['code' => 'Ce code de role existe deja.'], $permIds, $sources, 409);
}
throw $exception;
}
$this->pinThrottle()->reset($guard->userId ?? 0);
$this->setFlash('Role cree.');
return $this->redirect('/admin/roles');
}
/**
* @param array<string, string> $params
*/
public function edit(array $params): Response
{
$guard = $this->guard('role.manage');
if ($guard instanceof Response) {
return $guard;
}
$id = (int) ($params['id'] ?? 0);
$role = $this->roleRepository()->findRole($id);
if ($role === null) {
return $this->notFound($guard);
}
return $this->renderForm(
$guard,
$id,
$role,
[],
$this->roleRepository()->permissionIdsFor($id),
$this->roleRepository()->visibleSources($id),
);
}
/**
* @param array<string, string> $params
*/
public function update(array $params): Response
{
$guard = $this->guard('role.manage');
if ($guard instanceof Response) {
return $guard;
}
$form = $this->request->formBody();
if (!Csrf::validate($this->sessionManager(), $form['_csrf'] ?? null)) {
return $this->invalidCsrf();
}
$id = (int) ($params['id'] ?? 0);
$current = $this->roleRepository()->findRole($id);
if ($current === null) {
return $this->notFound($guard);
}
[$data, $errors] = $this->validate($form, false);
$permIds = $this->selectedPermissionIds($form);
$sources = $this->selectedSources($form);
if ($errors !== []) {
return $this->renderForm($guard, $id, $form + ['code' => $current['code']], $errors, $permIds, $sources, 422);
}
$isActive = isset($form['is_active']) ? 1 : 0;
$newCodes = $this->codesForIds($permIds);
// Garde-fou anti-lockout : le role admin garde role.manage ET reste actif.
if ((string) ($current['code'] ?? '') === self::ADMIN_CODE) {
if (!in_array('role.manage', $newCodes, true) || $isActive === 0) {
return $this->renderForm($guard, $id, $form + ['code' => $current['code']], ['permissions' => 'Le role administrateur doit conserver role.manage et rester actif.'], $permIds, $sources, 422);
}
}
[$actor, $errorMsg] = $this->resolvePin($guard, $form, $id);
if ($actor === null) {
return $this->renderForm($guard, $id, $form + ['code' => $current['code']], ['pin' => $errorMsg], $permIds, $sources, 422);
}
// Diff de permissions (RG-6), calcule AVANT la reecriture.
$currentCodes = $this->roleRepository()->permissionCodesFor($id);
$added = array_values(array_diff($newCodes, $currentCodes));
$removed = array_values(array_diff($currentCodes, $newCodes));
$this->db()->transaction(function (DatabaseInterface $db) use ($id, $data, $isActive, $permIds, $sources, $actor, $added, $removed): void {
$repo = new RoleRepository($db);
$repo->updateRole($id, [
'label' => (string) $data['label'],
'description' => $data['description'],
'default_route' => $data['default_route'],
'order_source' => $data['order_source'],
'is_active' => $isActive,
]);
$repo->replacePermissions($db, $id, $permIds);
$repo->replaceVisibleSources($db, $id, $sources);
$this->writeAudit($db, $actor['id'], $actor['role_id'], $id, 'Mise a jour RBAC role ' . (string) ($data['code'] ?? ''), ['added' => $added, 'removed' => $removed]);
});
$this->pinThrottle()->reset($guard->userId ?? 0);
$this->setFlash('Role mis a jour.');
return $this->redirect('/admin/roles');
}
// --- Helpers ---
protected function roleRepository(): RoleRepository
{
return new RoleRepository($this->db());
}
protected function pinVerifier(): PinVerifier
{
return new PinVerifier($this->db(), $this->config, $this->passwordHasher());
}
protected function pinThrottle(): PinThrottle
{
return new PinThrottle($this->db(), $this->config);
}
protected function passwordHasher(): PasswordHasher
{
return new PasswordHasher($this->config);
}
/**
* @param array<string, string> $form
* @return list<int>
*/
private function selectedPermissionIds(array $form): array
{
$ids = [];
foreach ($this->roleRepository()->allPermissions() as $p) {
$pid = (int) ($p['id'] ?? 0);
if ($pid > 0 && ($form['perm_' . $pid] ?? '') !== '') {
$ids[] = $pid;
}
}
return $ids;
}
/**
* @param array<string, string> $form
* @return list<string>
*/
private function selectedSources(array $form): array
{
$out = [];
foreach (self::SOURCES as $source) {
if (($form['source_' . $source] ?? '') !== '') {
$out[] = $source;
}
}
return $out;
}
/**
* Codes de permission correspondant a une liste d'ids (via le catalogue).
*
* @param list<int> $ids
* @return list<string>
*/
private function codesForIds(array $ids): array
{
$map = [];
foreach ($this->roleRepository()->allPermissions() as $p) {
$map[(int) ($p['id'] ?? 0)] = (string) ($p['code'] ?? '');
}
$codes = [];
foreach ($ids as $id) {
if (isset($map[$id]) && $map[$id] !== '') {
$codes[] = $map[$id];
}
}
return $codes;
}
/**
* Porte du PIN sensible (RG-T13 + throttle RG-T22), identique a UserController.
*
* @param array<string, string> $form
* @return array{0: array<string, mixed>|null, 1: string}
*/
private function resolvePin(GuardResult $guard, array $form, int $entityId): array
{
$generic = 'Email ou PIN invalide (requis pour cette action).';
$actorId = $guard->userId ?? 0;
if ($actorId > 0 && $this->pinThrottle()->isLocked($actorId)) {
$this->pinVerifier()->payTimingDecoy($form['pin'] ?? '');
return [null, $generic];
}
$actor = $this->pinVerifier()->resolveActingUser(trim($form['pin_email'] ?? ''), $form['pin'] ?? '');
if ($actor === null) {
$email = trim($form['pin_email'] ?? '');
$this->db()->transaction(function (DatabaseInterface $db) use ($email, $entityId, $actorId): void {
$this->logFailedPin($db, $email, $entityId);
$this->pinThrottle()->recordFailureWithin($db, $actorId);
});
return [null, $generic];
}
return [$actor, ''];
}
/**
* Validation serveur (RG-T18). `code` requis + immuable a la creation seulement.
*
* @param array<string, string> $form
* @return array{0: array{code: ?string, label: string, description: ?string, default_route: ?string, order_source: ?string}, 1: array<string, string>}
*/
private function validate(array $form, bool $isCreate): array
{
$errors = [];
$code = null;
if ($isCreate) {
$code = trim($form['code'] ?? '');
if ($code === '' || mb_strlen($code) > 40 || preg_match('/^[a-z][a-z0-9_]{1,39}$/', $code) !== 1) {
$errors['code'] = 'Code requis : minuscules/chiffres/_ , commence par une lettre (40 max).';
}
}
$label = trim($form['label'] ?? '');
if ($label === '' || mb_strlen($label) > 80) {
$errors['label'] = 'Le libelle est requis (80 caracteres max).';
}
$route = trim($form['default_route'] ?? '');
if (mb_strlen($route) > 120) {
$errors['default_route'] = 'Route par defaut trop longue (120 max).';
}
$source = trim($form['order_source'] ?? '');
if ($source !== '' && !in_array($source, self::SOURCES, true)) {
$errors['order_source'] = 'Source de commande invalide.';
}
$description = trim($form['description'] ?? '');
$data = [
'code' => $code,
'label' => $label,
'description' => $description !== '' ? $description : null,
'default_route' => $route !== '' ? $route : null,
'order_source' => $source !== '' ? $source : null,
];
return [$data, $errors];
}
private function logFailedPin(DatabaseInterface $db, string $email, int $entityId): void
{
$db->execute(
'INSERT INTO audit_log (actor_user_id, actor_role_id, action_code, entity_type, entity_id, summary) '
. 'VALUES (:uid, :rid, :code, :etype, :eid, :summary)',
[
'uid' => null,
'rid' => null,
'code' => 'pin.failed',
'etype' => self::ENTITY,
'eid' => $entityId > 0 ? $entityId : null,
'summary' => 'Echec PIN gestion RBAC (email tente: ' . $email . ')',
],
);
}
/**
* @param array<string, mixed> $details
*/
private function writeAudit(DatabaseInterface $db, int $userId, int $roleId, int $entityId, string $summary, array $details): void
{
$db->execute(
'INSERT INTO audit_log (actor_user_id, actor_role_id, action_code, entity_type, entity_id, summary, details) '
. 'VALUES (:uid, :rid, :code, :etype, :eid, :summary, :details)',
[
'uid' => $userId,
'rid' => $roleId,
'code' => 'role.manage',
'etype' => self::ENTITY,
'eid' => $entityId,
'summary' => $summary,
'details' => (string) json_encode($details),
],
);
}
/**
* @param array<string, mixed> $values
* @param array<string, string> $errors
* @param list<int> $selectedPermIds
* @param list<string> $selectedSources
*/
private function renderForm(GuardResult $guard, int $id, array $values, array $errors, array $selectedPermIds, array $selectedSources, int $status = 200): Response
{
return $this->adminView('admin/roles/form', [
'title' => ($id !== 0 ? 'Modifier' : 'Nouveau') . ' role - Wakdo Admin',
'activeNav' => 'roles',
'roleId' => $id,
'isAdminRole' => (string) ($values['code'] ?? '') === self::ADMIN_CODE,
'permissions' => $this->roleRepository()->allPermissions(),
'sources' => self::SOURCES,
'selectedPerms' => $selectedPermIds,
'selectedSources' => $selectedSources,
'values' => [
'code' => (string) ($values['code'] ?? ''),
'label' => (string) ($values['label'] ?? ''),
'description' => (string) ($values['description'] ?? ''),
'default_route' => (string) ($values['default_route'] ?? ''),
'order_source' => (string) ($values['order_source'] ?? ''),
'is_active' => $id === 0 ? true : ((int) ($values['is_active'] ?? 1) === 1),
],
'errors' => $errors,
'csrfToken' => Csrf::token($this->sessionManager()),
], $guard, $status);
}
private function notFound(GuardResult $guard): Response
{
return $this->adminView('admin/not_found', ['title' => 'Introuvable', 'activeNav' => 'roles'], $guard, 404);
}
private function redirect(string $location): Response
{
return Response::make('', 302, ['Location' => $location]);
}
private function invalidCsrf(): Response
{
return Response::make('Requete invalide.', 403, ['Content-Type' => 'text/plain; charset=utf-8']);
}
}