Compare commits

..

2 commits

Author SHA1 Message Date
Imugiii
5b714e9a3a docs(api): ajoute /api/me au listing des endpoints
Some checks failed
CI / auto-merge (push) Has been skipped
CI / auto-merge (pull_request) Failing after 5s
CI / secret-scan (push) Successful in 8s
CI / php-lint (push) Successful in 17s
CI / static-tests (push) Successful in 31s
CI / secret-scan (pull_request) Successful in 7s
CI / php-lint (pull_request) Successful in 17s
CI / static-tests (pull_request) Successful in 30s
Reflete l'endpoint GET /api/me (session-gated, RG-6/RG-T02/RG-T03) en service dans la section 5.1.
2026-06-15 18:42:09 +00:00
Imugiii
91b6241096 feat(rbac): autorisation par permission + garde de session cablee (GET /api/me)
Authorizer verifie une PERMISSION via role_permission (RG-T03), rechargee depuis la base a
chaque appel (10.4 RG-3) ; un role desactive ne confere rien. AuthenticatedController (App\Controllers)
cable SessionGuard (RG-6 + RG-T02) et Authorizer sans inverser la dependance du Core. MeController
expose GET /api/me (identite + permissions ; 401 si session absente/expiree/inactive) : premier
consommateur reel du SessionGuard. Tests unitaires + integration DB (auto-skippee sans base) couvrant
le predicat is_active et la liaison par code de permission.
2026-06-15 18:42:09 +00:00
9 changed files with 552 additions and 0 deletions

View file

@ -99,6 +99,13 @@ Autres regles :
| POST | `/forgot_password` | public + CSRF | HTML (neutre) | envoi du lien (mlt 12.3) |
| GET | `/reset_password` | public (token en query) | HTML | formulaire nouveau mot de passe |
| POST | `/reset_password` | public + CSRF | 302 / HTML | confirmation (mlt 12.3) |
| GET | `/api/me` | session | JSON | identite + permissions du compte courant (RG-6/RG-T02/RG-T03) |
`/api/me` est le premier consommateur reel de `SessionGuard` (RG-6 idle/absolu + RG-T02
is_active) et d'`Authorizer` (RG-T03, permissions rechargees depuis la base). Reponse :
`{ "data": { "user_id", "role_id", "role_code", "permissions": [...] } }` ; `401 AUTH_REQUIRED`
si la session est absente, expiree ou le compte desactive. Les autorisations par operation
(et le PIN des actions sensibles, RG-T13) se cablent quand les operations existent (P3).
### 5.2 API kiosk - lecture catalogue + commande (prevu P4, public)

View file

@ -0,0 +1,75 @@
<?php
declare(strict_types=1);
namespace App\Auth;
use App\Core\DatabaseInterface;
/**
* Verification d'autorisation par PERMISSION (RG-T03), pas par nom de role : on
* teste qu'un role detient un code de permission via role_permission. Les droits
* sont recharges depuis la base a CHAQUE appel (mlt.md 10.4 RG-3) ; la session ne
* porte que role_id, donc un changement RBAC prend effet a la verification suivante
* sans invalider les sessions.
*
* Un role inactif (role.is_active = 0) ne confere aucune permission.
*/
final class Authorizer
{
public function __construct(private readonly DatabaseInterface $db)
{
}
public function can(int $roleId, string $permissionCode): bool
{
$row = $this->db->fetch(
'SELECT 1 AS granted FROM role_permission rp '
. 'JOIN permission p ON p.id = rp.permission_id '
. 'JOIN role r ON r.id = rp.role_id '
. 'WHERE rp.role_id = :role AND p.code = :code AND r.is_active = 1 LIMIT 1',
['role' => $roleId, 'code' => $permissionCode],
);
return $row !== null;
}
/**
* Liste des codes de permission du role (pour /api/me et l'affichage UI).
*
* @return list<string>
*/
public function permissionsFor(int $roleId): array
{
$rows = $this->db->fetchAll(
'SELECT p.code FROM role_permission rp '
. 'JOIN permission p ON p.id = rp.permission_id '
. 'JOIN role r ON r.id = rp.role_id '
. 'WHERE rp.role_id = :role AND r.is_active = 1 ORDER BY p.code',
['role' => $roleId],
);
$codes = [];
foreach ($rows as $row) {
$code = $row['code'] ?? null;
if (is_string($code)) {
$codes[] = $code;
}
}
return $codes;
}
/**
* Code du role (ex. 'admin', 'counter'). Lecture de metadonnee de role,
* regroupee ici avec l'acces a role_permission pour un seul seam de donnees.
*/
public function roleCode(int $roleId): ?string
{
// Filtre is_active comme can()/permissionsFor() : un role desactive ne
// doit exposer ni droits ni libelle exploitable (coherence de l'invariant).
$row = $this->db->fetch('SELECT r.code FROM role r WHERE r.id = :id AND r.is_active = 1', ['id' => $roleId]);
return is_string($row['code'] ?? null) ? $row['code'] : null;
}
}

