From 91b624109622fab25ac0b54f08489a01d93b078d Mon Sep 17 00:00:00 2001 From: Imugiii Date: Mon, 15 Jun 2026 18:42:09 +0000 Subject: [PATCH] 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. --- src/app/Auth/Authorizer.php | 75 ++++++++++ .../Controllers/AuthenticatedController.php | 36 +++++ src/app/Controllers/MeController.php | 50 +++++++ src/public/admin/index.php | 4 + tests/Integration/AuthorizerDbTest.php | 96 +++++++++++++ tests/Support/FakeDatabase.php | 48 +++++++ tests/Unit/Auth/AuthorizerTest.php | 100 +++++++++++++ tests/Unit/Auth/MeControllerTest.php | 136 ++++++++++++++++++ 8 files changed, 545 insertions(+) create mode 100644 src/app/Auth/Authorizer.php create mode 100644 src/app/Controllers/AuthenticatedController.php create mode 100644 src/app/Controllers/MeController.php create mode 100644 tests/Integration/AuthorizerDbTest.php create mode 100644 tests/Unit/Auth/AuthorizerTest.php create mode 100644 tests/Unit/Auth/MeControllerTest.php diff --git a/src/app/Auth/Authorizer.php b/src/app/Auth/Authorizer.php new file mode 100644 index 0000000..50369e2 --- /dev/null +++ b/src/app/Auth/Authorizer.php @@ -0,0 +1,75 @@ +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 + */ + 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; + } +} diff --git a/src/app/Controllers/AuthenticatedController.php b/src/app/Controllers/AuthenticatedController.php new file mode 100644 index 0000000..413cff0 --- /dev/null +++ b/src/app/Controllers/AuthenticatedController.php @@ -0,0 +1,36 @@ +config); + } + + protected function sessionGuard(): SessionGuard + { + return new SessionGuard($this->sessionManager(), $this->database, $this->config); + } + + protected function authorizer(): Authorizer + { + return new Authorizer($this->database); + } +} diff --git a/src/app/Controllers/MeController.php b/src/app/Controllers/MeController.php new file mode 100644 index 0000000..f7901ba --- /dev/null +++ b/src/app/Controllers/MeController.php @@ -0,0 +1,50 @@ + $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), + ], + ]); + } +} diff --git a/src/public/admin/index.php b/src/public/admin/index.php index dd44d61..bf6307d 100644 --- a/src/public/admin/index.php +++ b/src/public/admin/index.php @@ -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) { diff --git a/tests/Integration/AuthorizerDbTest.php b/tests/Integration/AuthorizerDbTest.php new file mode 100644 index 0000000..78821e2 --- /dev/null +++ b/tests/Integration/AuthorizerDbTest.php @@ -0,0 +1,96 @@ +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)); + } +} diff --git a/tests/Support/FakeDatabase.php b/tests/Support/FakeDatabase.php index 3009c38..3c27e54 100644 --- a/tests/Support/FakeDatabase.php +++ b/tests/Support/FakeDatabase.php @@ -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}> + */ + public array $reads = []; + + /** + * Codes de permission renvoyes par Authorizer::permissionsFor(). + * + * @var list + */ + public array $permissionCodes = []; + + /** + * Ligne role renvoyee pour la lecture du code de role (/api/me) ; null = absent. + * + * @var array|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 []; } diff --git a/tests/Unit/Auth/AuthorizerTest.php b/tests/Unit/Auth/AuthorizerTest.php new file mode 100644 index 0000000..24663c7 --- /dev/null +++ b/tests/Unit/Auth/AuthorizerTest.php @@ -0,0 +1,100 @@ +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} + */ + private function lastRead(): array + { + $reads = $this->db->reads; + self::assertNotEmpty($reads, 'aucune lecture enregistree'); + + return $reads[array_key_last($reads)]; + } +} diff --git a/tests/Unit/Auth/MeControllerTest.php b/tests/Unit/Auth/MeControllerTest.php new file mode 100644 index 0000000..760fdb3 --- /dev/null +++ b/tests/Unit/Auth/MeControllerTest.php @@ -0,0 +1,136 @@ +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 */ + 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()); + } +}