Compare commits

...

10 Commits

Author SHA1 Message Date
Matthieu
7ccc913862 docs : exception CLAUDE.md pour les migrations multi-namespace
Documente le bug Doctrine Migrations 3.x (tri par FQCN au lieu de
version timestamp avec plusieurs migrations_paths) et la regle
provisoire : migrations d'init au namespace racine, namespace
modulaire reserve aux migrations applicatives.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 17:25:26 +02:00
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
Matthieu
0a496f34e0 fix(core) : RBAC Task 6 polish - descriptions des roles systeme coherentes
ensureSystemRole() recopie desormais la description depuis la migration
RBAC pour que les chemins prod (migration) et dev (fixtures) produisent
un etat identique.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 17:15:23 +02:00
Matthieu
aafe08b6ad feat(core) : RBAC Task 6 - fixtures et CreateUserCommand branches sur les roles systeme
- AppFixtures : rattachement des users aux entites Role via
  RoleRepositoryInterface. Re-seed idempotent des roles systeme dans
  ensureSystemRole() pour compenser le purger Doctrine qui vide la table
  role avant load(), afin que "make db-reset && make fixtures" reste un
  workflow one-shot.
- CreateUserCommand : flag --admin attache au role systeme admin + is_admin,
  sinon au role user. Gestion d'erreur explicite si les roles systeme sont
  absents (FAILURE + message pointant vers la migration).
- CreateUserCommand devient final, descriptions traduites en francais.

Ticket #343 - 6/7 : fixtures et command alignes sur le RBAC relationnel.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 17:12:09 +02:00
Matthieu
d68aa0456a feat(core) : RBAC Task 5 - migration Doctrine RBAC + data-migration JSON roles
- Nouvelles tables permission, role, role_permission, user_role, user_permission
- Ajout user.is_admin (BOOLEAN, default false)
- Seed des roles systeme admin et user via SQL brut (autonome, pas besoin
  de fixtures pour cette etape)
- Migration des donnees : is_admin reflete ROLE_ADMIN du JSON roles, puis
  rattachement user_role selon admin/user
- Drop user.roles en dernier (apres la migration de donnees)
- down() recree la colonne roles et la rehydrate depuis is_admin

Ticket #343 - 5/7 : persistance + migration donnees safe.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 17:02:26 +02:00
Matthieu
3b1f18b0e0 feat(core) : RBAC Task 4 - CoreModule::permissions() + SyncPermissionsCommand
- CoreModule declare 4 permissions initiales (users.view/manage, roles.manage,
  permissions.view)
- Nouvelle commande app:sync-permissions :
  * scan des *Module::permissions() via config/modules.php
  * validation stricte : cles [code, label], prefixe module, non-vides
  * upsert transactionnel non-destructif
  * revival des permissions orphelines qui reapparaissent
  * marquage orphan pour les permissions disparues du code
  * un seul flush() final (evite le flush-par-save de la repo save())

Ticket #343 - 4/7 : scanner et synchroniseur de permissions RBAC.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 16:56:50 +02:00
Matthieu
7aa32b1972 feat(core) : RBAC Task 3 - mutation User (isAdmin + roles RBAC + permissions directes)
- Suppression de la colonne JSON roles (persiste jusqu'a la migration Task 5)
- Ajout is_admin bool (seul levier de bypass RBAC via getRoles())
- Ajout ManyToMany User-Role (EAGER, table user_role)
- Ajout ManyToMany User-Permission directes (EAGER, table user_permission)
- getEffectivePermissions() : union dedupliquee triee, utilisee par le
  futur PermissionVoter (#345)
- getRbacRoles() pour ne pas shadow getRoles() de UserInterface Symfony
- Tests unitaires couvrant derivation getRoles, union, deduplication, tri

Ticket #343 - 3/7 : migration du User vers le modele RBAC relationnel.
Fetch EAGER documente : evite le lazy-load au refresh JWT.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 16:48:49 +02:00
Matthieu
3b34d00872 feat(core) : RBAC Task 2 - repositories Permission et Role
- PermissionRepositoryInterface avec findByCode et findAllCodes (pour le sync
  command et le futur PermissionVoter)
- RoleRepositoryInterface avec findByCode
- Implementations Doctrine alignees sur DoctrineUserRepository
- Alias DI dans config/services.yaml
- Rebranchement de repositoryClass sur les entites Permission et Role

Ticket #343 - 2/7 : couche persistence RBAC.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 16:40:44 +02:00
Matthieu
0fc0b57e37 refactor(core) : RBAC Task 1 - polish apres revue qualite
- Permission : guards constructeur (code/label/module non vides, code avec point)
- Permission::revive() reutilise updateMetadata() pour eviter la duplication
- Suppression de SystemRolesTest (tautologique, ne capture aucun comportement)
- Role::permissions : commentaire explicite sur la raison du fetch EAGER
- Alignement des types de retour sur static (style User.php)
- Nouveau test Role::addPermission avec permissions distinctes

Ticket #343 - Task 1 polish (revue qualite).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 16:37:53 +02:00
Matthieu
f0ea9201f5 feat(core) : RBAC Task 1 - entites Permission et Role + domaine securite
- Entite Permission avec methodes markOrphan/revive/updateMetadata
- Entite Role avec addPermission/removePermission/ensureDeletable
- Constantes SystemRoles (codes admin/user partages)
- Exception SystemRoleDeletionException pour la garde de suppression
- Tests unitaires couvrant le comportement domaine (pas de BDD)

Ticket #343 - 1/7 : fondations RBAC (domaine pur, sans persistence).
Les entites ne portent pas encore repositoryClass (ajoute en Task 2).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 16:30:15 +02:00
20 changed files with 1507 additions and 25 deletions

View File

@@ -140,6 +140,7 @@ Le code du module Commercial n'est pas touche.
- `config/modules.php` = seule source de verite pour l'activation
- `config/sidebar.php` = seule source de verite pour l'organisation de la sidebar (chaque item reference son module owner via la cle `module`)
- Migrations par module dans `src/Module/{Module}/Infrastructure/Doctrine/Migrations/`
- **Exception connue** : avec plusieurs `migrations_paths` configures, Doctrine Migrations 3.x trie les migrations par FQCN alphabetique et non par version timestamp → ordre d'execution incorrect entre namespaces sur une base vide. Tant que ce n'est pas resolu (via un `MigrationsComparator` custom ou un upgrade), les migrations d'initialisation critiques (setup user, RBAC, etc.) vivent au namespace racine `DoctrineMigrations` dans `migrations/`. Le namespace modulaire reste configure pour les futures migrations applicatives (qui dependent d'un schema deja cree).
**Frontend :**
- Chaque module est un layer Nuxt auto-detecte (`modules/*/nuxt.config.ts` minimal)

View File

@@ -16,5 +16,11 @@ services:
App\:
resource: '../src/'
App\Module\Core\Domain\Repository\PermissionRepositoryInterface:
alias: App\Module\Core\Infrastructure\Doctrine\DoctrinePermissionRepository
App\Module\Core\Domain\Repository\RoleRepositoryInterface:
alias: App\Module\Core\Infrastructure\Doctrine\DoctrineRoleRepository
App\Module\Core\Domain\Repository\UserRepositoryInterface:
alias: App\Module\Core\Infrastructure\Doctrine\DoctrineUserRepository

View File

@@ -0,0 +1,223 @@
<?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');
}
}

View File

@@ -9,4 +9,31 @@ final class CoreModule
public const string ID = 'core';
public const string LABEL = 'Core';
public const bool REQUIRED = true;
/**
* Liste declarative des permissions RBAC exposees par le module Core.
*
* Consommee par la commande `app:sync-permissions` (SyncPermissionsCommand)
* qui se charge d'upserter ces entrees dans la table `permission`, de
* reactiver les codes precedemment marques orphelins et de marquer comme
* orphelins ceux qui ont disparu du code source.
*
* La cle `module` est auto-injectee par le sync command a partir de
* `self::ID`, il est donc inutile de la repeter dans chaque entree.
*
* Convention de nommage des codes : `module.resource[.sub].action` en
* snake_case, le prefixe module devant correspondre exactement a
* `self::ID` (verifie par la commande de synchronisation).
*
* @return array<int, array{code: string, label: string}>
*/
public static function permissions(): array
{
return [
['code' => 'core.users.view', 'label' => 'Voir les utilisateurs'],
['code' => 'core.users.manage', 'label' => 'Gerer les utilisateurs (creer, editer, supprimer)'],
['code' => 'core.roles.manage', 'label' => 'Gerer les roles et permissions'],
['code' => 'core.permissions.view', 'label' => 'Voir la liste des permissions'],
];
}
}

View File

@@ -0,0 +1,127 @@
<?php
declare(strict_types=1);
namespace App\Module\Core\Domain\Entity;
use App\Module\Core\Infrastructure\Doctrine\DoctrinePermissionRepository;
use Doctrine\ORM\Mapping as ORM;
use InvalidArgumentException;
#[ORM\Entity(repositoryClass: DoctrinePermissionRepository::class)]
#[ORM\Table(name: 'permission')]
#[ORM\UniqueConstraint(name: 'uniq_permission_code', columns: ['code'])]
#[ORM\Index(name: 'idx_permission_module', columns: ['module'])]
#[ORM\Index(name: 'idx_permission_orphan', columns: ['orphan'])]
class Permission
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\Column(length: 255)]
private string $code;
#[ORM\Column(length: 255)]
private string $label;
#[ORM\Column(length: 100)]
private string $module;
#[ORM\Column(options: ['default' => false])]
private bool $orphan = false;
/**
* Invariants : une permission doit avoir un code non vide respectant la
* convention "module.resource[.sub].action" (donc contenir au moins un
* point), un libelle non vide et un module proprietaire non vide. Ces
* garde-fous evitent la persistence de lignes incoherentes si un appelant
* (fixture, commande de synchro, import) oublie un champ ou passe une
* chaine vide.
*/
public function __construct(string $code, string $label, string $module)
{
if ('' === $code) {
throw new InvalidArgumentException('Le code de permission ne peut pas etre vide.');
}
if (!str_contains($code, '.')) {
throw new InvalidArgumentException(sprintf('Le code de permission "%s" ne respecte pas la convention "module.resource[.sub].action".', $code));
}
if ('' === $label) {
throw new InvalidArgumentException('Le libelle de permission ne peut pas etre vide.');
}
if ('' === $module) {
throw new InvalidArgumentException('Le module proprietaire de la permission ne peut pas etre vide.');
}
$this->code = $code;
$this->label = $label;
$this->module = $module;
}
public function getId(): ?int
{
return $this->id;
}
public function getCode(): string
{
return $this->code;
}
public function getLabel(): string
{
return $this->label;
}
public function getModule(): string
{
return $this->module;
}
public function isOrphan(): bool
{
return $this->orphan;
}
/**
* Marque la permission comme orpheline : son code n'est plus declare par
* aucun module. Elle reste en base pour preserver les assignations et
* permettre une reactivation ulterieure, mais doit etre ignoree par les
* verifications d'autorisation.
*/
public function markOrphan(): static
{
$this->orphan = true;
return $this;
}
/**
* Reactive une permission precedemment orpheline : son code reapparait
* dans le code source d'un module. Equivaut a updateMetadata() suivi d'un
* clearing du flag orphan ; on delegue a updateMetadata() pour ne pas
* dupliquer la logique d'affectation des metadonnees.
*/
public function revive(string $label, string $module): static
{
$this->updateMetadata($label, $module);
$this->orphan = false;
return $this;
}
/**
* Met a jour les metadonnees d'une permission active sans toucher a son
* statut d'orphelin. Utilise par la commande de synchronisation lorsque
* seul le libelle ou le module proprietaire a change cote code.
*/
public function updateMetadata(string $label, string $module): static
{
$this->label = $label;
$this->module = $module;
return $this;
}
}

View File

@@ -0,0 +1,153 @@
<?php
declare(strict_types=1);
namespace App\Module\Core\Domain\Entity;
use App\Module\Core\Domain\Exception\SystemRoleDeletionException;
use App\Module\Core\Infrastructure\Doctrine\DoctrineRoleRepository;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
/**
* Role RBAC : groupe nomme de permissions assignable a un utilisateur.
*
* Un role peut etre "systeme" (cree et protege par la plateforme) ou
* "personnalise" (cree par un administrateur). Seuls les roles personnalises
* peuvent etre supprimes.
*/
#[ORM\Entity(repositoryClass: DoctrineRoleRepository::class)]
#[ORM\Table(name: '`role`')]
#[ORM\UniqueConstraint(name: 'uniq_role_code', columns: ['code'])]
#[ORM\Index(name: 'idx_role_is_system', columns: ['is_system'])]
class Role
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\Column(length: 100)]
private string $code;
#[ORM\Column(length: 255)]
private string $label;
#[ORM\Column(type: Types::TEXT, nullable: true)]
private ?string $description = null;
#[ORM\Column(name: 'is_system', options: ['default' => false])]
private bool $isSystem = false;
/** @var Collection<int, Permission> */
// Choix deliberé de fetch: 'EAGER' (durcissement, pas oubli de perf) :
// - Evite un lazy-load silencieux pendant un refresh de token JWT ou une
// serialisation hors contexte EntityManager (voir ticket #343, section
// 11 risque #1) ou la collection serait inaccessible et provoquerait
// une erreur opaque.
// - Compromis accepte : surcout SQL volontaire, acceptable a l'echelle
// d'un CRM/ERP PME ou un role porte quelques dizaines de permissions.
// - Si la volumetrie augmente significativement : revoir vers une
// projection cachee (ticket a ouvrir a ce moment-la).
#[ORM\ManyToMany(targetEntity: Permission::class, fetch: 'EAGER')]
#[ORM\JoinTable(name: 'role_permission')]
private Collection $permissions;
public function __construct(string $code, string $label, bool $isSystem = false, ?string $description = null)
{
$this->code = $code;
$this->label = $label;
$this->isSystem = $isSystem;
$this->description = $description;
$this->permissions = new ArrayCollection();
}
public function getId(): ?int
{
return $this->id;
}
public function getCode(): string
{
return $this->code;
}
public function getLabel(): string
{
return $this->label;
}
public function getDescription(): ?string
{
return $this->description;
}
public function isSystem(): bool
{
return $this->isSystem;
}
/** @return Collection<int, Permission> */
public function getPermissions(): Collection
{
return $this->permissions;
}
/**
* Met a jour le libelle affichable du role. Le code reste immuable pour
* garantir la stabilite des references cote fixtures et migrations.
*/
public function setLabel(string $label): static
{
$this->label = $label;
return $this;
}
/**
* Met a jour la description libre du role (champ documentaire).
*/
public function setDescription(?string $description): static
{
$this->description = $description;
return $this;
}
/**
* Ajoute une permission au role. Idempotent : ajouter deux fois la meme
* permission n'entraine pas de doublon dans la collection.
*/
public function addPermission(Permission $permission): static
{
if (!$this->permissions->contains($permission)) {
$this->permissions->add($permission);
}
return $this;
}
/**
* Retire une permission du role. Idempotent : retirer une permission
* absente est un no-op silencieux.
*/
public function removePermission(Permission $permission): static
{
$this->permissions->removeElement($permission);
return $this;
}
/**
* Garde domaine : refuse la suppression d'un role marque comme systeme.
* La traduction HTTP (403) est faite au niveau application / API Platform.
*/
public function ensureDeletable(): void
{
if ($this->isSystem) {
throw SystemRoleDeletionException::forRole($this);
}
}
}

View File

