feat(admin): RBAC - matrice roles/permissions + roles custom (PIN+audit diff) (P3)
All checks were successful
CI / secret-scan (pull_request) Successful in 14s
CI / js-tests (pull_request) Successful in 28s
CI / php-lint (pull_request) Successful in 26s
CI / static-tests (pull_request) Successful in 1m1s
CI / secret-scan (push) Successful in 12s
CI / php-lint (push) Successful in 25s
CI / static-tests (push) Successful in 50s
CI / js-tests (push) Successful in 22s
CI / auto-merge (pull_request) Successful in 4s
CI / auto-merge (push) Has been skipped
All checks were successful
CI / secret-scan (pull_request) Successful in 14s
CI / js-tests (pull_request) Successful in 28s
CI / php-lint (pull_request) Successful in 26s
CI / static-tests (pull_request) Successful in 1m1s
CI / secret-scan (push) Successful in 12s
CI / php-lint (push) Successful in 25s
CI / static-tests (push) Successful in 50s
CI / js-tests (push) Successful in 22s
CI / auto-merge (pull_request) Successful in 4s
CI / auto-merge (push) Has been skipped
Lot R du cycle P3 (Users/RBAC/Stats), dernier lot. Gestion RBAC (mlt 10.4
MANAGE_RBAC, permission role.manage) : matrice roles x permissions + roles
personnalises (RG-4). Action a fort impact (escalade de privileges) -> PIN
equipier + audit_log dans la meme transaction (RG-T13/14), throttle PIN (RG-T22).
- RoleRepository (App\Auth) : roles (CRUD, code immuable), matrice (permissionIds/
CodesFor, setPermissions tx + variante raw replacePermissions pour enrobage
controleur), sources visibles (role_visible_source, tx + raw). Catalogue de
permissions fige (lecture seule).
- RoleController (role.manage) : index ; create/store (role custom : code+label+
default_route+order_source) ; edit/update (champs role + matrice + sources, en
UNE transaction). audit role.manage avec details=DIFF des codes de permission
(ajoutes/retires, RG-6), calcule avant la reecriture delete-and-reinsert.
- Matrice soumise en champs SCALAIRES (perm_<id>, source_<enum>) : Request::formBody
ne garde que les scalaires, donc pas de name[] ni de JS.
- Garde-fous anti-lockout : le role admin conserve role.manage ET reste actif ;
code immuable apres creation ; order_source borne a l'ENUM ; code dupli -> 409.
- Vues admin/roles/{index,form}, 5 routes, nav Roles (gated role.manage).
Tests : unit 263, integration 301 / 916 assertions (WAKDO_DB_TESTS=1, dont
RoleControllerTest 12 + RoleRepositoryDbTest 4), PHPStan L6 propre.
This commit is contained in:
parent
e430f54d85
commit
de48ddf7cd
9 changed files with 1405 additions and 4 deletions
214
src/app/Auth/RoleRepository.php
Normal file
214
src/app/Auth/RoleRepository.php
Normal file
|
|
@ -0,0 +1,214 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Auth;
|
||||
|
||||
use App\Core\DatabaseInterface;
|
||||
|
||||
/**
|
||||
* Acces aux donnees RBAC en ECRITURE (mlt 10.4 MANAGE_RBAC) : roles, matrice
|
||||
* role_permission, sources visibles role_visible_source. La lecture des
|
||||
* permissions pour l'autorisation reste dans Authorizer (rechargee a chaque
|
||||
* verification). Le catalogue `permission` est fige au seed (lecture seule ici).
|
||||
*
|
||||
* Invariants : le `code` d'un role est UNIQUE et IMMUABLE apres creation (cle
|
||||
* referencee par la logique applicative). La matrice et les sources sont reposees
|
||||
* en delete-and-reinsert dans UNE transaction (RG-T08 / mlt 10.4 RG-1).
|
||||
*/
|
||||
final class RoleRepository
|
||||
{
|
||||
public function __construct(private readonly DatabaseInterface $db)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
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<string, mixed>|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<int, array<string, mixed>>
|
||||
*/
|
||||
public function allPermissions(): array
|
||||
{
|
||||
return $this->db->fetchAll('SELECT id, code, label FROM permission ORDER BY id');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<int>
|
||||
*/
|
||||
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<string>
|
||||
*/
|
||||
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<int> $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<int> $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<string>
|
||||
*/
|
||||
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<string> $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<string> $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],
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
459
src/app/Controllers/RoleController.php
Normal file
459
src/app/Controllers/RoleController.php
Normal file
|
|
@ -0,0 +1,459 @@
|
|||
<?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']);
|
||||
}
|
||||
}
|
||||
|
|
@ -125,18 +125,22 @@ $navClass = static function (string $code, string $current): string {
|
|||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ($can('user.read')): ?>
|
||||
<?php if ($can('user.read') || $can('role.manage')): ?>
|
||||
<div class="sidebar-section">
|
||||
<div class="sidebar-section-label">Administration</div>
|
||||
<a href="/admin/users" class="<?= $navClass('users', $active) ?>">Utilisateurs</a>
|
||||
<?php if ($can('user.read')): ?>
|
||||
<a href="/admin/users" class="<?= $navClass('users', $active) ?>">Utilisateurs</a>
|
||||
<?php endif; ?>
|
||||
<?php if ($can('role.manage')): ?>
|
||||
<a href="/admin/roles" class="<?= $navClass('roles', $active) ?>">Roles</a>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php /*
|
||||
Items de nav volontairement absents tant que leur page n'existe pas
|
||||
(un lien vers une route non enregistree renvoie un 404). A reactiver
|
||||
avec leur route respective : Commandes (order.read), Roles (role.manage)
|
||||
-- lot RBAC / P4.
|
||||
avec leur route respective : Commandes (order.read) -- domaine P4.
|
||||
*/ ?>
|
||||
</nav>
|
||||
|
||||
|
|
|
|||
145
src/app/Views/admin/roles/form.php
Normal file
145
src/app/Views/admin/roles/form.php
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Formulaire role (creation/edition RBAC), injecte dans admin/layout.php. La
|
||||
* matrice de permissions et les sources visibles sont des cases SCALAIRES
|
||||
* (`perm_<id>`, `source_<enum>`) : 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<int, array<string, mixed>> $permissions catalogue {id, code, label}
|
||||
* @var list<string> $sources enum visibles
|
||||
* @var list<int> $selectedPerms
|
||||
* @var list<string> $selectedSources
|
||||
* @var array<string, mixed> $values
|
||||
* @var array<string, string> $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<string, mixed> $vals */
|
||||
$vals = isset($values) && is_array($values) ? $values : [];
|
||||
/** @var array<string, string> $errs */
|
||||
$errs = isset($errors) && is_array($errors) ? $errors : [];
|
||||
/** @var array<int, array<string, mixed>> $perms */
|
||||
$perms = isset($permissions) && is_array($permissions) ? $permissions : [];
|
||||
/** @var list<int> $selPerms */
|
||||
$selPerms = isset($selectedPerms) && is_array($selectedPerms) ? array_map('intval', $selectedPerms) : [];
|
||||
/** @var list<string> $selSources */
|
||||
$selSources = isset($selectedSources) && is_array($selectedSources) ? $selectedSources : [];
|
||||
/** @var list<string> $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);
|
||||
?>
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1 class="page-title"><?= $id !== 0 ? 'Modifier le role' : 'Nouveau role' ?></h1>
|
||||
<?php if ($isAdmin): ?><p class="page-subtitle">Role administrateur : il doit conserver <code>role.manage</code> et rester actif.</p><?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form method="post" action="<?= htmlspecialchars($action, ENT_QUOTES, 'UTF-8') ?>" class="form-card">
|
||||
<input type="hidden" name="_csrf" value="<?= $csrf ?>">
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="code">Code</label>
|
||||
<?php if ($id === 0): ?>
|
||||
<input class="form-input" type="text" id="code" name="code" maxlength="40" value="<?= $val('code') ?>" required>
|
||||
<?php if ($err('code') !== ''): ?><p class="form-error"><?= $err('code') ?></p><?php endif; ?>
|
||||
<?php else: ?>
|
||||
<input class="form-input" type="text" id="code" value="<?= $val('code') ?>" disabled>
|
||||
<p><small class="muted">Le code est immuable apres creation.</small></p>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="label">Libelle</label>
|
||||
<input class="form-input" type="text" id="label" name="label" maxlength="80" value="<?= $val('label') ?>" required>
|
||||
<?php if ($err('label') !== ''): ?><p class="form-error"><?= $err('label') ?></p><?php endif; ?>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="description">Description</label>
|
||||
<input class="form-input" type="text" id="description" name="description" value="<?= $val('description') ?>">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="default_route">Route par defaut (landing)</label>
|
||||
<input class="form-input" type="text" id="default_route" name="default_route" maxlength="120" value="<?= $val('default_route') ?>">
|
||||
<?php if ($err('default_route') !== ''): ?><p class="form-error"><?= $err('default_route') ?></p><?php endif; ?>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="order_source">Source de commande auto-taggee</label>
|
||||
<select class="form-input" id="order_source" name="order_source">
|
||||
<option value="">-- aucune (admin/manager) --</option>
|
||||
<?php foreach ($srcList as $src): ?>
|
||||
<option value="<?= htmlspecialchars($src, ENT_QUOTES, 'UTF-8') ?>"<?= $src === $selectedSource ? ' selected' : '' ?>><?= htmlspecialchars($src, ENT_QUOTES, 'UTF-8') ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
<?php if ($err('order_source') !== ''): ?><p class="form-error"><?= $err('order_source') ?></p><?php endif; ?>
|
||||
</div>
|
||||
|
||||
<?php if ($id !== 0): ?>
|
||||
<div class="form-group">
|
||||
<label class="form-label"><input type="checkbox" name="is_active" value="1"<?= $active ? ' checked' : '' ?>> Role actif</label>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<fieldset class="form-group">
|
||||
<legend>Permissions</legend>
|
||||
<?php if ($err('permissions') !== ''): ?><p class="form-error"><?= $err('permissions') ?></p><?php endif; ?>
|
||||
<div style="max-height:320px; overflow-y:auto;">
|
||||
<?php foreach ($perms as $p): ?>
|
||||
<?php
|
||||
$pid = (int) ($p['id'] ?? 0);
|
||||
$checked = in_array($pid, $selPerms, true);
|
||||
?>
|
||||
<label style="display:block; padding:2px 0;">
|
||||
<input type="checkbox" name="perm_<?= $pid ?>" value="1"<?= $checked ? ' checked' : '' ?>>
|
||||
<code><?= htmlspecialchars((string) ($p['code'] ?? ''), ENT_QUOTES, 'UTF-8') ?></code>
|
||||
<span class="muted">- <?= htmlspecialchars((string) ($p['label'] ?? ''), ENT_QUOTES, 'UTF-8') ?></span>
|
||||
</label>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<fieldset class="form-group">
|
||||
<legend>Sources de tableau de bord visibles</legend>
|
||||
<?php foreach ($srcList as $src): ?>
|
||||
<label style="display:inline-block; margin-right:1rem;">
|
||||
<input type="checkbox" name="source_<?= htmlspecialchars($src, ENT_QUOTES, 'UTF-8') ?>" value="1"<?= in_array($src, $selSources, true) ? ' checked' : '' ?>>
|
||||
<?= htmlspecialchars($src, ENT_QUOTES, 'UTF-8') ?>
|
||||
</label>
|
||||
<?php endforeach; ?>
|
||||
</fieldset>
|
||||
|
||||
<fieldset class="form-group">
|
||||
<legend>Re-autorisation (PIN equipier)</legend>
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="pin_email">Email equipier</label>
|
||||
<input class="form-input" type="email" id="pin_email" name="pin_email" autocomplete="off">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="pin">PIN</label>
|
||||
<input class="form-input" type="password" id="pin" name="pin" inputmode="numeric" autocomplete="off">
|
||||
</div>
|
||||
<?php if ($err('pin') !== ''): ?><p class="form-error"><?= $err('pin') ?></p><?php endif; ?>
|
||||
</fieldset>
|
||||
|
||||
<div class="form-actions">
|
||||
<button class="btn btn-primary" type="submit">Enregistrer</button>
|
||||
<a class="btn btn-secondary" href="/admin/roles">Annuler</a>
|
||||
</div>
|
||||
</form>
|
||||
67
src/app/Views/admin/roles/index.php
Normal file
67
src/app/Views/admin/roles/index.php
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Liste des roles (RBAC, role.manage), injectee dans admin/layout.php. Texte echappe.
|
||||
*
|
||||
* @var array<int, array<string, mixed>> $roles
|
||||
*/
|
||||
|
||||
/** @var array<int, array<string, mixed>> $rows */
|
||||
$rows = isset($roles) && is_array($roles) ? $roles : [];
|
||||
$esc = static fn (mixed $v): string => htmlspecialchars((string) $v, ENT_QUOTES, 'UTF-8');
|
||||
?>
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1 class="page-title">Roles et permissions</h1>
|
||||
<p class="page-subtitle">Matrice RBAC. Modifier un role est une action sensible (PIN + audit).</p>
|
||||
</div>
|
||||
<div class="page-actions">
|
||||
<a class="btn btn-primary" href="/admin/roles/new">Nouveau role</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="table-container">
|
||||
<div class="table-wrapper">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Code</th>
|
||||
<th>Libelle</th>
|
||||
<th>Route par defaut</th>
|
||||
<th>Source</th>
|
||||
<th>Statut</th>
|
||||
<th style="width:120px;"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php if ($rows === []): ?>
|
||||
<tr><td colspan="6" class="muted">Aucun role.</td></tr>
|
||||
<?php endif; ?>
|
||||
<?php foreach ($rows as $row): ?>
|
||||
<?php
|
||||
$id = (int) ($row['id'] ?? 0);
|
||||
$active = (int) ($row['is_active'] ?? 0) === 1;
|
||||
?>
|
||||
<tr>
|
||||
<td class="fw-600"><?= $esc($row['code'] ?? '') ?></td>
|
||||
<td><?= $esc($row['label'] ?? '') ?></td>
|
||||
<td class="muted"><?= $esc($row['default_route'] ?? '') ?></td>
|
||||
<td class="muted"><?= $esc($row['order_source'] ?? '-') ?></td>
|
||||
<td>
|
||||
<?php if ($active): ?>
|
||||
<span class="pill pill-success">Actif</span>
|
||||
<?php else: ?>
|
||||
<span class="pill pill-neutral">Inactif</span>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td>
|
||||
<a class="btn btn-secondary" href="/admin/roles/<?= $id ?>/edit">Modifier</a>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -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']);
|
||||
|
|
|
|||
138
tests/Integration/RoleRepositoryDbTest.php
Normal file
138
tests/Integration/RoleRepositoryDbTest.php
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Integration;
|
||||
|
||||
use PDOException;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Throwable;
|
||||
use App\Auth\RoleRepository;
|
||||
use App\Core\Config;
|
||||
use App\Core\Database;
|
||||
|
||||
/**
|
||||
* RoleRepository (RBAC, mlt 10.4) contre une vraie MariaDB (schema migre + seede).
|
||||
* Auto-skip si WAKDO_DB_TESTS != 1. Role jetable (code it-role-*) ; CASCADE retire
|
||||
* role_permission + role_visible_source a la suppression du role (teardown simple).
|
||||
*/
|
||||
final class RoleRepositoryDbTest extends TestCase
|
||||
{
|
||||
private Database $db;
|
||||
private string $code = '';
|
||||
private int $permA = 0;
|
||||
private int $permB = 0;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
if (getenv('WAKDO_DB_TESTS') !== '1') {
|
||||
self::markTestSkipped('Tests DB desactives (definir WAKDO_DB_TESTS=1 + DB_*).');
|
||||
}
|
||||
$this->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)
|
||||
}
|
||||
}
|
||||
|
|
@ -237,6 +237,44 @@ final class FakeDatabase implements DatabaseInterface
|
|||
*/
|
||||
public array $rolesRows = [];
|
||||
|
||||
/**
|
||||
* Lignes renvoyees par RoleRepository::allRoles().
|
||||
*
|
||||
* @var list<array<string, mixed>>
|
||||
*/
|
||||
public array $rolesAllRows = [];
|
||||
|
||||
/**
|
||||
* Ligne renvoyee par RoleRepository::findRole() ; null = absent.
|
||||
*
|
||||
* @var array<string, mixed>|null
|
||||
*/
|
||||
public ?array $roleManageRow = null;
|
||||
|
||||
/** Resultat de RoleRepository::codeExists(). */
|
||||
public bool $roleCodeTaken = false;
|
||||
|
||||
/**
|
||||
* Catalogue renvoye par RoleRepository::allPermissions().
|
||||
*
|
||||
* @var list<array<string, mixed>>
|
||||
*/
|
||||
public array $permissionsRows = [];
|
||||
|
||||
/**
|
||||
* Lignes {permission_id} renvoyees par RoleRepository::permissionIdsFor().
|
||||
*
|
||||
* @var list<array<string, mixed>>
|
||||
*/
|
||||
public array $rolePermIds = [];
|
||||
|
||||
/**
|
||||
* Lignes {source} renvoyees par RoleRepository::visibleSources().
|
||||
*
|
||||
* @var list<array<string, mixed>>
|
||||
*/
|
||||
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 [];
|
||||
|
|
|
|||
298
tests/Unit/Admin/RoleControllerTest.php
Normal file
298
tests/Unit/Admin/RoleControllerTest.php
Normal file
|
|
@ -0,0 +1,298 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Admin;
|
||||
|
||||
use PDOException;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use App\Auth\Csrf;
|
||||
use App\Auth\PasswordHasher;
|
||||
use App\Auth\SessionManager;
|
||||
use App\Controllers\RoleController;
|
||||
use App\Core\Config;
|
||||
use App\Core\Database;
|
||||
use App\Core\DatabaseInterface;
|
||||
use App\Core\Request;
|
||||
use App\Tests\Support\FakeDatabase;
|
||||
|
||||
final class TestRoleController extends RoleController
|
||||
{
|
||||
public function __construct(
|
||||
Request $request,
|
||||
Config $config,
|
||||
Database $database,
|
||||
private readonly SessionManager $testSession,
|
||||
private readonly FakeDatabase $fakeDb,
|
||||
) {
|
||||
parent::__construct($request, $config, $database);
|
||||
}
|
||||
|
||||
protected function sessionManager(): SessionManager
|
||||
{
|
||||
return $this->testSession;
|
||||
}
|
||||
|
||||
protected function db(): DatabaseInterface
|
||||
{
|
||||
return $this->fakeDb;
|
||||
}
|
||||
}
|
||||
|
||||
final class RoleControllerTest extends TestCase
|
||||
{
|
||||
/** @var list<string> */
|
||||
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<string, string> $overrides
|
||||
* @return array<string, string>
|
||||
*/
|
||||
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<string, string> $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<string|int, mixed>}|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'));
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue