diff --git a/CLAUDE.md b/CLAUDE.md index 1989820..59a41e9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -3,7 +3,7 @@ ## Contexte CRM/ERP en architecture **modular monolith DDD**. Le backend est la source de verite unique (modules actifs, sidebar). Le frontend scanne `frontend/modules/*/` comme layers Nuxt et consomme l'API pour la navigation. Multi-tenant : chaque module est activable/desactivable. -Doc humaine : @README.md — Spec audit : @doc/audit-log.md +Doc humaine : `README.md` — Spec audit : `doc/audit-log.md` (à lire à la demande, non chargés en permanence). ## Stack - Backend : PHP 8.4, Symfony 8, API Platform 4, Doctrine ORM, PostgreSQL 16 (port 5437) @@ -37,7 +37,7 @@ Doc humaine : @README.md — Spec audit : @doc/audit-log.md @.claude/rules/git.md @.claude/rules/workflow.md -## Commandes (liste complete dans @README.md) +## Commandes (liste complete dans `README.md`) - Demarrer : `make start` - Dev front (hot reload) : `make dev-nuxt` (port 3004) @@ -70,3 +70,5 @@ Editer uniquement `config/modules.php` (commenter la ligne). Cascade automatique ## Credentials (dev) `admin` / `admin` (ROLE_ADMIN) ; `alice` / `alice`, `bob` / `bob` (ROLE_USER). + +Comptes demo des roles metier (seedes par `RbacDemoFixtures` / `app:seed-rbac --with-demo-users`, mot de passe `demo`) : `bureau` / `demo`, `compta` / `demo`, `commerciale` / `demo`, `usine` / `demo`. Matrice RBAC § 2.7 (M1 Clients) attachee aux roles correspondants. diff --git a/README.md b/README.md index 6a93517..6663b9d 100644 --- a/README.md +++ b/README.md @@ -169,13 +169,41 @@ Secrets requis dans Gitea : - `RELEASE_TOKEN` — PAT avec droits `write:repository` - `REGISTRY_TOKEN` — token pour le registry Docker +## Déploiement — seed RBAC (recette / prod) + +Le RBAC métier (rôles `bureau` / `compta` / `commerciale` / `usine` + matrice § 2.7) +est seedé par une **commande applicative idempotente** (présente dans le build prod, +contrairement aux fixtures Doctrine en `require-dev`). À jouer dans l'étape de release, +**après** les migrations et la synchronisation des permissions : + +```bash +php bin/console doctrine:migrations:migrate --no-interaction +php bin/console app:sync-permissions # pose les permissions commercial.clients.* +php bin/console app:seed-rbac # PROD : rôles + matrice § 2.7 (sans comptes démo) +``` + +En **recette / staging**, ajouter le flag pour disposer de logins de test (mot de passe +fourni explicitement, jamais en dur) : + +```bash +php bin/console app:seed-rbac --with-demo-users --password='' +# ou via la variable d'env RBAC_DEMO_PASSWORD +``` + +La commande est rejouable sans effet de bord (aucun doublon de rôle, de lien ou de compte). +En dev, `make db-reset` produit le même résultat (rôles + matrice + comptes démo). + ## Credentials (dev) -| Username | Password | Role | -|----------|----------|------| -| admin | admin | ROLE_ADMIN | -| alice | alice | ROLE_USER | -| bob | bob | ROLE_USER | +| Username | Password | Role | RBAC métier | +|----------|----------|------|-------------| +| admin | admin | ROLE_ADMIN | bypass (is_admin) | +| alice | alice | ROLE_USER | — | +| bob | bob | ROLE_USER | — | +| bureau | demo | ROLE_USER | clients : view + manage | +| compta | demo | ROLE_USER | clients : view + accounting.view/manage | +| commerciale | demo | ROLE_USER | clients : view + manage (Information obligatoire — RG-1.04) | +| usine | demo | ROLE_USER | aucun accès clients | ## Conventions diff --git a/makefile b/makefile index 74857f9..6738ff5 100644 --- a/makefile +++ b/makefile @@ -198,8 +198,11 @@ migration-migrate: # doctrine:fixtures:load essaie de DELETE toutes les tables connues # via les mappings — si fake_site_aware_entity est mappe mais absent # en DB, le purger crash. -# 3. fixtures -> sync-permissions : fixtures:load purge la table permission, -# donc sync doit passer apres. +# 3. fixtures -> sync-permissions -> seed-rbac : fixtures:load purge la table +# permission, donc sync doit passer apres. seed-rbac (matrice RBAC § 2.7) +# passe ensuite, car attachMatrix() exige les permissions en base. Les +# comptes demo sont crees par RbacDemoFixtures au load (sans la matrice, +# attachee ici). Cf. ERP-74. # 4. recreation des index partiels uniques : schema:update drop les index # orphelins du mapping ORM. Les index partiels (LOWER + WHERE) ne sont pas # exprimables via les attributs Doctrine ORM (fonctionnel + partiel), donc @@ -220,6 +223,7 @@ test-db-setup: $(SYMFONY_CONSOLE) --env=test --no-interaction app:apply-column-comments $(SYMFONY_CONSOLE) --env=test --no-interaction doctrine:fixtures:load $(SYMFONY_CONSOLE) --env=test --no-interaction app:sync-permissions + $(SYMFONY_CONSOLE) --env=test --no-interaction app:seed-rbac $(SYMFONY_CONSOLE) --env=test dbal:run-sql "CREATE UNIQUE INDEX IF NOT EXISTS uq_category_name_type_active ON category (LOWER(name), category_type_id) WHERE deleted_at IS NULL" $(SYMFONY_CONSOLE) --env=test dbal:run-sql "CREATE UNIQUE INDEX IF NOT EXISTS uq_client_company_name_active ON client (LOWER(company_name)) WHERE is_archived = FALSE AND deleted_at IS NULL" @@ -231,6 +235,15 @@ fixtures: sync-permissions: $(SYMFONY_CONSOLE) --no-interaction app:sync-permissions +# Seed RBAC metier : roles (bureau/compta/commerciale/usine) + matrice § 2.7 +# (+ comptes demo en dev). Idempotent et NON destructif. A lancer APRES +# sync-permissions (attachMatrix exige les permissions en base). Les comptes +# demo dev sont deja crees par RbacDemoFixtures (make fixtures) ; ici on attache +# la matrice (les permissions etaient purgees au moment du load fixtures). +# En recette/prod, c'est cette commande (avec/sans --with-demo-users) qui seede. +seed-rbac: + $(SYMFONY_CONSOLE) --no-interaction app:seed-rbac + # Attention, supprime votre bdd local db-reset: $(DOCKER_COMPOSE) down -v @@ -240,6 +253,7 @@ db-reset: $(MAKE) migration-migrate $(MAKE) fixtures $(MAKE) sync-permissions + $(MAKE) seed-rbac $(MAKE) test-db-setup # Restart la bdd diff --git a/src/Module/Core/Application/Rbac/RbacSeeder.php b/src/Module/Core/Application/Rbac/RbacSeeder.php new file mode 100644 index 0000000..777b943 --- /dev/null +++ b/src/Module/Core/Application/Rbac/RbacSeeder.php @@ -0,0 +1,218 @@ + permissions) + * et les comptes demo par role. Aucun de ces litteraux ne doit etre duplique + * ailleurs (ni SQL en dur, ni autre fixture). + * + * Consomme par : + * - la commande applicative `app:seed-rbac` (presente dans le build prod, donc + * rejouable en recette/prod, contrairement aux fixtures `require-dev`) ; + * - la fixture Core dev/test (DRY : meme seeder). + * + * Toutes les operations sont idempotentes et non destructives : + * - ensureRoles() : cree un role par lookup de code (skip si present) ; + * - attachMatrix() : attache les permissions § 2.7 via la M2M role_permission, + * sans re-attacher un lien existant ; STOP explicite si un code manque ; + * - ensureDemoUsers() : cree un user par role (lookup par username, skip si + * present), rattache au role + a >= 1 site. + */ +final class RbacSeeder +{ + /** + * Codes des roles metier (snake_case, regex Role respectee). `commerciale` + * reference la constante Shared deja consommee par le ClientProcessor + * (RG-1.04) pour eviter tout drift : un seul litteral pour ce code. + */ + public const string ROLE_BUREAU = 'bureau'; + public const string ROLE_COMPTA = 'compta'; + public const string ROLE_COMMERCIALE = BusinessRoles::COMMERCIALE; + public const string ROLE_USINE = 'usine'; + + /** Site de rattachement par defaut des comptes demo (cf. SitesFixtures). */ + private const string DEFAULT_SITE_NAME = 'Chatellerault'; + + /** + * Definition unique des 4 roles + matrice § 2.7. La cle est le code du role, + * `label` le libelle FR affichable, `permissions` la liste des codes RBAC a + * attacher (vide pour usine : aucun acces ; admin n'apparait pas car il + * bypass tout via isAdmin ; `commercial.clients.archive` n'est attache a + * aucun role metier — admin seul). + * + * @var array}> + */ + private const array MATRIX = [ + self::ROLE_BUREAU => [ + 'label' => 'Bureau', + 'permissions' => [ + 'commercial.clients.view', + 'commercial.clients.manage', + ], + ], + self::ROLE_COMPTA => [ + 'label' => 'Comptabilité', + 'permissions' => [ + 'commercial.clients.view', + 'commercial.clients.accounting.view', + 'commercial.clients.accounting.manage', + ], + ], + self::ROLE_COMMERCIALE => [ + 'label' => 'Commerciale', + 'permissions' => [ + 'commercial.clients.view', + 'commercial.clients.manage', + ], + ], + self::ROLE_USINE => [ + 'label' => 'Usine', + 'permissions' => [], + ], + ]; + + public function __construct( + private readonly RoleRepositoryInterface $roleRepository, + private readonly PermissionRepositoryInterface $permissionRepository, + private readonly UserRepositoryInterface $userRepository, + private readonly SiteProviderInterface $siteProvider, + private readonly UserPasswordHasherInterface $passwordHasher, + ) {} + + /** + * Cree chaque role metier absent (lookup par code). Idempotent. + * + * @return list codes des roles effectivement crees (vide au rejeu) + */ + public function ensureRoles(): array + { + $created = []; + + foreach (self::MATRIX as $code => $definition) { + if (null !== $this->roleRepository->findByCode($code)) { + continue; + } + + // isSystem=false : ce sont des roles metier, supprimables par un + // admin (contrairement aux roles systeme admin/user). + $this->roleRepository->save(new Role($code, $definition['label'], isSystem: false)); + $created[] = $code; + } + + return $created; + } + + /** + * Attache la matrice § 2.7 a chaque role via la M2M role_permission. Lookup + * de la permission par code ; un code absent leve une RbacSeedException + * (garde-fou : `app:sync-permissions` doit avoir tourne). Idempotent : un + * lien deja present n'est pas recree. + * + * @return int nombre de liens role->permission effectivement ajoutes (0 au rejeu) + * + * @throws RbacSeedException si un role ou une permission de la matrice manque + */ + public function attachMatrix(): int + { + $added = 0; + + foreach (self::MATRIX as $code => $definition) { + $role = $this->roleRepository->findByCode($code); + if (null === $role) { + throw RbacSeedException::missingRole($code); + } + + $touched = false; + foreach ($definition['permissions'] as $permissionCode) { + $permission = $this->permissionRepository->findByCode($permissionCode); + if (null === $permission) { + throw RbacSeedException::missingPermission($permissionCode); + } + + if (!$role->getPermissions()->contains($permission)) { + $role->addPermission($permission); + $touched = true; + ++$added; + } + } + + // Un seul flush par role, et seulement si un lien a change. + if ($touched) { + $this->roleRepository->save($role); + } + } + + return $added; + } + + /** + * Cree un compte demo par role metier (username = code du role), non-admin, + * mot de passe hashe, rattache a son role et a >= 1 site. Lookup par + * username : idempotent (un compte existant est laisse intact, mot de passe + * inchange). + * + * @return list usernames effectivement crees (vide au rejeu) + * + * @throws RbacSeedException si un role metier attendu est absent (ensureRoles non joue) + */ + public function ensureDemoUsers(string $password): array + { + // Rattachement a un site par defaut s'il existe (les flux login / me en + // ont besoin ; le repertoire clients n'est pas site-scope mais on reste + // coherent avec les fixtures admin/alice/bob). + $defaultSite = $this->siteProvider->findByName(self::DEFAULT_SITE_NAME); + $created = []; + + foreach (array_keys(self::MATRIX) as $code) { + $username = $code; + if (null !== $this->userRepository->findByUsername($username)) { + continue; + } + + $role = $this->roleRepository->findByCode($code); + if (null === $role) { + throw RbacSeedException::missingRole($code); + } + + $user = new User(); + $user->setUsername($username); + $user->setIsAdmin(false); + $user->setPassword($this->passwordHasher->hashPassword($user, $password)); + $user->addRbacRole($role); + + if (null !== $defaultSite) { + $user->addSite($defaultSite); + $user->setCurrentSite($defaultSite); + } + + $this->userRepository->save($user); + $created[] = $username; + } + + return $created; + } + + /** + * Liste des codes des roles metier definis (pour reporting / tests). + * + * @return list + */ + public static function roleCodes(): array + { + return array_keys(self::MATRIX); + } +} diff --git a/src/Module/Core/Domain/Exception/RbacSeedException.php b/src/Module/Core/Domain/Exception/RbacSeedException.php new file mode 100644 index 0000000..8544cd6 --- /dev/null +++ b/src/Module/Core/Domain/Exception/RbacSeedException.php @@ -0,0 +1,38 @@ +` pour + * disposer de logins de test. + * + * Toute la logique (litteraux des roles, matrice, comptes demo) vit dans + * RbacSeeder — cette commande n'en est que l'enveloppe CLI. + */ +#[AsCommand( + name: 'app:seed-rbac', + description: 'Seede les roles metier RBAC + la matrice § 2.7 (idempotent, non destructif).', +)] +final class SeedRbacCommand extends Command +{ + /** Variable d'environnement de repli pour le mot de passe des comptes demo. */ + private const string PASSWORD_ENV = 'RBAC_DEMO_PASSWORD'; + + public function __construct(private readonly RbacSeeder $seeder) + { + parent::__construct(); + } + + protected function configure(): void + { + $this + ->addOption( + 'with-demo-users', + null, + InputOption::VALUE_NONE, + 'Cree aussi un compte demo par role metier (recette/dev — JAMAIS en prod).', + ) + ->addOption( + 'password', + null, + InputOption::VALUE_REQUIRED, + 'Mot de passe des comptes demo (defaut : variable d\'env '.self::PASSWORD_ENV.'). Requis avec --with-demo-users.', + ) + ; + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + + // 1. Roles metier + matrice § 2.7. attachMatrix() exige que les + // permissions soient en base : sinon RbacSeedException porteuse de + // l'invite a lancer `app:sync-permissions`. + try { + $createdRoles = $this->seeder->ensureRoles(); + $addedLinks = $this->seeder->attachMatrix(); + } catch (RbacSeedException $e) { + $io->error($e->getMessage()); + + return Command::FAILURE; + } + + $io->text(sprintf( + 'Roles metier : %d cree(s), matrice § 2.7 : %d lien(s) ajoute(s).', + count($createdRoles), + $addedLinks, + )); + + // 2. Comptes demo (optionnel, jamais en prod). + if ((bool) $input->getOption('with-demo-users')) { + $password = $this->resolveDemoPassword($input); + if (null === $password) { + $io->error(sprintf( + '--with-demo-users exige un mot de passe : passe --password=<...> ou definis la variable d\'env %s. ' + .'(Aucun mot de passe en dur cote serveur.)', + self::PASSWORD_ENV, + )); + + return Command::FAILURE; + } + + try { + $createdUsers = $this->seeder->ensureDemoUsers($password); + } catch (RbacSeedException $e) { + $io->error($e->getMessage()); + + return Command::FAILURE; + } + + $io->text(sprintf( + 'Comptes demo : %d cree(s)%s.', + count($createdUsers), + [] === $createdUsers ? '' : ' ['.implode(', ', $createdUsers).']', + )); + } + + $io->success('Seed RBAC metier termine (idempotent).'); + + return Command::SUCCESS; + } + + /** + * Resout le mot de passe demo : option `--password` prioritaire, sinon + * variable d'environnement. Renvoie null si aucun n'est fourni (la commande + * refuse alors --with-demo-users plutot que d'inventer un mot de passe). + */ + private function resolveDemoPassword(InputInterface $input): ?string + { + /** @var null|string $option */ + $option = $input->getOption('password'); + if (null !== $option && '' !== $option) { + return $option; + } + + $env = $_SERVER[self::PASSWORD_ENV] ?? $_ENV[self::PASSWORD_ENV] ?? getenv(self::PASSWORD_ENV); + if (is_string($env) && '' !== $env) { + return $env; + } + + return null; + } +} diff --git a/src/Module/Core/Infrastructure/DataFixtures/RbacDemoFixtures.php b/src/Module/Core/Infrastructure/DataFixtures/RbacDemoFixtures.php new file mode 100644 index 0000000..31829ee --- /dev/null +++ b/src/Module/Core/Infrastructure/DataFixtures/RbacDemoFixtures.php @@ -0,0 +1,56 @@ + + */ + public function getDependencies(): array + { + return [SitesFixtures::class]; + } + + public function load(ObjectManager $manager): void + { + // Idempotent : ensureRoles puis ensureDemoUsers (lookup par code / + // username). La matrice est volontairement deferree (cf. docblock). + $this->seeder->ensureRoles(); + $this->seeder->ensureDemoUsers(self::DEMO_PASSWORD); + } +}