@@ -14,6 +14,8 @@ use App\Module\Core\Infrastructure\ApiPlatform\State\Processor\UserPasswordHashe
use App\Module\Core\Infrastructure\ApiPlatform\State\Provider\MeProvider;
use App\Module\Core\Infrastructure\Doctrine\DoctrineUserRepository;
use DateTimeImmutable;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
use Symfony\Component\Security\Core\User\UserInterface;
@@ -52,10 +54,37 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
#[Groups(['me:read', 'user:list', 'user:write'])]
private ?string $username = null;
/** @var list<string> */
#[ORM\Column]
#[ORM\Column(name: 'is_admin', options: ['default' => false])]
#[Groups(['me:read', 'user:list', 'user:write'])]
private array $roles = [];
private bool $isAdmin = false;
/**
* Les roles RBAC metier rattaches a l'utilisateur.
*
* Le fetch EAGER est delibere : evite un lazy-load silencieux pendant
* un refresh de token JWT ou une serialisation hors contexte EntityManager
* (cf. docs/rbac/ticket-343-spec.md section 11 risque 1). Le surcout SQL est
* accepte a l'echelle d'un CRM/ERP PME ; a revoir si la volumetrie augmente.
*
* @var Collection<int, Role>
*/
#[ORM\ManyToMany(targetEntity: Role::class, fetch: 'EAGER')]
#[ORM\JoinTable(name: 'user_role')]
#[Groups(['me:read', 'user:list', 'user:write'])]
private Collection $roles;
/**
* Les permissions directes accordees hors des roles.
*
* Meme justification EAGER que pour $roles : garantie que
* getEffectivePermissions() fonctionne dans tous les contextes de chargement.
*
* @var Collection<int, Permission>
*/
#[ORM\ManyToMany(targetEntity: Permission::class, fetch: 'EAGER')]
#[ORM\JoinTable(name: 'user_permission')]
#[Groups(['me:read', 'user:list', 'user:write'])]
private Collection $directPermissions;
#[ORM\Column]
private ?string $password = null;
@@ -68,7 +97,9 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
public function __construct()
{
$this->createdAt = new DateTimeImmutable();
$this->createdAt = new DateTimeImmutable();
$this->roles = new ArrayCollection();
$this->directPermissions = new ArrayCollection();
}
public function getId(): ?int
@@ -93,23 +124,127 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
return (string) $this->username;
}
/** @return list<string> */
/**
* Retourne les roles Symfony Security, derives de $isAdmin.
*
* ROLE_USER est toujours present pour que Symfony accepte l'authentification.
* ROLE_ADMIN est ajoute si l'utilisateur porte le flag is_admin — c'est le
* SEUL levier technique de bypass RBAC (cf. section 11 du spec).
*
* Important : ne JAMAIS iterer $this->roles (la Collection de Role) ici.
* Cette methode peut etre appelee pendant un refresh JWT, moment ou la
* Collection peut ne pas etre hydratee. On se contente d'un calcul base
* sur un scalaire.
*
* @return list<string>
*/
public function getRoles(): array
{
$roles = $this->roles;
$roles[] = 'ROLE_USER';
$roles = ['ROLE_USER'];
return array_values(array_unique($roles));
if ($this->isAdmin) {
$roles[] = 'ROLE_ADMIN';
}
return $roles;
}
/** @param list<string> $roles */
public function setRoles(array $roles): static
public function isAdmin(): bool
{
$this->roles = $roles;
return $this->isAdmin;
}
public function setIsAdmin(bool $isAdmin): static
{
$this->isAdmin = $isAdmin;
return $this;
}
/**
* Retourne la collection de roles RBAC rattaches a l'utilisateur.
*
* NE PAS confondre avec getRoles() qui renvoie les roles Symfony scalaires.
*
* @return Collection<int, Role>
*/
public function getRbacRoles(): Collection
{
return $this->roles;
}
public function addRbacRole(Role $role): static
{
if (!$this->roles->contains($role)) {
$this->roles->add($role);
}
return $this;
}
public function removeRbacRole(Role $role): static
{
$this->roles->removeElement($role);
return $this;
}
/**
* @return Collection<int, Permission>
*/
public function getDirectPermissions(): Collection
{
return $this->directPermissions;
}
public function addDirectPermission(Permission $permission): static
{
if (!$this->directPermissions->contains($permission)) {
$this->directPermissions->add($permission);
}
return $this;
}
public function removeDirectPermission(Permission $permission): static
{
$this->directPermissions->removeElement($permission);
return $this;
}
/**
* Retourne l'union dedupliquee des codes de permissions effectives.
*
* Agrege les permissions venant des roles RBAC et les permissions directes.
* Utilisee par le PermissionVoter (ticket #345) et exposee via /api/me
* apres l'evolution du MeProvider (aussi ticket #345).
*
* Ne PAS appeler dans getRoles() : voir commentaire sur cette derniere
* methode pour le piege de chargement au refresh JWT.
*
* @return list<string>
*/
public function getEffectivePermissions(): array
{
$codes = [];
foreach ($this->roles as $role) {
foreach ($role->getPermissions() as $permission) {
$codes[$permission->getCode()] = true;
}
}
foreach ($this->directPermissions as $permission) {
$codes[$permission->getCode()] = true;
}
$keys = array_keys($codes);
sort($keys);
return $keys;
}
public function getPassword(): ?string
{
return $this->password;

View File

@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace App\Module\Core\Domain\Exception;
use App\Module\Core\Domain\Entity\Role;
use DomainException;
/**
* Levee lorsqu'une tentative de suppression vise un role marque comme systeme.
*
* Les roles systeme (ex : admin, user) sont proteges au niveau du domaine
* pour garantir qu'ils ne peuvent jamais etre retires par un administrateur,
* une commande ou un processus d'import. La traduction HTTP (403) est faite
* ailleurs, cette exception reste purement domaine.
*/
final class SystemRoleDeletionException extends DomainException
{
/**
* Construit l'exception a partir du role refuse a la suppression.
*/
public static function forRole(Role $role): self
{
return new self(sprintf('Le role systeme "%s" ne peut pas etre supprime.', $role->getCode()));
}
}

View File

@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace App\Module\Core\Domain\Repository;
use App\Module\Core\Domain\Entity\Permission;
/**
* Contrat du catalogue de permissions RBAC.
*
* Utilise par la commande de synchronisation (app:sync-permissions), les
* fixtures, et — a terme (ticket #345) — par le PermissionVoter pour valider
* que les codes verifies existent bien dans le catalogue.
*/
interface PermissionRepositoryInterface
{
public function findById(int $id): ?Permission;
public function findByCode(string $code): ?Permission;
/**
* @return array<int, Permission>
*/
public function findAll(): array;
/**
* @return array<int, string> liste des codes connus, pour la commande de sync et le futur voter
*/
public function findAllCodes(): array;
public function save(Permission $permission): void;
}

View File

@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace App\Module\Core\Domain\Repository;
use App\Module\Core\Domain\Entity\Role;
/**
* Contrat des roles RBAC.
*
* Utilise par les fixtures, la future API d'administration (ticket #344) et
* le PermissionVoter pour resoudre les permissions effectives d'un role.
*/
interface RoleRepositoryInterface
{
public function findById(int $id): ?Role;
public function findByCode(string $code): ?Role;
/**
* @return array<int, Role>
*/
public function findAll(): array;
public function save(Role $role): void;
}

View File

@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace App\Module\Core\Domain\Security;
/**
* Source de verite unique pour les codes des roles systeme RBAC.
*
* Ces constantes sont partagees entre les fixtures applicatives et les
* migrations Doctrine (qui inserent les memes codes en SQL brut). Toute
* modification ici doit etre repercutee dans la migration correspondante.
*/
final class SystemRoles
{
public const string ADMIN_CODE = 'admin';
public const string USER_CODE = 'user';
/**
* Empeche l'instanciation : cette classe est un simple porteur de constantes.
*/
private function __construct() {}
}

View File

@@ -6,6 +6,7 @@ namespace App\Module\Core\Infrastructure\ApiPlatform\State\Provider;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use Symfony\Bundle\SecurityBundle\Security;
/**
* @implements ProviderInterface<object>
@@ -13,7 +14,7 @@ use ApiPlatform\State\ProviderInterface;
class MeProvider implements ProviderInterface
{
public function __construct(
private readonly \Symfony\Bundle\SecurityBundle\Security $security,
private readonly Security $security,
) {}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): ?object

View File

@@ -5,7 +5,9 @@ declare(strict_types=1);
namespace App\Module\Core\Infrastructure\Console;
use App\Module\Core\Domain\Entity\User;
use App\Module\Core\Domain\Repository\RoleRepositoryInterface;
use App\Module\Core\Domain\Repository\UserRepositoryInterface;
use App\Module\Core\Domain\Security\SystemRoles;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
@@ -17,13 +19,14 @@ use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
#[AsCommand(
name: 'app:create-user',
description: 'Create a new user',
description: 'Cree un utilisateur rattache au role systeme admin ou user.',
)]
class CreateUserCommand extends Command
final class CreateUserCommand extends Command
{
public function __construct(
private readonly UserRepositoryInterface $userRepository,
private readonly UserPasswordHasherInterface $passwordHasher,
private readonly RoleRepositoryInterface $roleRepository,
) {
parent::__construct();
}
@@ -31,9 +34,9 @@ class CreateUserCommand extends Command
protected function configure(): void
{
$this
->addArgument('username', InputArgument::REQUIRED, 'Username')
->addArgument('password', InputArgument::REQUIRED, 'Plain password')
->addOption('admin', null, InputOption::VALUE_NONE, 'Grant ROLE_ADMIN')
->addArgument('username', InputArgument::REQUIRED, 'Nom d\'utilisateur')
->addArgument('password', InputArgument::REQUIRED, 'Mot de passe en clair')
->addOption('admin', null, InputOption::VALUE_NONE, 'Rattache au role systeme admin + active is_admin')
;
}
@@ -43,18 +46,34 @@ class CreateUserCommand extends Command
$username = $input->getArgument('username');
$plainPassword = $input->getArgument('password');
$isAdmin = (bool) $input->getOption('admin');
$roleCode = $isAdmin ? SystemRoles::ADMIN_CODE : SystemRoles::USER_CODE;
$role = $this->roleRepository->findByCode($roleCode);
if (null === $role) {
$io->error(sprintf(
'Le role systeme "%s" est introuvable. Lance "bin/console doctrine:migrations:migrate" pour le seeder.',
$roleCode,
));
return Command::FAILURE;
}
$user = new User();
$user->setUsername($username);
$user->setPassword($this->passwordHasher->hashPassword($user, $plainPassword));
if ($input->getOption('admin')) {
$user->setRoles(['ROLE_ADMIN']);
}
$user->setIsAdmin($isAdmin);
$user->addRbacRole($role);
$this->userRepository->save($user);
$io->success(sprintf('User "%s" created%s.', $username, $input->getOption('admin') ? ' with ROLE_ADMIN' : ''));
$io->success(sprintf(
'Utilisateur "%s" cree, rattache au role systeme "%s"%s.',
$username,
$roleCode,
$isAdmin ? ' (bypass is_admin actif)' : '',
));
return Command::SUCCESS;
}

View File

@@ -0,0 +1,210 @@
<?php
declare(strict_types=1);
namespace App\Module\Core\Infrastructure\Console;
use App\Module\Core\Domain\Entity\Permission;
use App\Module\Core\Domain\Repository\PermissionRepositoryInterface;
use Doctrine\ORM\EntityManagerInterface;
use InvalidArgumentException;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Throwable;
use function count;
#[AsCommand(
name: 'app:sync-permissions',
description: 'Synchronise les permissions RBAC declarees par les modules actifs.',
)]
final class SyncPermissionsCommand extends Command
{
public function __construct(
private readonly EntityManagerInterface $em,
private readonly PermissionRepositoryInterface $permissionRepository,
#[Autowire(param: 'kernel.project_dir')]
private readonly string $projectDir,
) {
parent::__construct();
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
try {
// Etape 1 : scan + validation stricte des modules actifs AVANT
// tout acces en ecriture a la base, afin qu'une erreur de
// declaration laisse la table `permission` intacte.
$desiredPermissions = $this->collectDesiredPermissions();
} catch (InvalidArgumentException $e) {
$io->error($e->getMessage());
return Command::FAILURE;
}
// Etape 2 : upsert transactionnel non destructif.
$this->em->beginTransaction();
try {
// Indexation des permissions existantes par code pour un acces O(1).
$existingByCode = [];
foreach ($this->permissionRepository->findAll() as $permission) {
$existingByCode[$permission->getCode()] = $permission;
}
$added = 0;
$updated = 0;
$orphans = 0;
// Upsert : chaque entree desiree est creee, revivee ou mise a jour.
foreach ($desiredPermissions as $code => $entry) {
$label = $entry['label'];
$module = $entry['module'];
if (isset($existingByCode[$code])) {
$existing = $existingByCode[$code];
if ($existing->isOrphan()) {
// Revival : le code reapparait dans le source, on
// rafraichit ses metadonnees et on retire le flag.
$existing->revive($label, $module);
++$updated;
} elseif ($existing->getLabel() !== $label || $existing->getModule() !== $module) {
// Mise a jour des metadonnees sans toucher au flag orphan.
$existing->updateMetadata($label, $module);
++$updated;
}
// Sinon : strictement identique, no-op.
} else {
// Creation : on persiste directement via l'EM pour ne
// pas declencher un flush par appel (cf. save() repo).
$permission = new Permission($code, $label, $module);
$this->em->persist($permission);
++$added;
}
}
// Etape 3 : marquage orphelin des permissions absentes du source.
foreach ($existingByCode as $code => $existing) {
if (isset($desiredPermissions[$code])) {
continue;
}
if (!$existing->isOrphan()) {
$existing->markOrphan();
++$orphans;
}
}
// Un unique flush regroupe toutes les mutations de la transaction.
$this->em->flush();
$this->em->commit();
} catch (Throwable $e) {
$this->em->rollback();
$io->error(sprintf('Echec de la synchronisation des permissions : %s', $e->getMessage()));
return Command::FAILURE;
}
$totalInDb = count($this->permissionRepository->findAll());
$io->success('Synchronisation des permissions RBAC terminee.');
$io->table(
['Indicateur', 'Valeur'],
[
['Permissions ajoutees', (string) $added],
['Permissions mises a jour ou revivees', (string) $updated],
['Permissions marquees orphelines', (string) $orphans],
['Total en base apres sync', (string) $totalInDb],
],
);
return Command::SUCCESS;
}
/**
* Parcourt la liste des modules actifs declares dans `config/modules.php`,
* extrait leurs permissions statiques, valide strictement chaque entree
* puis renvoie une map indexee par code.
*
* Regles de validation appliquees :
* - chaque entree doit posseder exactement les cles `code` et `label`
* - le `code` doit etre prefixe par `<ModuleClass>::ID . '.'`
* - `code` et `label` ne peuvent pas etre des chaines vides
*
* Les modules ne definissant pas de methode statique `permissions()` sont
* ignores silencieusement (compat ascendante pour les modules legacy).
*
* @return array<string, array{code: string, label: string, module: string}>
*/
private function collectDesiredPermissions(): array
{
/** @var array<int, class-string> $moduleClasses */
$moduleClasses = require $this->projectDir.'/config/modules.php';
$desired = [];
foreach ($moduleClasses as $moduleClass) {
if (!method_exists($moduleClass, 'permissions')) {
continue;
}
/** @var array<int, array<string, string>> $entries */
$entries = $moduleClass::permissions();
$moduleId = $moduleClass::ID;
foreach ($entries as $entry) {
$keys = array_keys($entry);
sort($keys);
if (['code', 'label'] !== $keys) {
throw new InvalidArgumentException(sprintf(
'Permission malformee declaree par %s : chaque entree doit contenir exactement les cles [code, label], recu [%s].',
$moduleClass,
implode(', ', array_keys($entry)),
));
}
$code = $entry['code'];
$label = $entry['label'];
if ('' === $code) {
throw new InvalidArgumentException(sprintf(
'Permission invalide declaree par %s : le code ne peut pas etre vide.',
$moduleClass,
));
}
if ('' === $label) {
throw new InvalidArgumentException(sprintf(
'Permission invalide declaree par %s (code "%s") : le libelle ne peut pas etre vide.',
$moduleClass,
$code,
));
}
$expectedPrefix = $moduleId.'.';
if (!str_starts_with($code, $expectedPrefix)) {
throw new InvalidArgumentException(sprintf(
'Permission invalide declaree par %s : le code "%s" doit etre prefixe par "%s" (ID du module).',
$moduleClass,
$code,
$expectedPrefix,
));
}
$desired[$code] = [
'code' => $code,
'label' => $label,
'module' => $moduleId,
];
}
}
return $desired;
}
}

