From d880f2512a626255d6da226dc4bc769b234fe1b0 Mon Sep 17 00:00:00 2001 From: Corentin JOGUET Date: Wed, 17 Jun 2026 14:25:42 +0200 Subject: [PATCH] feat(admin): RBAC - matrice roles/permissions + roles custom (PIN+audit diff) (P3) (#39) --- src/app/Auth/RoleRepository.php | 214 ++++++++++ src/app/Controllers/RoleController.php | 459 +++++++++++++++++++++ src/app/Views/admin/layout.php | 12 +- src/app/Views/admin/roles/form.php | 145 +++++++ src/app/Views/admin/roles/index.php | 67 +++ src/public/admin/index.php | 9 + tests/Integration/RoleRepositoryDbTest.php | 138 +++++++ tests/Support/FakeDatabase.php | 67 +++ tests/Unit/Admin/RoleControllerTest.php | 298 +++++++++++++ 9 files changed, 1405 insertions(+), 4 deletions(-) create mode 100644 src/app/Auth/RoleRepository.php create mode 100644 src/app/Controllers/RoleController.php create mode 100644 src/app/Views/admin/roles/form.php create mode 100644 src/app/Views/admin/roles/index.php create mode 100644 tests/Integration/RoleRepositoryDbTest.php create mode 100644 tests/Unit/Admin/RoleControllerTest.php diff --git a/src/app/Auth/RoleRepository.php b/src/app/Auth/RoleRepository.php new file mode 100644 index 0000000..60dd2c9 --- /dev/null +++ b/src/app/Auth/RoleRepository.php @@ -0,0 +1,214 @@ +> + */ + public function allRoles(): array + { + return $this->db->fetchAll( + 'SELECT id, code, label, description, default_route, order_source, is_active ' + . 'FROM role ORDER BY id', + ); + } + + /** + * @return array|null + */ + public function findRole(int $id): ?array + { + return $this->db->fetch( + 'SELECT id, code, label, description, default_route, order_source, is_active ' + . 'FROM role WHERE id = :id', + ['id' => $id], + ); + } + + public function codeExists(string $code, int $exceptId = 0): bool + { + return $this->db->fetch( + 'SELECT id FROM role WHERE code = :code AND id <> :id', + ['code' => $code, 'id' => $exceptId], + ) !== null; + } + + /** + * Catalogue complet des permissions (fige au seed), pour peupler la matrice. + * + * @return array> + */ + public function allPermissions(): array + { + return $this->db->fetchAll('SELECT id, code, label FROM permission ORDER BY id'); + } + + /** + * @return list + */ + public function permissionIdsFor(int $roleId): array + { + $rows = $this->db->fetchAll( + 'SELECT permission_id FROM role_permission WHERE role_id = :id', + ['id' => $roleId], + ); + + return array_map(static fn (array $r): int => (int) ($r['permission_id'] ?? 0), $rows); + } + + /** + * Codes de permission d'un role (pour le diff d'audit RG-6 : add/remove). + * + * @return list + */ + public function permissionCodesFor(int $roleId): array + { + $rows = $this->db->fetchAll( + 'SELECT p.code FROM role_permission rp JOIN permission p ON p.id = rp.permission_id ' + . 'WHERE rp.role_id = :id ORDER BY p.code', + ['id' => $roleId], + ); + + return array_map(static fn (array $r): string => (string) ($r['code'] ?? ''), $rows); + } + + /** + * Reecrit la matrice d'un role (mlt 10.4 RG-1) : DELETE puis INSERT des paires + * selectionnees, dans UNE transaction. L'appelant a deja filtre les + * permission_id au catalogue existant (PRE-3). + * + * @param list $permissionIds + */ + public function setPermissions(int $roleId, array $permissionIds): void + { + $this->db->transaction(function (DatabaseInterface $db) use ($roleId, $permissionIds): void { + $this->replacePermissions($db, $roleId, $permissionIds); + }); + } + + /** + * Variante SANS transaction propre : reecrit la matrice sur le $db fourni, pour + * que le controleur l'enrobe dans UNE transaction avec l'ecriture d'audit (RG-6, + * audit du diff dans la meme transaction que l'effet). Ne pas appeler hors d'une + * transaction de l'appelant. + * + * @param list $permissionIds + */ + public function replacePermissions(DatabaseInterface $db, int $roleId, array $permissionIds): void + { + $db->execute('DELETE FROM role_permission WHERE role_id = :id', ['id' => $roleId]); + foreach (array_values(array_unique($permissionIds)) as $permissionId) { + $db->execute( + 'INSERT INTO role_permission (role_id, permission_id) VALUES (:role, :perm)', + ['role' => $roleId, 'perm' => $permissionId], + ); + } + } + + /** + * Creation d'un role personnalise (mlt 10.4 RG-4). `is_active` pose cote serveur. + * Retourne l'id. Allowlist RG-T16. + * + * @param array{code: string, label: string, description: ?string, default_route: ?string, order_source: ?string} $data + */ + public function createRole(array $data): int + { + $this->db->execute( + 'INSERT INTO role (code, label, description, default_route, order_source, is_active) ' + . 'VALUES (:code, :label, :description, :route, :source, 1)', + [ + 'code' => $data['code'], + 'label' => $data['label'], + 'description' => $data['description'], + 'route' => $data['default_route'], + 'source' => $data['order_source'], + ], + ); + + return (int) ($this->db->fetch('SELECT LAST_INSERT_ID() AS id')['id'] ?? 0); + } + + /** + * Mise a jour d'un role. Le `code` n'est PAS lie (immuable apres creation). + * + * @param array{label: string, description: ?string, default_route: ?string, order_source: ?string, is_active: int} $data + */ + public function updateRole(int $id, array $data): void + { + $this->db->execute( + 'UPDATE role SET label = :label, description = :description, default_route = :route, ' + . 'order_source = :source, is_active = :active WHERE id = :id', + [ + 'label' => $data['label'], + 'description' => $data['description'], + 'route' => $data['default_route'], + 'source' => $data['order_source'], + 'active' => $data['is_active'], + 'id' => $id, + ], + ); + } + + /** + * @return list + */ + public function visibleSources(int $roleId): array + { + $rows = $this->db->fetchAll( + 'SELECT source FROM role_visible_source WHERE role_id = :id', + ['id' => $roleId], + ); + + return array_map(static fn (array $r): string => (string) ($r['source'] ?? ''), $rows); + } + + /** + * Reecrit les sources visibles d'un role (delete-and-reinsert, tx). L'appelant + * filtre $sources a l'ENUM valide ('kiosk','counter','drive'). + * + * @param list $sources + */ + public function setVisibleSources(int $roleId, array $sources): void + { + $this->db->transaction(function (DatabaseInterface $db) use ($roleId, $sources): void { + $this->replaceVisibleSources($db, $roleId, $sources); + }); + } + + /** + * Variante SANS transaction propre (cf. replacePermissions), pour enrobage par + * le controleur dans une transaction unique. + * + * @param list $sources + */ + public function replaceVisibleSources(DatabaseInterface $db, int $roleId, array $sources): void + { + $db->execute('DELETE FROM role_visible_source WHERE role_id = :id', ['id' => $roleId]); + foreach (array_values(array_unique($sources)) as $source) { + $db->execute( + 'INSERT INTO role_visible_source (role_id, source) VALUES (:role, :source)', + ['role' => $roleId, 'source' => $source], + ); + } + } +} diff --git a/src/app/Controllers/RoleController.php b/src/app/Controllers/RoleController.php new file mode 100644 index 0000000..7886bd4 --- /dev/null +++ b/src/app/Controllers/RoleController.php @@ -0,0 +1,459 @@ +`, + * `source_`) 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 ENUM role_visible_source.source / customer_order.source */ + private const SOURCES = ['kiosk', 'counter', 'drive']; + + /** + * @param array $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 $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 $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 $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 $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 $form + * @return list + */ + 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 $form + * @return list + */ + 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 $ids + * @return list + */ + 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 $form + * @return array{0: array|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 $form + * @return array{0: array{code: ?string, label: string, description: ?string, default_route: ?string, order_source: ?string}, 1: array} + */ + 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 $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 $values + * @param array $errors + * @param list $selectedPermIds + * @param list $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']); + } +} diff --git a/src/app/Views/admin/layout.php b/src/app/Views/admin/layout.php index 4fb894b..eed811e 100644 --- a/src/app/Views/admin/layout.php +++ b/src/app/Views/admin/layout.php @@ -125,18 +125,22 @@ $navClass = static function (string $code, string $current): string { - + diff --git a/src/app/Views/admin/roles/form.php b/src/app/Views/admin/roles/form.php new file mode 100644 index 0000000..a8eb864 --- /dev/null +++ b/src/app/Views/admin/roles/form.php @@ -0,0 +1,145 @@ +`, `source_`) : Request::formBody ne garde que les scalaires, + * donc pas de `name[]` ni de JS. Toute soumission exige le PIN equipier (RG-T13). + * Le `code` est editable a la creation, fige a l'edition (immuable). + * + * @var int $roleId + * @var bool $isAdminRole + * @var array> $permissions catalogue {id, code, label} + * @var list $sources enum visibles + * @var list $selectedPerms + * @var list $selectedSources + * @var array $values + * @var array $errors + * @var string $csrfToken + */ + +$csrf = htmlspecialchars($csrfToken ?? '', ENT_QUOTES, 'UTF-8'); +$id = (int) ($roleId ?? 0); +$action = $id !== 0 ? '/admin/roles/' . $id : '/admin/roles'; +$isAdmin = (bool) ($isAdminRole ?? false); +/** @var array $vals */ +$vals = isset($values) && is_array($values) ? $values : []; +/** @var array $errs */ +$errs = isset($errors) && is_array($errors) ? $errors : []; +/** @var array> $perms */ +$perms = isset($permissions) && is_array($permissions) ? $permissions : []; +/** @var list $selPerms */ +$selPerms = isset($selectedPerms) && is_array($selectedPerms) ? array_map('intval', $selectedPerms) : []; +/** @var list $selSources */ +$selSources = isset($selectedSources) && is_array($selectedSources) ? $selectedSources : []; +/** @var list $srcList */ +$srcList = isset($sources) && is_array($sources) ? $sources : []; + +$val = static fn (string $k): string => htmlspecialchars((string) ($vals[$k] ?? ''), ENT_QUOTES, 'UTF-8'); +$err = static fn (string $k): string => isset($errs[$k]) && is_string($errs[$k]) ? htmlspecialchars($errs[$k], ENT_QUOTES, 'UTF-8') : ''; +$selectedSource = (string) ($vals['order_source'] ?? ''); +$active = (bool) ($vals['is_active'] ?? true); +?> + + +
+ + +
+ + + +

+ + +

Le code est immuable apres creation.

+ +
+ +
+ + +

+
+ +
+ + +
+ +
+ + +

+
+ +
+ + +

+
+ + +
+ +
+ + +
+ Permissions +

+
+ + + + +
+
+ +
+ Sources de tableau de bord visibles + + + +
+ +
+ Re-autorisation (PIN equipier) +
+ + +
+
+ + +
+

+
+ +
+ + Annuler +
+
diff --git a/src/app/Views/admin/roles/index.php b/src/app/Views/admin/roles/index.php new file mode 100644 index 0000000..cdc65e3 --- /dev/null +++ b/src/app/Views/admin/roles/index.php @@ -0,0 +1,67 @@ +> $roles + */ + +/** @var array> $rows */ +$rows = isset($roles) && is_array($roles) ? $roles : []; +$esc = static fn (mixed $v): string => htmlspecialchars((string) $v, ENT_QUOTES, 'UTF-8'); +?> + + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
CodeLibelleRoute par defautSourceStatut
Aucun role.
+ + Actif + + Inactif + + + Modifier +
+
+
diff --git a/src/public/admin/index.php b/src/public/admin/index.php index c01cec8..796dc22 100644 --- a/src/public/admin/index.php +++ b/src/public/admin/index.php @@ -23,6 +23,7 @@ use App\Controllers\PasswordResetController; use App\Controllers\ProductController; use App\Controllers\ProfileController; use App\Controllers\StatsController; +use App\Controllers\RoleController; use App\Controllers\UserController; use App\Core\Autoloader; use App\Core\Config; @@ -90,6 +91,14 @@ try { $router->add('GET', '/admin/users/{id}/erase', [UserController::class, 'confirmErase']); $router->add('POST', '/admin/users/{id}/erase', [UserController::class, 'erase']); + // RBAC (mlt 10.4, role.manage) : matrice roles x permissions + roles custom. + // Toute mutation = PIN equipier + audit (details = diff de permissions, RG-6). + $router->add('GET', '/admin/roles', [RoleController::class, 'index']); + $router->add('GET', '/admin/roles/new', [RoleController::class, 'create']); + $router->add('POST', '/admin/roles', [RoleController::class, 'store']); + $router->add('GET', '/admin/roles/{id}/edit', [RoleController::class, 'edit']); + $router->add('POST', '/admin/roles/{id}', [RoleController::class, 'update']); + // CRUD Categories (permission category.manage). Pas de suppression dure : toggle is_active. $router->add('GET', '/admin/categories', [CategoryController::class, 'index']); $router->add('GET', '/admin/categories/new', [CategoryController::class, 'create']); diff --git a/tests/Integration/RoleRepositoryDbTest.php b/tests/Integration/RoleRepositoryDbTest.php new file mode 100644 index 0000000..485a753 --- /dev/null +++ b/tests/Integration/RoleRepositoryDbTest.php @@ -0,0 +1,138 @@ +db = new Database(new Config()); + try { + $this->db->fetch('SELECT 1'); + } catch (Throwable $exception) { + self::markTestSkipped('Base injoignable: ' . $exception->getMessage()); + } + $this->code = 'it-role-' . bin2hex(random_bytes(4)); + $this->permA = (int) ($this->db->fetch("SELECT id FROM permission WHERE code = 'stats.read'")['id'] ?? 0); + $this->permB = (int) ($this->db->fetch("SELECT id FROM permission WHERE code = 'user.read'")['id'] ?? 0); + } + + protected function tearDown(): void + { + if ($this->code !== '') { + $this->db->execute('DELETE FROM role WHERE code = :c', ['c' => $this->code]); // CASCADE perms + sources + } + } + + private function makeRole(RoleRepository $repo): int + { + return $repo->createRole([ + 'code' => $this->code, + 'label' => 'IT Role', + 'description' => 'jetable', + 'default_route' => '/admin/dashboard', + 'order_source' => null, + ]); + } + + public function testCreateRoleAndCodeUnique(): void + { + $repo = new RoleRepository($this->db); + $id = $this->makeRole($repo); + self::assertGreaterThan(0, $id); + + $found = $repo->findRole($id); + self::assertNotNull($found); + self::assertSame($this->code, (string) $found['code']); + self::assertTrue($repo->codeExists($this->code)); + self::assertFalse($repo->codeExists($this->code, $id)); // s'exclut lui-meme + + $violated = false; + try { + $repo->createRole(['code' => $this->code, 'label' => 'Dup', 'description' => null, 'default_route' => null, 'order_source' => null]); + } catch (PDOException $exception) { + $violated = (string) $exception->getCode() === '23000'; + } + self::assertTrue($violated, 'uk_role_code doit rejeter un doublon.'); + } + + public function testSetPermissionsReplacesAndExposesCodes(): void + { + $repo = new RoleRepository($this->db); + $id = $this->makeRole($repo); + + $repo->setPermissions($id, [$this->permA, $this->permB]); + $ids = $repo->permissionIdsFor($id); + sort($ids); + $expected = [$this->permA, $this->permB]; + sort($expected); + self::assertSame($expected, $ids); + + $codes = $repo->permissionCodesFor($id); + self::assertContains('stats.read', $codes); + self::assertContains('user.read', $codes); + + // Delete-and-reinsert : la nouvelle selection REMPLACE l'ancienne. + $repo->setPermissions($id, [$this->permA]); + self::assertSame([$this->permA], $repo->permissionIdsFor($id)); + + // 23 permissions au catalogue (fige au seed). + self::assertCount(23, $repo->allPermissions()); + } + + public function testSetVisibleSourcesReplaces(): void + { + $repo = new RoleRepository($this->db); + $id = $this->makeRole($repo); + + $repo->setVisibleSources($id, ['counter', 'drive']); + $sources = $repo->visibleSources($id); + sort($sources); + self::assertSame(['counter', 'drive'], $sources); + + $repo->setVisibleSources($id, ['kiosk']); + self::assertSame(['kiosk'], $repo->visibleSources($id)); + } + + public function testUpdateRoleKeepsCodeImmutable(): void + { + $repo = new RoleRepository($this->db); + $id = $this->makeRole($repo); + + $repo->updateRole($id, [ + 'label' => 'Relabelled', + 'description' => 'maj', + 'default_route' => '/admin/stats', + 'order_source' => 'counter', + 'is_active' => 1, + ]); + $updated = $repo->findRole($id); + self::assertNotNull($updated); + self::assertSame('Relabelled', (string) $updated['label']); + self::assertSame('/admin/stats', (string) $updated['default_route']); + self::assertSame('counter', (string) $updated['order_source']); + self::assertSame($this->code, (string) $updated['code']); // code inchange (immuable) + } +} diff --git a/tests/Support/FakeDatabase.php b/tests/Support/FakeDatabase.php index b81d40a..7c9fcf5 100644 --- a/tests/Support/FakeDatabase.php +++ b/tests/Support/FakeDatabase.php @@ -237,6 +237,44 @@ final class FakeDatabase implements DatabaseInterface */ public array $rolesRows = []; + /** + * Lignes renvoyees par RoleRepository::allRoles(). + * + * @var list> + */ + public array $rolesAllRows = []; + + /** + * Ligne renvoyee par RoleRepository::findRole() ; null = absent. + * + * @var array|null + */ + public ?array $roleManageRow = null; + + /** Resultat de RoleRepository::codeExists(). */ + public bool $roleCodeTaken = false; + + /** + * Catalogue renvoye par RoleRepository::allPermissions(). + * + * @var list> + */ + public array $permissionsRows = []; + + /** + * Lignes {permission_id} renvoyees par RoleRepository::permissionIdsFor(). + * + * @var list> + */ + public array $rolePermIds = []; + + /** + * Lignes {source} renvoyees par RoleRepository::visibleSources(). + * + * @var list> + */ + public array $roleSources = []; + /** * Allowlist optionnelle de codes de permission accordes (RG-T03). Si non nul, * can() repond par appartenance du :code lie a cette liste (permet de tester la @@ -319,6 +357,15 @@ final class FakeDatabase implements DatabaseInterface return $this->roleActiveExists ? ['id' => 1] : null; } + // RBAC (RoleRepository) : findRole (7 colonnes) + codeExists (unicite). + if (str_contains($sql, 'order_source, is_active FROM role WHERE id = :id')) { + return $this->roleManageRow; + } + + if (str_contains($sql, 'FROM role WHERE code = :code AND id <> :id')) { + return $this->roleCodeTaken ? ['id' => 1] : null; + } + if (str_contains($sql, 'LAST_INSERT_ID')) { return ['id' => $this->lastInsertId]; } @@ -472,6 +519,26 @@ final class FakeDatabase implements DatabaseInterface return $this->movementsRows; } + // --- RBAC (RoleRepository) --- + if (str_contains($sql, 'FROM role ORDER BY id')) { + return $this->rolesAllRows; + } + + if (str_contains($sql, 'FROM permission ORDER BY id')) { + return $this->permissionsRows; + } + + if (str_contains($sql, 'permission_id FROM role_permission WHERE role_id')) { + return $this->rolePermIds; + } + + if (str_contains($sql, 'FROM role_visible_source WHERE role_id')) { + return $this->roleSources; + } + + // Sert Authorizer::permissionsFor ET RoleRepository::permissionCodesFor + // (meme requete 'SELECT p.code FROM role_permission rp JOIN permission p') : + // les deux renvoient $permissionCodes (le diff RBAC reutilise ce bouton). if (str_contains($sql, 'SELECT p.code FROM role_permission')) { if (!$this->roleActive) { return []; diff --git a/tests/Unit/Admin/RoleControllerTest.php b/tests/Unit/Admin/RoleControllerTest.php new file mode 100644 index 0000000..57606ca --- /dev/null +++ b/tests/Unit/Admin/RoleControllerTest.php @@ -0,0 +1,298 @@ +testSession; + } + + protected function db(): DatabaseInterface + { + return $this->fakeDb; + } +} + +final class RoleControllerTest extends TestCase +{ + /** @var list */ + private array $touchedKeys = []; + private SessionManager $session; + private string $csrf = ''; + + protected function setUp(): void + { + $this->setEnv('SESSION_LIFETIME_IDLE', '14400'); + $this->setEnv('SESSION_LIFETIME_ABSOLUTE', '36000'); + $this->setEnv('STAFF_PIN_MIN_LENGTH', '4'); + $this->setEnv('STAFF_PIN_MAX_LENGTH', '12'); + $this->setEnv('ARGON2_MEMORY_COST', '1024'); + $this->setEnv('ARGON2_TIME_COST', '1'); + $this->setEnv('ARGON2_THREADS', '1'); + + $this->session = new SessionManager(new Config(), true); + $now = time(); + $this->session->set('user_id', 1); + $this->session->set('role_id', 1); + $this->session->set('logged_in_at', $now - 100); + $this->session->set('last_activity', $now - 50); + $this->csrf = Csrf::token($this->session); + } + + protected function tearDown(): void + { + foreach ($this->touchedKeys as $key) { + putenv($key); + } + $this->touchedKeys = []; + } + + private function setEnv(string $key, string $value): void + { + $this->touchedKeys[] = $key; + putenv($key . '=' . $value); + } + + private function permittedDb(): FakeDatabase + { + $db = new FakeDatabase(); + $db->guardUserRow = ['is_active' => 1]; + $db->userDisplayRow = ['first_name' => 'Cor', 'last_name' => 'J', 'role_label' => 'Administrateur']; + $db->canResult = true; + $db->permissionCodes = ['role.manage']; + // Catalogue minimal : id 1 = role.manage (le vecteur de lockout). + $db->permissionsRows = [ + ['id' => 1, 'code' => 'role.manage', 'label' => 'Manage RBAC'], + ['id' => 2, 'code' => 'stats.read', 'label' => 'Stats'], + ['id' => 3, 'code' => 'user.read', 'label' => 'Users'], + ]; + + return $db; + } + + private function actingPin(FakeDatabase $db): void + { + $db->actingUserRow = ['id' => 9, 'role_id' => 4, 'pin_hash' => (new PasswordHasher(new Config()))->hash('4729')]; + } + + /** + * @param array $overrides + * @return array + */ + private function createForm(array $overrides = []): array + { + return array_merge([ + '_csrf' => $this->csrf, + 'code' => 'kitchen_kds', + 'label' => 'Kitchen KDS', + 'default_route' => '/kitchen/display', + 'order_source' => '', + 'perm_1' => '1', // role.manage coche + 'source_counter' => '1', + 'pin_email' => 'sam@wakdo.local', + 'pin' => '4729', + ], $overrides); + } + + private function get(string $path): Request + { + return new Request('GET', $path, [], [], '', '203.0.113.5'); + } + + /** + * @param array $form + */ + private function post(array $form, string $path): Request + { + return new Request('POST', $path, [], ['content-type' => 'application/x-www-form-urlencoded'], http_build_query($form), '203.0.113.5'); + } + + private function controller(Request $request, FakeDatabase $db): TestRoleController + { + return new TestRoleController($request, new Config(), new Database(new Config()), $this->session, $db); + } + + /** + * @return array{sql: string, params: array}|null + */ + private function findWrite(FakeDatabase $db, string $needle): ?array + { + foreach ($db->writes as $write) { + if (str_contains($write['sql'], $needle)) { + return $write; + } + } + + return null; + } + + public function testIndexRequiresRoleManage(): void + { + $db = $this->permittedDb(); + $db->canResult = false; + + self::assertSame(403, $this->controller($this->get('/admin/roles'), $db)->index()->status()); + } + + public function testIndexListsRoles(): void + { + $db = $this->permittedDb(); + $db->rolesAllRows = [['id' => 2, 'code' => 'manager', 'label' => 'Manager', 'default_route' => '/admin/stats', 'order_source' => null, 'is_active' => 1]]; + + $response = $this->controller($this->get('/admin/roles'), $db)->index(); + self::assertSame(200, $response->status()); + self::assertStringContainsString('manager', $response->body()); + } + + public function testStoreCreatesCustomRoleWithPinAndAudit(): void + { + $db = $this->permittedDb(); + $this->actingPin($db); + $db->lastInsertId = 10; + + $response = $this->controller($this->post($this->createForm(), '/admin/roles'), $db)->store(); + + self::assertSame(302, $response->status()); + self::assertSame(['begin', 'commit'], $db->transactionEvents); + self::assertTrue($db->wrote('INSERT INTO role ')); + self::assertTrue($db->wrote('INSERT INTO role_permission')); + $audit = $this->findWrite($db, 'INSERT INTO audit_log'); + self::assertNotNull($audit); + self::assertSame('role.manage', $audit['params']['code'] ?? null); + self::assertSame(9, $audit['params']['uid'] ?? null); // acteur = PIN + } + + public function testStoreRejectsDuplicateCodeWith409(): void + { + $db = $this->permittedDb(); + $this->actingPin($db); + $db->roleCodeTaken = true; + + $response = $this->controller($this->post($this->createForm(), '/admin/roles'), $db)->store(); + + self::assertSame(409, $response->status()); + self::assertFalse($db->wrote('INSERT INTO role ')); + } + + public function testStoreRejectsInvalidCode(): void + { + $db = $this->permittedDb(); + $response = $this->controller($this->post($this->createForm(['code' => 'Bad Code!']), '/admin/roles'), $db)->store(); + + self::assertSame(422, $response->status()); + self::assertFalse($db->wrote('INSERT INTO role ')); + } + + public function testStoreRejectsInvalidCsrf(): void + { + $db = $this->permittedDb(); + $response = $this->controller($this->post($this->createForm(['_csrf' => 'bad']), '/admin/roles'), $db)->store(); + + self::assertSame(403, $response->status()); + } + + public function testStoreWithoutValidPinLogsFailed(): void + { + $db = $this->permittedDb(); + $db->actingUserRow = null; + + $response = $this->controller($this->post($this->createForm(['pin' => '0000']), '/admin/roles'), $db)->store(); + + self::assertSame(422, $response->status()); + self::assertFalse($db->wrote('INSERT INTO role ')); + self::assertSame(['pin.failed'], $db->auditActions()); + } + + public function testUpdateNotFound(): void + { + $db = $this->permittedDb(); + $db->roleManageRow = null; + + self::assertSame(404, $this->controller($this->post($this->createForm(), '/admin/roles/9'), $db)->update(['id' => '9'])->status()); + } + + public function testUpdateAppliesWithPinAndAuditDiff(): void + { + $db = $this->permittedDb(); + $db->roleManageRow = ['id' => 5, 'code' => 'counter', 'label' => 'Counter', 'description' => null, 'default_route' => '/counter/orders', 'order_source' => 'counter', 'is_active' => 1]; + $db->permissionCodes = ['stats.read']; // permissions actuelles (diff RG-6 reutilise ce bouton) + $this->actingPin($db); + + // perm_1 (role.manage) coche, is_active coche. + $form = ['_csrf' => $this->csrf, 'label' => 'Counter', 'default_route' => '/counter/orders', 'order_source' => 'counter', 'perm_1' => '1', 'is_active' => '1', 'pin_email' => 'sam@wakdo.local', 'pin' => '4729']; + $response = $this->controller($this->post($form, '/admin/roles/5'), $db)->update(['id' => '5']); + + self::assertSame(302, $response->status()); + self::assertTrue($db->wrote('UPDATE role SET')); + self::assertTrue($db->wrote('INSERT INTO role_permission')); + self::assertSame('role.manage', ($this->findWrite($db, 'INSERT INTO audit_log')['params']['code'] ?? null)); + } + + public function testUpdateBlocksRemovingRoleManageFromAdmin(): void + { + $db = $this->permittedDb(); + $db->roleManageRow = ['id' => 1, 'code' => 'admin', 'label' => 'Administrator', 'description' => null, 'default_route' => '/admin/dashboard', 'order_source' => null, 'is_active' => 1]; + + // role.manage (perm_1) NON coche -> retirerait role.manage a l'admin. + $form = ['_csrf' => $this->csrf, 'label' => 'Administrator', 'perm_2' => '1', 'is_active' => '1', 'pin_email' => 'sam@wakdo.local', 'pin' => '4729']; + $response = $this->controller($this->post($form, '/admin/roles/1'), $db)->update(['id' => '1']); + + self::assertSame(422, $response->status()); + self::assertStringContainsString('administrateur', $response->body()); + self::assertFalse($db->wrote('UPDATE role SET')); + } + + public function testUpdateBlocksDeactivatingAdminRole(): void + { + $db = $this->permittedDb(); + $db->roleManageRow = ['id' => 1, 'code' => 'admin', 'label' => 'Administrator', 'description' => null, 'default_route' => '/admin/dashboard', 'order_source' => null, 'is_active' => 1]; + + // role.manage conserve mais is_active absent -> desactivation de l'admin -> bloque. + $form = ['_csrf' => $this->csrf, 'label' => 'Administrator', 'perm_1' => '1', 'pin_email' => 'sam@wakdo.local', 'pin' => '4729']; + $response = $this->controller($this->post($form, '/admin/roles/1'), $db)->update(['id' => '1']); + + self::assertSame(422, $response->status()); + self::assertFalse($db->wrote('UPDATE role SET')); + } + + public function testUpdateLockedActorReturns422WithoutEffect(): void + { + $db = $this->permittedDb(); + $db->roleManageRow = ['id' => 5, 'code' => 'counter', 'label' => 'Counter', 'description' => null, 'default_route' => null, 'order_source' => 'counter', 'is_active' => 1]; + $this->actingPin($db); + $db->pinThrottleLockoutUntil = date('Y-m-d H:i:s', time() + 300); + + $form = ['_csrf' => $this->csrf, 'label' => 'Counter', 'perm_1' => '1', 'is_active' => '1', 'pin_email' => 'sam@wakdo.local', 'pin' => '4729']; + $response = $this->controller($this->post($form, '/admin/roles/5'), $db)->update(['id' => '5']); + + self::assertSame(422, $response->status()); + self::assertSame([], $db->auditActions()); // pas de pin.failed sous verrou + self::assertFalse($db->wrote('UPDATE role SET')); + } +}