View file

@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace App\Controllers;
use App\Auth\Authorizer;
use App\Auth\SessionGuard;
use App\Auth\SessionManager;
use App\Core\Controller;
/**
* Base des controleurs proteges : fournit la session, la garde de session
* (RG-6 + RG-T02) et le service d'autorisation (RG-T03), cables depuis la Config
* et la Database injectees. Vit dans App\Controllers (au-dessus de Core et Auth)
* pour ne pas inverser la dependance du Core vers Auth.
*
* Les hooks sont proteges et surchargeables en test pour injecter des doubles.
*/
abstract class AuthenticatedController extends Controller
{
protected function sessionManager(): SessionManager
{
return new SessionManager($this->config);
}
protected function sessionGuard(): SessionGuard
{
return new SessionGuard($this->sessionManager(), $this->database, $this->config);
}
protected function authorizer(): Authorizer
{
return new Authorizer($this->database);
}
}

View file

@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace App\Controllers;
use App\Core\Response;
/**
* GET /api/me : identite et permissions de l'utilisateur de la session courante.
*
* Premier consommateur reel de SessionGuard (RG-6 + RG-T02) et d'Authorizer
* (RG-T03) : la garde rejette toute session absente/expiree/inactive (401), sinon
* on renvoie le role et la liste des permissions rechargee depuis la base.
*
* NB P2 : les pages admin sont encore des fichiers statiques servis par Apache
* (hors PHP), donc non couvertes par cette garde. Leur protection arrive en P3
* quand elles deviennent des vues rendues serveur passant par ce socle.
*
* Non `final` a dessein : les tests sous-classent pour injecter des doubles
* (session en mode test + FakeDatabase) via les hooks proteges.
*/
class MeController extends AuthenticatedController
{
/**
* @param array<string, string> $params
*/
public function show(array $params = []): Response
{
$guard = $this->sessionGuard()->check();
if (!$guard->authenticated || $guard->userId === null || $guard->roleId === null) {
return $this->json(
['data' => null, 'error' => ['code' => 'AUTH_REQUIRED', 'message' => 'Authentification requise']],
401,
);
}
$authorizer = $this->authorizer();
return $this->json([
'data' => [
'user_id' => $guard->userId,
'role_id' => $guard->roleId,
'role_code' => $authorizer->roleCode($guard->roleId),
'permissions' => $authorizer->permissionsFor($guard->roleId),
],
]);
}
}

View file