View File

@@ -4,37 +4,90 @@ declare(strict_types=1);
namespace App\Module\Core\Infrastructure\DataFixtures;
use App\Module\Core\Domain\Entity\Role;
use App\Module\Core\Domain\Entity\User;
use App\Module\Core\Domain\Repository\RoleRepositoryInterface;
use App\Module\Core\Domain\Security\SystemRoles;
use Doctrine\Bundle\FixturesBundle\Fixture;
use Doctrine\Persistence\ObjectManager;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
/**
* Fixtures de base du module Core : 3 utilisateurs (1 admin + 2 standards)
* rattaches aux roles systeme RBAC seedes par la migration Version20260414150034.
*
* Note : le purger Doctrine execute avant load() supprime l'ensemble des
* entites managees, ce qui inclut la table role. On re-seede donc les roles
* systeme de maniere idempotente avant de rattacher les utilisateurs, afin
* que le workflow "make db-reset && make fixtures" reste one-shot.
*/
class AppFixtures extends Fixture
{
public function __construct(
private readonly UserPasswordHasherInterface $passwordHasher,
private readonly RoleRepositoryInterface $roleRepository,
) {}
public function load(ObjectManager $manager): void
{
$adminRole = $this->ensureSystemRole(
$manager,
SystemRoles::ADMIN_CODE,
'Administrateur',
'Role administrateur - bypass complet via is_admin',
);
$userRole = $this->ensureSystemRole(
$manager,
SystemRoles::USER_CODE,
'Utilisateur',
'Role de base sans permission specifique',
);
$admin = new User();
$admin->setUsername('admin');
$admin->setRoles(['ROLE_ADMIN']);
$admin->setIsAdmin(true);
$admin->setPassword($this->passwordHasher->hashPassword($admin, 'admin'));
$admin->addRbacRole($adminRole);
$manager->persist($admin);
$alice = new User();
$alice->setUsername('alice');
$alice->setRoles(['ROLE_USER']);
$alice->setPassword($this->passwordHasher->hashPassword($alice, 'alice'));
$alice->addRbacRole($userRole);
$manager->persist($alice);
$bob = new User();
$bob->setUsername('bob');
$bob->setRoles(['ROLE_USER']);
$bob->setPassword($this->passwordHasher->hashPassword($bob, 'bob'));
$bob->addRbacRole($userRole);
$manager->persist($bob);
$manager->flush();
}
/**
* Retourne le role systeme correspondant au code donne, en le creant
* s'il n'existe pas encore (le purger Doctrine a pu vider la table role).
*
* La description est recopiee depuis la migration RBAC pour que les
* deux chemins (migration prod, fixtures dev) produisent un etat
* identique.
*/
private function ensureSystemRole(
ObjectManager $manager,
string $code,
string $label,
string $description,
): Role {
$role = $this->roleRepository->findByCode($code);
if (null !== $role) {
return $role;
}
$role = new Role($code, $label, isSystem: true, description: $description);
$manager->persist($role);
return $role;
}
}

