Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
## Résumé
Implémentation complète du système RBAC (Role-Based Access Control) pour Coltura.
### Backend
- Entités Permission et Role avec API Platform CRUD
- PermissionVoter : vérification des permissions effectives (rôles + directes), admin bypass
- Endpoints `PATCH /users/{id}/rbac` pour assigner rôles, permissions directes et isAdmin
- AdminHeadcountGuard : protection contre la suppression du dernier admin
- Commande `app:sync-permissions` pour synchroniser les permissions déclarées par les modules
- Filtrage sidebar par permission RBAC (`permission` key optionnelle dans sidebar.php)
- 115 tests PHPUnit (fonctionnels + unitaires)
### Frontend
- Composable `usePermissions()` avec `can()`, `canAny()`, `canAll()` et admin bypass
- Page `/admin/roles` : DataTable, création/édition via drawer, suppression avec confirmation
- Page `/admin/users` : DataTable, drawer RBAC avec rôles, permissions directes, résumé effectif
- PermissionGroup : checkboxes groupées par module avec "tout sélectionner"
- EffectivePermissions : résumé lecture seule avec badges source ("via Rôle X" / "Direct")
- Warning auto-édition, toggle isAdmin
- Tests Vitest pour usePermissions
### Permissions déclarées
- `core.users.view` — Voir les utilisateurs
- `core.users.manage` — Gérer les utilisateurs
- `core.roles.view` — Voir les rôles RBAC
- `core.roles.manage` — Gérer les rôles et permissions
- `GET /api/permissions` accessible à tout utilisateur authentifié (catalogue read-only)
## Tickets Lesstime
- ERP-23 (#343) — Entités Permission et Role
- ERP-24 (#344) — API CRUD Roles & Permissions
- ERP-25 (#345) — Voter Symfony + usePermissions
- ERP-26 (#346) — Interface Admin : Gestion des Rôles
- ERP-27 (#347) — Interface Admin : Permissions Utilisateur
## Test plan
- [ ] `make db-reset` puis vérifier les fixtures (admin/alice/bob, rôles système)
- [ ] Login admin : sidebar affiche Gestion des rôles + Utilisateurs
- [ ] Login alice : sidebar masque ces onglets (pas de permission)
- [ ] Page /admin/roles : CRUD rôles, permissions groupées, protection rôles système
- [ ] Page /admin/users : assignation rôles + permissions directes, résumé effectif
- [ ] Warning auto-édition quand admin modifie ses propres droits
- [ ] `make test` : 115 tests PHPUnit passent
- [ ] `cd frontend && npm run test` : tests Vitest passent
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Matthieu <mtholot19@gmail.com>
Co-authored-by: tristan <tristan@yuno.malio.fr>
Reviewed-on: #7
Co-authored-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr>
Co-committed-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr>
224 lines
9.9 KiB
PHP
224 lines
9.9 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace DoctrineMigrations;
|
|
|
|
use Doctrine\DBAL\Schema\Schema;
|
|
use Doctrine\Migrations\AbstractMigration;
|
|
|
|
/**
|
|
* Migration RBAC - Task 5.
|
|
*
|
|
* Ce que fait cette migration :
|
|
* - Cree les tables du modele RBAC relationnel : permission, role,
|
|
* role_permission, user_role et user_permission.
|
|
* - Ajoute la colonne "user".is_admin (booleen, defaut false) qui sert de
|
|
* bypass complet pour les super-administrateurs.
|
|
* - Migre les donnees existantes depuis la colonne JSON "user".roles vers
|
|
* le nouveau modele relationnel : les users ayant ROLE_ADMIN passent a
|
|
* is_admin = TRUE et sont rattaches au role systeme 'admin', les autres
|
|
* sont rattaches au role systeme 'user'.
|
|
* - Supprime enfin la colonne "user".roles devenue obsolete.
|
|
*
|
|
* Ordonnancement critique :
|
|
* Les INSERT de data-migration DOIVENT s'executer AVANT le DROP COLUMN
|
|
* "user".roles, sinon l'information d'appartenance a ROLE_ADMIN est perdue.
|
|
* L'ordre respecte donc strictement : creation des tables -> ADD is_admin
|
|
* -> seed des roles systeme -> migration des donnees -> DROP roles.
|
|
*
|
|
* Dependance avec Task 6 (fixtures) :
|
|
* Les fixtures applicatives reposent sur l'existence des roles systeme
|
|
* 'admin' et 'user' seedes ici par SQL brut. Cette migration est donc
|
|
* auto-suffisante et n'a pas besoin que les fixtures soient executees.
|
|
*/
|
|
final class Version20260414150034 extends AbstractMigration
|
|
{
|
|
public function getDescription(): string
|
|
{
|
|
return 'RBAC : tables permission/role + jointures + is_admin + migration des donnees depuis user.roles';
|
|
}
|
|
|
|
public function up(Schema $schema): void
|
|
{
|
|
// 1) Creation des tables RBAC (permission, role, jointures).
|
|
$this->addSql(<<<'SQL'
|
|
CREATE TABLE permission (
|
|
id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
|
|
code VARCHAR(255) NOT NULL,
|
|
label VARCHAR(255) NOT NULL,
|
|
module VARCHAR(100) NOT NULL,
|
|
orphan BOOLEAN DEFAULT false NOT NULL,
|
|
PRIMARY KEY (id)
|
|
)
|
|
SQL);
|
|
$this->addSql('CREATE INDEX idx_permission_module ON permission (module)');
|
|
$this->addSql('CREATE INDEX idx_permission_orphan ON permission (orphan)');
|
|
$this->addSql('CREATE UNIQUE INDEX uniq_permission_code ON permission (code)');
|
|
$this->addSql(<<<'SQL'
|
|
CREATE TABLE "role" (
|
|
id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
|
|
code VARCHAR(100) NOT NULL,
|
|
label VARCHAR(255) NOT NULL,
|
|
description TEXT DEFAULT NULL,
|
|
is_system BOOLEAN DEFAULT false NOT NULL,
|
|
PRIMARY KEY (id)
|
|
)
|
|
SQL);
|
|
$this->addSql('CREATE INDEX idx_role_is_system ON "role" (is_system)');
|
|
$this->addSql('CREATE UNIQUE INDEX uniq_role_code ON "role" (code)');
|
|
$this->addSql(<<<'SQL'
|
|
CREATE TABLE role_permission (
|
|
role_id INT NOT NULL,
|
|
permission_id INT NOT NULL,
|
|
PRIMARY KEY (role_id, permission_id)
|
|
)
|
|
SQL);
|
|
$this->addSql('CREATE INDEX IDX_6F7DF886D60322AC ON role_permission (role_id)');
|
|
$this->addSql('CREATE INDEX IDX_6F7DF886FED90CCA ON role_permission (permission_id)');
|
|
$this->addSql(<<<'SQL'
|
|
CREATE TABLE user_role (
|
|
user_id INT NOT NULL,
|
|
role_id INT NOT NULL,
|
|
PRIMARY KEY (user_id, role_id)
|
|
)
|
|
SQL);
|
|
$this->addSql('CREATE INDEX IDX_2DE8C6A3A76ED395 ON user_role (user_id)');
|
|
$this->addSql('CREATE INDEX IDX_2DE8C6A3D60322AC ON user_role (role_id)');
|
|
$this->addSql(<<<'SQL'
|
|
CREATE TABLE user_permission (
|
|
user_id INT NOT NULL,
|
|
permission_id INT NOT NULL,
|
|
PRIMARY KEY (user_id, permission_id)
|
|
)
|
|
SQL);
|
|
$this->addSql('CREATE INDEX IDX_472E5446A76ED395 ON user_permission (user_id)');
|
|
$this->addSql('CREATE INDEX IDX_472E5446FED90CCA ON user_permission (permission_id)');
|
|
$this->addSql(<<<'SQL'
|
|
ALTER TABLE
|
|
role_permission
|
|
ADD
|
|
CONSTRAINT FK_6F7DF886D60322AC FOREIGN KEY (role_id) REFERENCES "role" (id) ON DELETE CASCADE
|
|
SQL);
|
|
$this->addSql(<<<'SQL'
|
|
ALTER TABLE
|
|
role_permission
|
|
ADD
|
|
CONSTRAINT FK_6F7DF886FED90CCA FOREIGN KEY (permission_id) REFERENCES permission (id) ON DELETE CASCADE
|
|
SQL);
|
|
$this->addSql(<<<'SQL'
|
|
ALTER TABLE
|
|
user_role
|
|
ADD
|
|
CONSTRAINT FK_2DE8C6A3A76ED395 FOREIGN KEY (user_id) REFERENCES "user" (id) ON DELETE CASCADE
|
|
SQL);
|
|
$this->addSql(<<<'SQL'
|
|
ALTER TABLE
|
|
user_role
|
|
ADD
|
|
CONSTRAINT FK_2DE8C6A3D60322AC FOREIGN KEY (role_id) REFERENCES "role" (id) ON DELETE CASCADE
|
|
SQL);
|
|
$this->addSql(<<<'SQL'
|
|
ALTER TABLE
|
|
user_permission
|
|
ADD
|
|
CONSTRAINT FK_472E5446A76ED395 FOREIGN KEY (user_id) REFERENCES "user" (id) ON DELETE CASCADE
|
|
SQL);
|
|
$this->addSql(<<<'SQL'
|
|
ALTER TABLE
|
|
user_permission
|
|
ADD
|
|
CONSTRAINT FK_472E5446FED90CCA FOREIGN KEY (permission_id) REFERENCES permission (id) ON DELETE CASCADE
|
|
SQL);
|
|
|
|
// 2) Ajout de la colonne is_admin sur "user" (avant la data-migration
|
|
// qui en a besoin pour marquer les super-admins).
|
|
$this->addSql('ALTER TABLE "user" ADD is_admin BOOLEAN DEFAULT false NOT NULL');
|
|
|
|
// 3) Seed des roles systeme avant toute migration de donnees utilisateurs.
|
|
// Les codes sont centralises dans App\Module\Core\Domain\Security\SystemRoles
|
|
// mais dupliques ici volontairement pour que la migration reste auto-suffisante.
|
|
$this->addSql("INSERT INTO \"role\" (code, label, description, is_system) VALUES ('admin', 'Administrateur', 'Role administrateur - bypass complet via is_admin', TRUE)");
|
|
$this->addSql("INSERT INTO \"role\" (code, label, description, is_system) VALUES ('user', 'Utilisateur', 'Role de base sans permission specifique', TRUE)");
|
|
|
|
// 4) Bascule is_admin a TRUE pour tout user dont le JSON roles contient ROLE_ADMIN.
|
|
$this->addSql(<<<'SQL'
|
|
UPDATE "user" u
|
|
SET is_admin = TRUE
|
|
WHERE EXISTS (
|
|
SELECT 1
|
|
FROM jsonb_array_elements_text(COALESCE(u.roles::jsonb, '[]'::jsonb)) AS role_code
|
|
WHERE role_code = 'ROLE_ADMIN'
|
|
)
|
|
SQL);
|
|
|
|
// 5) Rattachement des admins au role systeme 'admin'.
|
|
$this->addSql(<<<'SQL'
|
|
INSERT INTO user_role (user_id, role_id)
|
|
SELECT u.id, r.id
|
|
FROM "user" u
|
|
CROSS JOIN "role" r
|
|
WHERE r.code = 'admin'
|
|
AND EXISTS (
|
|
SELECT 1
|
|
FROM jsonb_array_elements_text(COALESCE(u.roles::jsonb, '[]'::jsonb)) AS role_code
|
|
WHERE role_code = 'ROLE_ADMIN'
|
|
)
|
|
ON CONFLICT DO NOTHING
|
|
SQL);
|
|
|
|
// 6) Rattachement des autres users (y compris roles vide ou NULL) au role 'user'.
|
|
$this->addSql(<<<'SQL'
|
|
INSERT INTO user_role (user_id, role_id)
|
|
SELECT u.id, r.id
|
|
FROM "user" u
|
|
CROSS JOIN "role" r
|
|
WHERE r.code = 'user'
|
|
AND NOT EXISTS (
|
|
SELECT 1
|
|
FROM jsonb_array_elements_text(COALESCE(u.roles::jsonb, '[]'::jsonb)) AS role_code
|
|
WHERE role_code = 'ROLE_ADMIN'
|
|
)
|
|
ON CONFLICT DO NOTHING
|
|
SQL);
|
|
|
|
// 7) Drop de la colonne "user".roles (DOIT etre la derniere instruction,
|
|
// apres la migration des donnees qui la lit).
|
|
$this->addSql('ALTER TABLE "user" DROP roles');
|
|
}
|
|
|
|
public function down(Schema $schema): void
|
|
{
|
|
// 1) Recreation de la colonne roles (avec un defaut pour permettre le
|
|
// backfill). Le NOT NULL est conserve comme dans le schema d'origine.
|
|
$this->addSql('ALTER TABLE "user" ADD roles JSON NOT NULL DEFAULT \'[]\'');
|
|
|
|
// 2) Rehydratation du JSON roles depuis is_admin avant de perdre la colonne.
|
|
$this->addSql(<<<'SQL'
|
|
UPDATE "user"
|
|
SET roles = CASE
|
|
WHEN is_admin THEN '["ROLE_ADMIN"]'::json
|
|
ELSE '["ROLE_USER"]'::json
|
|
END
|
|
SQL);
|
|
|
|
// 3) Drop des FK puis des tables de jointure (enfants d'abord).
|
|
$this->addSql('ALTER TABLE role_permission DROP CONSTRAINT FK_6F7DF886D60322AC');
|
|
$this->addSql('ALTER TABLE role_permission DROP CONSTRAINT FK_6F7DF886FED90CCA');
|
|
$this->addSql('ALTER TABLE user_role DROP CONSTRAINT FK_2DE8C6A3A76ED395');
|
|
$this->addSql('ALTER TABLE user_role DROP CONSTRAINT FK_2DE8C6A3D60322AC');
|
|
$this->addSql('ALTER TABLE user_permission DROP CONSTRAINT FK_472E5446A76ED395');
|
|
$this->addSql('ALTER TABLE user_permission DROP CONSTRAINT FK_472E5446FED90CCA');
|
|
$this->addSql('DROP TABLE user_permission');
|
|
$this->addSql('DROP TABLE user_role');
|
|
$this->addSql('DROP TABLE role_permission');
|
|
|
|
// 4) Drop des tables parentes permission et role.
|
|
$this->addSql('DROP TABLE permission');
|
|
$this->addSql('DROP TABLE "role"');
|
|
|
|
// 5) Drop de is_admin, la colonne roles ayant ete rehydratee en amont.
|
|
$this->addSql('ALTER TABLE "user" DROP is_admin');
|
|
}
|
|
}
|