Files
Coltura/migrations/Version20260414150034.php
Matthieu eb0b49a7ef fix(core) : RBAC migration deplacee vers le namespace DoctrineMigrations racine
Bug decouvert a l'execution de 'make db-reset' sur base vide : Doctrine
Migrations 3.x avec plusieurs 'migrations_paths' execute les migrations
dans l'ordre (namespace, version) et non (version, namespace). Le
Version20260414150034 sous 'App\Module\Core\...' passait donc avant
Version20260407095546 sous 'DoctrineMigrations', provoquant un
"relation user does not exist".

Deplacement du fichier vers 'migrations/' (namespace DoctrineMigrations).
Le chemin modulaire reste configure pour les futurs modules, mais
la migration RBAC d'initialisation vit a la racine pour que
'make db-reset' fonctionne en one-shot.

Smoke test end-to-end valide :
- db-reset + fixtures : admin (is_admin=t, role admin), alice/bob
  (is_admin=f, role user)
- app:sync-permissions : 4 permissions Core ajoutees, idempotent au 2e run
- User::getRoles() : ['ROLE_USER', 'ROLE_ADMIN'] pour admin, ['ROLE_USER']
  pour alice/bob
- User::getEffectivePermissions() : union triee des permissions via roles

Ticket #343 - 7/7 : smoke test end-to-end OK.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 17:21:43 +02:00

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');
}
}