View File

@@ -0,0 +1,62 @@
<?php
declare(strict_types=1);
namespace App\Module\Core\Infrastructure\Doctrine;
use App\Module\Core\Domain\Entity\Permission;
use App\Module\Core\Domain\Repository\PermissionRepositoryInterface;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<Permission>
*/
class DoctrinePermissionRepository extends ServiceEntityRepository implements PermissionRepositoryInterface
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Permission::class);
}
public function findById(int $id): ?Permission
{
return $this->find($id);
}
public function findByCode(string $code): ?Permission
{
return $this->findOneBy(['code' => $code]);
}
/**
* @return array<int, Permission>
*/
public function findAll(): array
{
return parent::findAll();
}
/**
* @return array<int, string>
*/
public function findAllCodes(): array
{
// Requete legere : on ne selectionne que la colonne code (pas d'hydratation
// d'entites Permission) car findAllCodes() est appelee par la commande de
// sync et le futur voter qui n'ont besoin que des chaines.
$rows = $this->createQueryBuilder('p')
->select('p.code')
->getQuery()
->getArrayResult()
;
return array_column($rows, 'code');
}
public function save(Permission $permission): void
{
$this->getEntityManager()->persist($permission);
$this->getEntityManager()->flush();
}
}

View File

@@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace App\Module\Core\Infrastructure\Doctrine;
use App\Module\Core\Domain\Entity\Role;
use App\Module\Core\Domain\Repository\RoleRepositoryInterface;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<Role>
*/
class DoctrineRoleRepository extends ServiceEntityRepository implements RoleRepositoryInterface
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Role::class);
}
public function findById(int $id): ?Role
{
return $this->find($id);
}
public function findByCode(string $code): ?Role
{
return $this->findOneBy(['code' => $code]);
}
/**
* @return array<int, Role>
*/
public function findAll(): array
{
return parent::findAll();
}
public function save(Role $role): void
{
$this->getEntityManager()->persist($role);
$this->getEntityManager()->flush();
}
}

