Compare commits
10 Commits
e3025bf2c9
...
7ccc913862
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7ccc913862 | ||
|
|
eb0b49a7ef | ||
|
|
0a496f34e0 | ||
|
|
aafe08b6ad | ||
|
|
d68aa0456a | ||
|
|
3b1f18b0e0 | ||
|
|
7aa32b1972 | ||
|
|
3b34d00872 | ||
|
|
0fc0b57e37 | ||
|
|
f0ea9201f5 |
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
223
migrations/Version20260414150034.php
Normal file
223
migrations/Version20260414150034.php
Normal 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');
|
||||
}
|
||||
}
|
||||
@@ -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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
127
src/Module/Core/Domain/Entity/Permission.php
Normal file
127
src/Module/Core/Domain/Entity/Permission.php
Normal 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;
|
||||
}
|
||||
}
|
||||
153
src/Module/Core/Domain/Entity/Role.php
Normal file
153
src/Module/Core/Domain/Entity/Role.php
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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()));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
23
src/Module/Core/Domain/Security/SystemRoles.php
Normal file
23
src/Module/Core/Domain/Security/SystemRoles.php
Normal 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() {}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
87
tests/Module/Core/Domain/Entity/PermissionTest.php
Normal file
87
tests/Module/Core/Domain/Entity/PermissionTest.php
Normal 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', '');
|
||||
}
|
||||
}
|
||||
91
tests/Module/Core/Domain/Entity/RoleTest.php
Normal file
91
tests/Module/Core/Domain/Entity/RoleTest.php
Normal 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();
|
||||
}
|
||||
}
|
||||
132
tests/Module/Core/Domain/Entity/UserTest.php
Normal file
132
tests/Module/Core/Domain/Entity/UserTest.php
Normal 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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user