@ -14,6 +14,7 @@ use App\Auth\SessionManager;
use App\Controllers\AuthController;
use App\Controllers\HealthController;
use App\Controllers\HomeController;
use App\Controllers\MeController;
use App\Controllers\PasswordResetController;
use App\Core\Autoloader;
use App\Core\Config;
@ -57,6 +58,9 @@ try {
$router->add('GET', '/reset_password', [PasswordResetController::class, 'showConfirm']);
$router->add('POST', '/reset_password', [PasswordResetController::class, 'submitConfirm']);
// RBAC : identite + permissions de la session courante (gardee par SessionGuard).
$router->add('GET', '/api/me', [MeController::class, 'show']);
$response = $router->dispatch(Request::fromGlobals());
$response->send();
} catch (Throwable $exception) {

View file

@ -0,0 +1,96 @@
<?php
declare(strict_types=1);
namespace App\Tests\Integration;
use PHPUnit\Framework\TestCase;
use Throwable;
use App\Auth\Authorizer;
use App\Core\Config;
use App\Core\Database;
/**
* Test d'integration de l'autorisation contre une vraie MariaDB (schema migre +
* seede). Garde anti-regression du predicat de securite `AND r.is_active = 1` et
* du filtrage par code de permission, que le double unitaire ne peut pas prouver.
*
* Auto-skip : ne s'execute que si WAKDO_DB_TESTS=1 ET base joignable.
* Isolation : cree un role jetable (code unique) + une ligne role_permission,
* supprimes en tearDown.
*/
final class AuthorizerDbTest extends TestCase
{
private Database $db;
private int $roleId = 0;
private string $roleCode = '';
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());
}
// Role jetable cree DESACTIVE, portant la permission product.read.
$this->roleCode = 'it-rbac-' . bin2hex(random_bytes(4));
$this->db->execute(
'INSERT INTO role (code, label, is_active) VALUES (:code, :label, 0)',
['code' => $this->roleCode, 'label' => 'IT RBAC'],
);
$this->roleId = (int) ($this->db->fetch('SELECT LAST_INSERT_ID() AS id')['id'] ?? 0);
$this->db->execute(
'INSERT INTO role_permission (role_id, permission_id) '
. 'SELECT :rid, id FROM permission WHERE code = :pc',
['rid' => $this->roleId, 'pc' => 'product.read'],
);
}
protected function tearDown(): void
{
if ($this->roleId === 0) {
return;
}
$this->db->execute('DELETE FROM role_permission WHERE role_id = :id', ['id' => $this->roleId]);
$this->db->execute('DELETE FROM role WHERE id = :id', ['id' => $this->roleId]);
$this->roleId = 0;
}
public function testInactiveRoleGrantsNothingThenActiveGrants(): void
{
$authz = new Authorizer($this->db);
// is_active = 0 : aucun droit ni libelle, malgre la ligne role_permission.
self::assertFalse($authz->can($this->roleId, 'product.read'));
self::assertSame([], $authz->permissionsFor($this->roleId));
self::assertNull($authz->roleCode($this->roleId));
// On active le role : le meme grant devient effectif -> c'est bien le
// predicat is_active qui gate (et non l'absence de role_permission).
$this->db->execute('UPDATE role SET is_active = 1 WHERE id = :id', ['id' => $this->roleId]);
self::assertTrue($authz->can($this->roleId, 'product.read'));
self::assertSame(['product.read'], $authz->permissionsFor($this->roleId));
self::assertSame($this->roleCode, $authz->roleCode($this->roleId));
}
public function testSeededAdminRoleFiltersByPermissionCode(): void
{
$authz = new Authorizer($this->db);
$adminId = (int) ($this->db->fetch("SELECT id FROM role WHERE code = 'admin'")['id'] ?? 0);
self::assertGreaterThan(0, $adminId, 'role admin seede attendu');
// RG-T03 : filtrage par code (admin detient product.create, pas une permission inventee).
self::assertTrue($authz->can($adminId, 'product.create'));
self::assertFalse($authz->can($adminId, 'totally.fake.permission'));
self::assertContains('role.manage', $authz->permissionsFor($adminId));
}
}

View file