View File

@@ -0,0 +1,87 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Core\Domain\Entity;
use App\Module\Core\Domain\Entity\Permission;
use InvalidArgumentException;
use PHPUnit\Framework\TestCase;
/**
* @internal
*/
final class PermissionTest extends TestCase
{
public function testConstructorInitialState(): void
{
$permission = new Permission('core.users.view', 'Voir les utilisateurs', 'core');
self::assertNull($permission->getId());
self::assertSame('core.users.view', $permission->getCode());
self::assertSame('Voir les utilisateurs', $permission->getLabel());
self::assertSame('core', $permission->getModule());
self::assertFalse($permission->isOrphan());
}
public function testMarkOrphanSetsFlag(): void
{
$permission = new Permission('core.users.view', 'Voir les utilisateurs', 'core');
$permission->markOrphan();
self::assertTrue($permission->isOrphan());
}
public function testReviveResetsOrphanAndUpdatesMetadata(): void
{
$permission = new Permission('core.users.view', 'Old label', 'core');
$permission->markOrphan();
$permission->revive('New label', 'commercial');
self::assertFalse($permission->isOrphan());
self::assertSame('New label', $permission->getLabel());
self::assertSame('commercial', $permission->getModule());
}
public function testUpdateMetadataDoesNotTouchOrphan(): void
{
$permission = new Permission('core.users.view', 'Old', 'core');
$permission->markOrphan();
$permission->updateMetadata('Lbl', 'core');
self::assertTrue($permission->isOrphan());
self::assertSame('Lbl', $permission->getLabel());
}
public function testConstructorRejectsEmptyCode(): void
{
$this->expectException(InvalidArgumentException::class);
new Permission('', 'Libelle', 'core');
}
public function testConstructorRejectsCodeWithoutDot(): void
{
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage('invalid_format');
new Permission('invalid_format', 'Libelle', 'core');
}
public function testConstructorRejectsEmptyLabel(): void
{
$this->expectException(InvalidArgumentException::class);
new Permission('core.users.view', '', 'core');
}
public function testConstructorRejectsEmptyModule(): void
{
$this->expectException(InvalidArgumentException::class);
new Permission('core.users.view', 'Libelle', '');
}
}

View File