@ -55,6 +55,34 @@ final class FakeDatabase implements DatabaseInterface
*/
public ?array $guardUserRow = null;
/** Resultat de Authorizer::can() (true = permission accordee). */
public bool $canResult = false;
/** Etat role.is_active modelise pour can()/permissionsFor() ; false => rien accorde. */
public bool $roleActive = true;
/**
* Trace des lectures (fetch/fetchAll) pour asserter les parametres lies
* (ex. liaison par code de permission, RG-T03), pendant que $writes trace les ecritures.
*
* @var list<array{sql: string, params: array<string|int, mixed>}>
*/
public array $reads = [];
/**
* Codes de permission renvoyes par Authorizer::permissionsFor().
*
* @var list<string>
*/
public array $permissionCodes = [];
/**
* Ligne role renvoyee pour la lecture du code de role (/api/me) ; null = absent.
*
* @var array<string, mixed>|null
*/
public ?array $roleRow = null;
/** Si non nul, execute() leve cette exception (simulation panne DB -> fail-closed). */
public ?RuntimeException $failOnExecute = null;
@ -66,6 +94,8 @@ final class FakeDatabase implements DatabaseInterface
public function fetch(string $sql, array $params = []): ?array
{
$this->reads[] = ['sql' => $sql, 'params' => $params];
if (str_contains($sql, 'FROM user u JOIN role')) {
return $this->userRow;
}
@ -82,6 +112,14 @@ final class FakeDatabase implements DatabaseInterface
return $this->guardUserRow;
}
if (str_contains($sql, 'SELECT 1 AS granted FROM role_permission')) {
return ($this->canResult && $this->roleActive) ? ['granted' => 1] : null;
}
if (str_contains($sql, 'FROM role r WHERE r.id')) {
return $this->roleRow;
}
if (str_contains($sql, 'SELECT lockout_until FROM login_throttle')) {
return ['lockout_until' => $this->ipLockoutUntil];
}
@ -95,6 +133,16 @@ final class FakeDatabase implements DatabaseInterface
public function fetchAll(string $sql, array $params = []): array
{
$this->reads[] = ['sql' => $sql, 'params' => $params];
if (str_contains($sql, 'SELECT p.code FROM role_permission')) {
if (!$this->roleActive) {
return [];
}
return array_map(static fn (string $code): array => ['code' => $code], $this->permissionCodes);
}
return [];
}

View file

@ -0,0 +1,100 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Auth;
use PHPUnit\Framework\TestCase;
use App\Auth\Authorizer;
use App\Tests\Support\FakeDatabase;
/**
* Verification par permission (RG-T03) : can() / permissionsFor() / roleCode()
* testes avec un FakeDatabase, sans base reelle.
*/
final class AuthorizerTest extends TestCase
{
private FakeDatabase $db;
protected function setUp(): void
{
$this->db = new FakeDatabase();
}
private function authorizer(): Authorizer
{
return new Authorizer($this->db);
}
public function testCanReturnsTrueWhenPermissionGranted(): void
{
$this->db->canResult = true;
self::assertTrue($this->authorizer()->can(1, 'product.create'));
// RG-T03 : la verification lie le CODE de permission + le role_id (jamais
// un nom de role). On asserte les parametres reellement lies a la requete.
self::assertSame(['role' => 1, 'code' => 'product.create'], $this->lastRead()['params']);
}
public function testCanReturnsFalseWhenNotGranted(): void
{
$this->db->canResult = false;
self::assertFalse($this->authorizer()->can(3, 'order.cancel'));
}
public function testPermissionsForReturnsCodes(): void
{
$this->db->permissionCodes = ['order.read', 'product.read', 'stock.read'];
self::assertSame(
['order.read', 'product.read', 'stock.read'],
$this->authorizer()->permissionsFor(4),
);
self::assertSame(['role' => 4], $this->lastRead()['params']);
}
public function testPermissionsForReturnsEmptyWhenNone(): void
{
$this->db->permissionCodes = [];
self::assertSame([], $this->authorizer()->permissionsFor(9));
}
public function testRoleCodeReturnsCodeOrNull(): void
{
$this->db->roleRow = ['code' => 'admin'];
self::assertSame('admin', $this->authorizer()->roleCode(1));
$this->db->roleRow = null;
self::assertNull($this->authorizer()->roleCode(999));
}
public function testCanDeniesWhenRoleInactive(): void
{
// Le role detient la permission (canResult) mais il est desactive : refus.
$this->db->canResult = true;
$this->db->roleActive = false;
self::assertFalse($this->authorizer()->can(1, 'product.create'));
}
public function testPermissionsForEmptyWhenRoleInactive(): void
{
$this->db->permissionCodes = ['order.read', 'product.read'];
$this->db->roleActive = false;
self::assertSame([], $this->authorizer()->permissionsFor(4));
}
/**
* @return array{sql: string, params: array<string|int, mixed>}
*/
private function lastRead(): array
{
$reads = $this->db->reads;
self::assertNotEmpty($reads, 'aucune lecture enregistree');
return $reads[array_key_last($reads)];
}
}