@@ -0,0 +1,91 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Core\Domain\Entity;
use App\Module\Core\Domain\Entity\Permission;
use App\Module\Core\Domain\Entity\Role;
use App\Module\Core\Domain\Exception\SystemRoleDeletionException;
use PHPUnit\Framework\TestCase;
/**
* @internal
*/
final class RoleTest extends TestCase
{
public function testConstructorInitialState(): void
{
$role = new Role('custom', 'Custom');
self::assertNull($role->getId());
self::assertSame('custom', $role->getCode());
self::assertSame('Custom', $role->getLabel());
self::assertNull($role->getDescription());
self::assertFalse($role->isSystem());
self::assertTrue($role->getPermissions()->isEmpty());
}
public function testAddPermissionAddsOnce(): void
{
$role = new Role('custom', 'Custom');
$permission = new Permission('core.users.view', 'Voir', 'core');
$role->addPermission($permission);
$role->addPermission($permission);
self::assertSame(1, $role->getPermissions()->count());
}
public function testAddPermissionAddsMultipleDistinct(): void
{
$role = new Role('custom', 'Custom');
$permissionView = new Permission('core.users.view', 'Voir', 'core');
$permissionEdit = new Permission('core.users.edit', 'Editer', 'core');
$role->addPermission($permissionView);
$role->addPermission($permissionEdit);
self::assertSame(2, $role->getPermissions()->count());
}
public function testRemovePermissionRemovesWhenPresent(): void
{
$role = new Role('custom', 'Custom');
$permission = new Permission('core.users.view', 'Voir', 'core');
$role->addPermission($permission);
$role->removePermission($permission);
self::assertSame(0, $role->getPermissions()->count());
}
public function testRemovePermissionIsNoOpWhenAbsent(): void
{
$role = new Role('custom', 'Custom');
$permission = new Permission('core.users.view', 'Voir', 'core');
$role->removePermission($permission);
self::assertSame(0, $role->getPermissions()->count());
}
public function testEnsureDeletableAllowsNonSystemRole(): void
{
$role = new Role('custom', 'Custom', false);
$role->ensureDeletable();
$this->expectNotToPerformAssertions();
}
public function testEnsureDeletableThrowsForSystemRole(): void
{
$role = new Role('admin', 'Admin', true);
$this->expectException(SystemRoleDeletionException::class);
$this->expectExceptionMessage('admin');
$role->ensureDeletable();
}
}

View File

@@ -0,0 +1,132 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Core\Domain\Entity;
use App\Module\Core\Domain\Entity\Permission;
use App\Module\Core\Domain\Entity\Role;
use App\Module\Core\Domain\Entity\User;
use PHPUnit\Framework\TestCase;
/**
* @internal
*/
final class UserTest extends TestCase
{
public function testGetRolesReturnsRoleUserByDefault(): void
{
$user = new User();
self::assertSame(['ROLE_USER'], $user->getRoles());
}
public function testGetRolesIncludesRoleAdminWhenIsAdminTrue(): void
{
$user = new User();
$user->setIsAdmin(true);
self::assertSame(['ROLE_USER', 'ROLE_ADMIN'], $user->getRoles());
}
public function testIsAdminDefaultsToFalse(): void
{
$user = new User();
self::assertFalse($user->isAdmin());
}
public function testGetEffectivePermissionsIsEmptyByDefault(): void
{
$user = new User();
self::assertSame([], $user->getEffectivePermissions());
}
public function testGetEffectivePermissionsUnionsRolesAndDirects(): void
{
$perm1 = new Permission('core.users.view', 'View users', 'core');
$perm2 = new Permission('core.users.edit', 'Edit users', 'core');
$perm3 = new Permission('core.users.delete', 'Delete users', 'core');
$role = new Role('manager', 'Manager');
$role->addPermission($perm1);
$role->addPermission($perm2);
$user = new User();
$user->addRbacRole($role);
$user->addDirectPermission($perm3);
self::assertSame(
['core.users.delete', 'core.users.edit', 'core.users.view'],
$user->getEffectivePermissions(),
);
}
public function testGetEffectivePermissionsDeduplicatesAcrossRolesAndDirects(): void
{
$perm = new Permission('core.users.view', 'View users', 'core');
$role = new Role('viewer', 'Viewer');
$role->addPermission($perm);
$user = new User();
$user->addRbacRole($role);
$user->addDirectPermission($perm);
$result = $user->getEffectivePermissions();
self::assertCount(1, $result);
self::assertSame(['core.users.view'], $result);
}
public function testAddRbacRoleIsIdempotent(): void
{
$role = new Role('manager', 'Manager');
$user = new User();
$user->addRbacRole($role);
$user->addRbacRole($role);
self::assertSame(1, $user->getRbacRoles()->count());
}
public function testAddDirectPermissionIsIdempotent(): void
{
$perm = new Permission('core.users.view', 'View users', 'core');
$user = new User();
$user->addDirectPermission($perm);
$user->addDirectPermission($perm);
self::assertSame(1, $user->getDirectPermissions()->count());
}
public function testRemoveRbacRole(): void
{
$role = new Role('manager', 'Manager');
$user = new User();
$user->addRbacRole($role);
$user->removeRbacRole($role);
self::assertSame(0, $user->getRbacRoles()->count());
}
public function testGetEffectivePermissionsOutputIsSorted(): void
{
$permZ = new Permission('core.z.action', 'Z', 'core');
$permA = new Permission('core.a.action', 'A', 'core');
$permM = new Permission('core.m.action', 'M', 'core');
$user = new User();
$user->addDirectPermission($permZ);
$user->addDirectPermission($permA);
$user->addDirectPermission($permM);
self::assertSame(
['core.a.action', 'core.m.action', 'core.z.action'],
$user->getEffectivePermissions(),
);
}
}