View file

@ -0,0 +1,136 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Auth;
use PHPUnit\Framework\TestCase;
use App\Auth\Authorizer;
use App\Auth\SessionGuard;
use App\Auth\SessionManager;
use App\Controllers\MeController;
use App\Core\Config;
use App\Core\Database;
use App\Core\Request;
use App\Tests\Support\FakeDatabase;
/**
* Sous-classe de test : injecte une session en mode test et un FakeDatabase dans
* la garde et l'autorisation, sans base reelle.
*/
final class TestMeController extends MeController
{
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 sessionGuard(): SessionGuard
{
return new SessionGuard($this->testSession, $this->fakeDb, $this->config);
}
protected function authorizer(): Authorizer
{
return new Authorizer($this->fakeDb);
}
}
final class MeControllerTest extends TestCase
{
/** @var list<string> */
private array $touchedKeys = [];
protected function setUp(): void
{
$this->setEnv('SESSION_LIFETIME_IDLE', '14400');
$this->setEnv('SESSION_LIFETIME_ABSOLUTE', '36000');
}
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 controller(SessionManager $session, FakeDatabase $db): TestMeController
{
$request = new Request('GET', '/api/me', [], [], '', '203.0.113.5');
return new TestMeController($request, new Config(), new Database(new Config()), $session, $db);
}
public function testNoSessionReturns401(): void
{
$response = $this->controller(new SessionManager(new Config(), true), new FakeDatabase())->show();
self::assertSame(401, $response->status());
$body = json_decode($response->body(), true);
self::assertIsArray($body);
self::assertSame('AUTH_REQUIRED', $body['error']['code'] ?? null);
}
public function testAuthenticatedReturnsIdentityAndPermissions(): void
{
$session = new SessionManager(new Config(), true);
// Horodatages relatifs a l'instant reel : MeController appelle check() sans
// injecter de temps (now = time()).
$now = time();
$session->set('user_id', 7);
$session->set('role_id', 3);
$session->set('logged_in_at', $now - 100);
$session->set('last_activity', $now - 50);
$db = new FakeDatabase();
$db->guardUserRow = ['is_active' => 1];
$db->roleRow = ['code' => 'manager'];
$db->permissionCodes = ['product.read', 'stats.read'];
$response = $this->controller($session, $db)->show();
self::assertSame(200, $response->status());
$body = json_decode($response->body(), true);
self::assertIsArray($body);
self::assertSame(7, $body['data']['user_id'] ?? null);
self::assertSame(3, $body['data']['role_id'] ?? null);
self::assertSame('manager', $body['data']['role_code'] ?? null);
self::assertSame(['product.read', 'stats.read'], $body['data']['permissions'] ?? null);
}
public function testInactiveUserSessionReturns401(): void
{
$session = new SessionManager(new Config(), true);
$now = time();
$session->set('user_id', 7);
$session->set('role_id', 3);
$session->set('logged_in_at', $now - 100);
$session->set('last_activity', $now - 50);
$db = new FakeDatabase();
$db->guardUserRow = ['is_active' => 0];
$response = $this->controller($session, $db)->show();
self::assertSame(401, $response->status());
}
}