Compare commits

..

27 Commits

Author SHA1 Message Date
Matthieu a88cb1bc35 fix(core) : harden review findings (me-provider null guard, audit-ignore plainpassword, rbac self-edit guard, module id dedup, audit pagination guard) 2026-06-19 22:39:26 +02:00
Matthieu 7686904c43 docs : log LST-61 audit log session learnings 2026-06-19 21:19:27 +02:00
Matthieu 9b26b43aca fix(core) : align audit entity-types front service with single-resource api shape 2026-06-19 21:18:22 +02:00
Matthieu e7af415a1f feat(core) : add audit log consultation tab in admin gated by permission 2026-06-19 21:15:13 +02:00
Matthieu 90b8ca15cd feat(core) : expose read-only audit-logs api with dbal provider and pagination 2026-06-19 21:09:55 +02:00
Matthieu 8c3699a9b0 feat(core) : add doctrine audit listener and mark core entities auditable 2026-06-19 21:05:34 +02:00
Matthieu d8553f06f5 feat(core) : add audit log writer and request id provider 2026-06-19 21:01:15 +02:00
Matthieu 934cf0835f feat(core) : add audit attributes, audit_log table and dedicated dbal connection 2026-06-19 20:56:32 +02:00
Matthieu fda03bd1f5 docs : add LST-61 audit log implementation plan 2026-06-19 20:53:36 +02:00
Matthieu 4760c386ed docs : log LST-57 rbac fin session learnings 2026-06-19 17:38:26 +02:00
Matthieu 511353c3f5 feat(core) : add usePermissions composable and rbac roles admin front 2026-06-19 17:35:51 +02:00
Matthieu 544d4cf44f feat(core) : gate sidebar by effective permissions 2026-06-19 17:28:42 +02:00
Matthieu 1a9eba93a0 feat(core) : add rbac seeder and seed-rbac command for system roles 2026-06-19 17:22:42 +02:00
Matthieu 48c67a5fb9 feat(core) : expose role and user-rbac api endpoints with processors 2026-06-19 17:16:38 +02:00
Matthieu 5060fb689b feat(core) : add permission voter and expose effective permissions on /api/me 2026-06-19 17:03:34 +02:00
Matthieu ac662e701b feat(core) : aggregate module permissions and add sync-permissions command 2026-06-19 17:00:14 +02:00
Matthieu ffed224979 feat(core) : add rbac role and permission entities with user relations 2026-06-19 16:56:07 +02:00
Matthieu fdc72573ea docs : add implementation plan for rbac fin (LST-57 / 1.2) 2026-06-19 16:47:04 +02:00
Matthieu 52de07ce23 docs : log LST-63 module core session learnings 2026-06-19 16:34:02 +02:00
Matthieu 117c2ff2e3 feat(core) : add core front layer with login and profile pages 2026-06-19 16:31:42 +02:00
Matthieu a98ea3df37 feat(core) : activate core module in modules registry 2026-06-19 16:27:10 +02:00
Matthieu f1a9b42930 feat(core) : move notification into core and expose notifier contract 2026-06-19 16:25:03 +02:00
Matthieu 0b4874e94d refactor(core) : move user repository/providers to core and migrate all consumers off App\Entity\User 2026-06-19 16:16:44 +02:00
Matthieu d70925b812 refactor(core) : point user relations to the shared contract via resolve_target_entities 2026-06-19 16:04:14 +02:00
Matthieu f8fc4d6bd9 feat(core) : move user entity into core module and repoint security/doctrine (temp legacy alias) 2026-06-19 16:03:52 +02:00
Matthieu 6ca91cbd3b feat(core) : add CoreModule, user repository contract, notifier contract and enriched user contract 2026-06-19 15:53:38 +02:00
Matthieu 8865bf51e6 docs : add implementation plan for module core (LST-63 / 1.1) 2026-06-19 15:50:32 +02:00
129 changed files with 7280 additions and 201 deletions
@@ -93,6 +93,51 @@
- Ticket laissé en **"En attente de validation" (4)**, pas "Terminé" : smoke visuel front (dev server + navigateur) et sign-off du **délta cosmétique d'ordre de sidebar** (décision 3 du plan) relèvent du PO. Implémentation + AC API validés.
- Time-tracking 100 % sur la session principale cette fois (consigne des sous-agents : ne jamais toucher aux outils `mcp__lesstime__*`) — respecté.
## Session 2026-06-19 (LST-63 / 1.1 — Module Core : identité User/Auth/JWT + Notifications + layer front)
### Contexte
- Plan TDD dédié (`docs/superpowers/plans/2026-06-19-lst-63-module-core.md`, 7 tasks / 6 phases A→F). Exécution : Phases A/B (1 sous-agent combiné), C (1 sous-agent), D (1 sous-agent), E + F faites en direct par la session principale (tâches courtes). Pilotage chrono/MCP/vérif + re-vérif login après chaque phase touchant l'auth sur la session principale.
- 5 commits impl (`6ca91cb` A, `f8fc4d6`+`d70925b` B, `0b4874e` C, `f1a9b42` D, `a98ea3d` E, `117c2ff` F) + plan `8865bf5`. Tests : 110→120 verts. Timer impl 1012 = 43 min.
### Patterns
- **Move d'entité « strangler » sans migration** : `git mv` `src/Entity/User.php``src/Module/Core/Domain/Entity/User.php` (table + colonnes + backticks VERBATIM) ; mapping Doctrine `Core` ajouté (dir `src/Module/Core/Domain/Entity`, prefix `App\Module\Core\Domain\Entity`) à côté de `App` ; `resolve_target_entities: UserInterface → Core\User`. `migrations:diff` reste vide (hors dérive préexistante `messenger_messages`) → AUCUNE migration. Idem Notification en Phase D.
- **Alias temporaire pour découpler le move des relations** : Phase B pose un `class_alias(App\Entity\User::class → Core\User)` (fichier `_compat_user_alias.php` en `autoload.files`, exclu de l'autowiring `App\:` via `exclude` services.yaml + `notPath` php-cs-fixer). Permet de relier d'abord les 8 relations d'entités au CONTRAT `UserInterface::class` (resolver propre) ; l'alias n'est qu'un pont de type-hint PHP. Phase C retire l'alias EN DERNIER, seulement quand `grep App\Entity\User` est vide.
- **Règle contrat-vs-concret pour migrer les consommateurs** (Phase C, ~50 fichiers) : type-hint `App\Shared\Domain\Contract\UserInterface` si le fichier n'appelle que les méthodes de lecture du contrat / instanceof / type DQL ; FQCN concret `App\Module\Core\Domain\Entity\User` si besoin de getters HR, `apiToken`, `avatarFileName`, setters, `new User()`. Les deux éliminent `App\Entity\User`. Collision de nom avec `Symfony\...\UserInterface` → aliaser en `SharedUserInterface`.
- **Notifier (Phase D)** : `NotifierInterface` (Shared) = API publique inter-modules ; impl `Notifier` (Core) persiste + flush. `TaskNotificationListener` appelle `notify()` UNIQUEMENT en `postFlush` (jamais `onFlush` — le flush interne y est dangereux). Comportement identique conservé.
- **Layer front d'un module (Phase F)** : `frontend/modules/core/nuxt.config.ts` (`export default defineNuxtConfig({})`) + `git mv` des pages d'identité sous `modules/core/pages/`. Les imports `~/...` (alias srcDir) survivent au déplacement ; seuls les imports relatifs/par chemin casseraient. Les URLs (`/login`, `/profile`) restent identiques (fusion auto des `pages/` de layers).
### Gotchas
- **`admin.vue` = shell admin MULTI-domaines** (onglets clients/workflows/efforts/gitea/zimbra/mail/absences + 1 onglet `AdminUserTab`) : NE PAS le déplacer entier dans Core (il porterait les admins d'autres modules pas encore extraits). Conformément au plan, en cas de doute on déplace seulement login + profile, on documente. La décomposition de `admin.vue` viendra avec les modules respectifs.
- **Vérifier la résolution des routes d'un layer Nuxt en SPA** : `ssr:false` → le dev server renvoie 200 pour N'IMPORTE QUEL chemin (shell SPA, routing client) — un `curl /login` = 200 ne prouve RIEN (testé : `/route-bidon-xyz` = 200 aussi). `nuxt prepare` ne génère pas le manifeste de routes. **Preuve déterministe** = `npx nuxt build` puis `grep 'name:"login"\|name:"profile"' .output/server/chunks/build/client.precomputed.mjs` (+ chunk CSS `profile.*.css` généré). Ne pas perturber un dev server déjà lancé (config `extends`/`imports.dirs` figée au démarrage avant création du layer) → lancer un dev frais sur un port libre pour smoke.
- **Aligner le contrat sur la réalité de l'entité, pas l'inverse** : `User::getUsername()` est `?string` (pas `string`) et la méthode réelle est `getIsEmployee(): bool` (pas `isEmployee()`). Le plan écrivait `isEmployee()` — le contrat existant était déjà correct, aucun changement. Toujours lire l'entité avant de figer une signature de contrat.
- **Tests fonctionnels qui persistent réellement** (pas de rollback transactionnel ici) : un `NotifierTest` qui crée une notif échoue au 2e run (`2 != 1`) → rendre les données uniques (`uniqid()` sur le titre) pour l'idempotence.
## Session 2026-06-19 (LST-57 / 1.2 — RBAC fin : portage Starseed)
### Contexte
- Plan TDD dédié (`docs/superpowers/plans/2026-06-19-lst-57-rbac-fin.md`, 7 phases A→G). Source de vérité = **implémentation RBAC de Starseed** (le brief attaché au ticket était inaccessible en local — fichier non synchronisé sur le stockage ; cartographié via un agent Explore sur `/home/matthieu/dev_malio/Starseed`). 1 sous-agent par phase, pilotage chrono/MCP/vérif/push sur la session principale.
- 7 commits impl (A `ffed224`, B `ac662e7`, C `5060fb6`, D `48c67a5`, E `1a9eba9`, F `544d4cf`, G `511353c`) + plan `fdc7257`. Tests 131→**147 verts**. Timer impl 1014.
### Décision d'architecture majeure (actée, à valider PO)
- **RBAC additif, `ROLE_ADMIN` = bypass, PAS de colonne `is_admin`** — divergence assumée vs Starseed (qui a supprimé la colonne JSON `roles` au profit de `is_admin`). Lesstime garde `roles` JSON + `getRoles()` (login/JWT/MCP/sidebar #62 reposent dessus) ; le `PermissionVoter` bypass si `in_array('ROLE_ADMIN', $user->getRoles())`. Réécrire l'auth aurait été une régression à haut risque pour zéro bénéfice AC. Migration future vers `is_admin` possible.
### Patterns
- **RBAC = Role + Permission (M2M) + relations User** : `Role`(code snake_case immuable, label, description, isSystem, ManyToMany permissions EAGER), `Permission`(code `module.resource.action` unique, label, module, orphan), `User` reçoit `rbacRoles` (table `user_role`) + `directPermissions` (table `user_permission`), `getEffectivePermissions()` = union triée dédupliquée. Migration **100% additive** (5 CREATE TABLE, zéro DROP/ALTER sur `user`).
- **Permissions déclaratives par module** : `ModuleInterface::permissions(): list<array{code,label}>`, agrégées par `ModuleRegistry::permissions($activeClasses)` (injecte `module=id()`, valide le préfixe). `app:sync-permissions` upsert (revive orphan / updateMetadata / create) + markOrphan des absentes. `app:seed-rbac` seede les rôles système (`admin`/`user`, isSystem) — **sans matrice métier** tant qu'aucune permission métier n'existe (les modules 2.x ajouteront leurs permissions + rôles).
- **Voter pur + bypass applicatif** : `PermissionVoter` (regex `/^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)+$/` pour `supports`, donc abstient sur `ROLE_*`/`IS_AUTHENTICATED_*`). Le bypass admin de la **sidebar** est dans `SidebarProvider` (si ROLE_ADMIN → injecte le catalogue complet `ModuleRegistry::permissions()`), pas dans `SidebarFilter` qui reste un filtre pur (`permissionSatisfied()`). Le seed n'attachant aucune permission, sans ce bypass l'admin ne verrait rien.
- **Front** : `usePermissions()` (`can/canAny/canAll/isAdmin`) dans `modules/core/composables/` (auto-importé) ; type `UserData` enrichi de `effectivePermissions` ; onglet `AdminRoleTab`+`RoleDrawer` dans `frontend/components/admin/` (le scan `components` Nuxt ne couvre que `~/components`, PAS les layers `modules/*` → les composants vont dans `components/`, le composable/services dans `modules/core/`).
### Gotchas
- **`Symfony\Component\Serializer\Annotation\Groups` N'EXISTE PLUS en Symfony 8** — seul `Attribute\Groups` existe. Un import `Annotation\Groups` rend tous les `#[Groups]` **no-op silencieux** (sérialisation cassée, POST en 400 car le constructeur n'est pas alimenté). Bug latent introduit en Phase A, révélé seulement par les tests fonctionnels de Phase D (TDD). Toujours utiliser `Attribute\Groups`. Vérifier la cohérence sur TOUTES les entités.
- **`isSystem` exposé sous la clé `system`** : PropertyInfo strippe le préfixe `is`. Mettre `#[Groups]` + `#[SerializedName('isSystem')]` sur le getter pour conserver `isSystem` côté API.
- **`options: ['comment' => ...]` sur les colonnes des entités** : sans le mapping `options.comment`, les `COMMENT ON COLUMN` de la migration créent une dérive `migrations:diff` perpétuelle (Doctrine veut les remettre à `''`). Aligner le mapping entité sur le COMMENT de la migration.
- **`make db-reset` détruit `lesstime_test`** (`docker compose down -v` supprime le volume) — les tests tournent sur la base suffixée `_test`. Après un db-reset, recréer la base de test : `doctrine:database:create --env=test --if-not-exists` + `migrations:migrate -n --env=test` + `fixtures:load -n --env=test`. Ne jamais lancer `make db-reset` depuis un sous-agent de phase.
- **Signature `Voter::voteOnAttribute`** : la version Symfony installée impose `voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token, ?Vote $vote = null): bool` (4e param). Sans lui : « Declaration must be compatible » fatal.
### MR / Git
- **MR empilées sur Gitea** (`tea pr create --base <branche-précédente>`) reflètent la chaîne de dépendances (#56→develop, #62#56, #63#62, #57#63) avec des diffs propres ; Gitea re-cible la base à chaque merge. `tea pr` n'a pas d'`edit` → pour sortir une MR du brouillon (retrait `WIP:`), PATCH API Gitea `/repos/{o}/{r}/pulls/{n}` avec le token de `~/.config/tea/config.yml`.
- **WIP en cours** : pousser la branche d'un ticket en cours + ouvrir la MR en brouillon (titre `WIP:`) sauvegarde le travail sans signaler « prêt à merger » ; re-pousser à chaque phase. Le push ne lock pas l'index → aucune contention avec un sous-agent qui committe en parallèle.
## Meta-learnings
- **Parallélisation**: Les tickets touchant des fichiers indépendants peuvent tourner en parallèle sans problème
- **Commits concurrents**: NE PAS lancer deux sous-agents qui committent sur le même repo en parallèle (collision `.git/index.lock`) — séquencer.
@@ -100,3 +145,25 @@
- **MCP status**: Toujours mettre "En cours" AVANT de commencer, "Terminé" APRÈS validation
- **PostgreSQL gotchas**: Tester les queries SQL avec agrégation + locking sur PostgreSQL, pas MySQL
- **Agents**: Les agents simples (1-3 fichiers) terminent en ~30s, les complexes (22 fichiers) en ~8min
## Session 2026-06-19 (LST-61 / 1.3 — Audit log : #[Auditable], audit_log, AuditListener, resource)
### Contexte
- Plan TDD dédié (`docs/superpowers/plans/2026-06-19-lst-61-audit-log.md`, Tasks A→F). Exécution : 1 sous-agent par task (A, B, C, D, E) en séquence, vérif + smoke par la session principale entre chaque ; Task F (validation finale + correctif front + learnings + push + statut) en direct.
- Infra portée VERBATIM depuis Starseed (réf canonique `/home/matthieu/dev_malio/Starseed`) : `AuditListener` byte-identique (`diff -q` OK), + 6 fichiers API (DTO/paginator/providers/resources) copiés tels quels — namespaces `App\Module\Core\...` et `App\Shared\Domain\Attribute\...` DÉJÀ alignés entre les deux projets, zéro adaptation.
- 6 commits impl (`934cf08` A, `d8553f0` B, `8c3699a` C, `90b8ca1` D, `e7af415` E, `9b26b43` fix front) + plan `fda03bd`. Tests : 147→157 verts. Branche `feat/lst-61-audit-log` empilée sur `feat/lst-57-rbac-fin`.
### Patterns
- **Audit en 4 couches additives** : (1) marquage déclaratif `#[Auditable]`(TARGET_CLASS) / `#[AuditIgnore]`(TARGET_PROPERTY) dans `src/Shared/Domain/Attribute/` (Shared, pas Core → aucun module n'a de dépendance circulaire) ; (2) capture `AuditListener` Doctrine sur `onFlush` (lit `UnitOfWork` : insertions/updates/deletions + `getScheduledCollectionUpdates/Deletions` pour le M2M) puis `postFlush` (écrit, swap-and-clear anti-réentrance) ; (3) écriture `AuditLogWriter` sur connexion DBAL dédiée `audit` (hors transaction ORM → survit aux rollbacks) ; (4) lecture `AuditLogProvider` DBAL (pas d'entité ORM) + `DbalPaginator implements PaginatorInterface` (API Platform génère `hydra:view` seul).
- **Connexion DBAL dédiée + `schema_filter`** : restructurer `doctrine.yaml` de connexion unique → `connections: {default, audit}` (même DSN), `default_connection: default`, `schema_filter: '~^(?!audit_log$).+~'` sur `default` (la table n'a PAS d'entité → exclue de `migrations:diff`/`schema:validate`). Le bloc `orm` reste INCHANGÉ (l'EM par défaut se lie à `default_connection`). En `when@test`, propager `dbname_suffix` aux DEUX connexions (sinon `audit` écrit en base dev pendant que l'ORM écrit en test).
- **Table append-only hors ORM** : créée par migration manuelle (squelette via `doctrine:migrations:generate` puis contenu écrit à la main — JAMAIS `migrations:diff`, qui ne voit pas la table). `id uuid` natif PG, `changes JSONB`, `performed_at TIMESTAMP(6) WITH TIME ZONE`. UUID v7 (writer, tri monotone) / v4 (requestId par requête HTTP). `entity_type` au format `module.Entity` (regex `App\Module\<module>\...\<Entity>``core.User`).
- **Marquage scope = entités migrées** : `#[Auditable]` posé sur User/Role/Permission (Core) uniquement ; `#[AuditIgnore]` sur `User.password` ET `User.apiToken` (Lesstime n'a pas de `plainPassword`). Défense en profondeur : `AuditLogWriter::SENSITIVE_KEYS` strippe aussi `password/plainPassword/apiToken/token/secret`. Les entités métier legacy (`src/Entity/*`) seront marquées à leur migration en modules (2.x).
### Gotchas
- **Tests fonctionnels Lesstime SANS rollback transactionnel** (pas de DAMADoctrineTestBundle) : les entités persistées survivent d'un run à l'autre → violation d'unicité `username`. Convention projet : `uniqid()` OU nettoyage explicite en `setUp()` (`DELETE FROM "user" WHERE username LIKE 'audit\_%'`). Les données d'audit de test se seedent directement via `doctrine.dbal.audit_connection` (DELETE + inserts UUID v7) pour du déterministe.
- **`migrations:diff` génère un fichier jetable** même quand on ne veut que vérifier : toujours supprimer le `Version<ts>.php` non suivi créé après un diff de contrôle (`git ls-files --others migrations/`). Une dérive préexistante `messenger_messages` (DROP) pollue le diff — sans rapport, ne pas committer.
- **`/audit-log-entity-types` = ressource item unique, pas une collection** : `Get` API Platform avec `uriTemplate` fixe sans `{id}` → renvoie `{ entityTypes: string[] }` (PAS d'enveloppe hydra `member`). Le service front ne doit PAS passer par `extractHydraMembers` ici (bug livré par le sous-agent E, corrigé en `9b26b43`). `/audit-logs` en revanche est bien une collection paginée hydra.
- **Login en curl = `/login_check` (POST), pas `/api/login`** ; le JWT json_login est capricieux en curl pur (405/cookie). La preuve d'auth faisant autorité reste le test fonctionnel (client `loginUser()`), pas un smoke curl.
### Time-tracking / orchestration
- **Interdire explicitement aux sous-agents de toucher au MCP lesstime** (timer + statut ticket) : un sous-agent a spontanément créé/stoppé une time entry (1016) alors que le chrono est piloté par la session principale. Ajouter la consigne « NE TOUCHE PAS au time-tracking » dans chaque prompt de sous-agent. Pas de conflit ici (il avait stoppé l'actif avant), mais découpage involontaire.
+4 -1
View File
@@ -6,6 +6,9 @@ declare(strict_types=1);
* Liste ordonnée des modules actifs (classes implémentant App\Shared\Domain\Module\ModuleInterface).
* Activer/désactiver un module = ajouter/commenter sa ligne. Exposé par GET /api/modules.
*/
use App\Module\Core\CoreModule;
return [
// Aucun module pour l'instant — les modules arrivent à partir du ticket 1.1 (Core).
CoreModule::class,
];
+27 -10
View File
@@ -1,12 +1,19 @@
doctrine:
dbal:
url: '%env(resolve:DATABASE_URL)%'
# IMPORTANT: You MUST configure your server version,
# either here or in the DATABASE_URL env var (see .env file)
#server_version: '16'
profiling_collect_backtrace: '%kernel.debug%'
default_connection: default
connections:
# ORM uses `default`; AuditLogWriter uses `audit` (same DSN, separate
# service) to write outside the ORM transaction so audit rows survive
# an application-side rollback and avoid transactional entanglement.
default:
url: '%env(resolve:DATABASE_URL)%'
profiling_collect_backtrace: '%kernel.debug%'
# audit_log has no ORM entity (written via raw DBAL). Exclude it
# from schema comparison so migrations:diff / schema:validate stay
# clean. Creation/teardown stay driven by migrations.
schema_filter: '~^(?!audit_log$).+~'
audit:
url: '%env(resolve:DATABASE_URL)%'
orm:
validate_xml_mapping: true
naming_strategy: doctrine.orm.naming_strategy.underscore_number_aware
@@ -14,7 +21,7 @@ doctrine:
Doctrine\DBAL\Platforms\PostgreSQLPlatform: identity
auto_mapping: true
resolve_target_entities:
App\Shared\Domain\Contract\UserInterface: App\Entity\User
App\Shared\Domain\Contract\UserInterface: App\Module\Core\Domain\Entity\User
mappings:
App:
type: attribute
@@ -22,14 +29,24 @@ doctrine:
dir: '%kernel.project_dir%/src/Entity'
prefix: 'App\Entity'
alias: App
Core:
type: attribute
is_bundle: false
dir: '%kernel.project_dir%/src/Module/Core/Domain/Entity'
prefix: 'App\Module\Core\Domain\Entity'
controller_resolver:
auto_mapping: false
when@test:
doctrine:
dbal:
# "TEST_TOKEN" is typically set by ParaTest
dbname_suffix: '_test%env(default::TEST_TOKEN)%'
# Propagate the _test suffix to BOTH connections: the audit
# connection must write to the test DB, not the dev DB.
connections:
default:
dbname_suffix: '_test%env(default::TEST_TOKEN)%'
audit:
dbname_suffix: '_test%env(default::TEST_TOKEN)%'
when@prod:
doctrine:
+1 -1
View File
@@ -10,7 +10,7 @@ security:
providers:
app_user_provider:
entity:
class: App\Entity\User
class: App\Module\Core\Domain\Entity\User
property: username
firewalls:
+8
View File
@@ -66,3 +66,11 @@ services:
$uploadDir: '%absence_justification_upload_dir%'
App\Service\Share\FileSource: '@App\Service\Share\SmbFileSource'
App\Module\Core\Domain\Repository\UserRepositoryInterface: '@App\Module\Core\Infrastructure\Doctrine\DoctrineUserRepository'
App\Module\Core\Domain\Repository\PermissionRepositoryInterface: '@App\Module\Core\Infrastructure\Doctrine\DoctrinePermissionRepository'
App\Module\Core\Domain\Repository\RoleRepositoryInterface: '@App\Module\Core\Infrastructure\Doctrine\DoctrineRoleRepository'
App\Shared\Domain\Contract\NotifierInterface: '@App\Module\Core\Infrastructure\Notifier'
+7 -4
View File
@@ -4,9 +4,12 @@ declare(strict_types=1);
/*
* Définition de la sidebar (sections + items) — navigation GLOBALE uniquement.
* Filtrée par SidebarFilter : `module` (route ajoutée à disabledRoutes si module inactif),
* `roles` (section ou item masqué si l'utilisateur n'a aucun des rôles listés ; gate minimal,
* le RBAC fin par permission arrive en #1.2).
* Filtrée par SidebarFilter :
* - `module` : route ajoutée à disabledRoutes si module inactif ;
* - `roles` : section ou item masqué si l'utilisateur n'a aucun des rôles listés (gate minimal) ;
* - `permission` : section ou item masqué si la permission effective absente (RBAC fin —
* `User::getEffectivePermissions()` ; ROLE_ADMIN bypasse via le voter, mais la
* sidebar évalue les permissions effectives réelles — combiner avec `roles` au besoin).
* Les items contextuels (Kanban/Groupes/Archives), feature-flag (Documents, Mail) et user-flag
* (Mes absences) restent rendus côté layout, hors de cet endpoint.
* Les labels sont des clés i18n (sidebar.<domaine>.<item>).
@@ -28,7 +31,7 @@ return [
'roles' => ['ROLE_ADMIN'],
'items' => [
['label' => 'sidebar.admin.teamAbsences', 'to' => '/team-absences', 'icon' => 'mdi:calendar-account-outline'],
['label' => 'sidebar.admin.administration', 'to' => '/admin', 'icon' => 'mdi:cog-outline'],
['label' => 'sidebar.admin.administration', 'to' => '/admin', 'icon' => 'mdi:cog-outline', 'permission' => 'core.users.view'],
],
],
];
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,706 @@
# LST-61 (1.3) · Audit log — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Porter l'infrastructure d'audit de Starseed dans Lesstime : tracer create/update/delete des entités `#[Auditable]` dans une table append-only `audit_log`, exposée en lecture seule via `GET /api/audit-logs` (paginé + filtrable), avec une page de consultation front gated RBAC.
**Architecture:** 4 couches indépendantes, additives (strangler) — (1) **marquage** déclaratif `#[Auditable]`/`#[AuditIgnore]` dans `src/Shared/Domain/Attribute/` ; (2) **capture** par un `AuditListener` Doctrine sur `onFlush`/`postFlush` (capture en mémoire puis écriture déphasée) ; (3) **écriture** via `AuditLogWriter` sur une connexion DBAL dédiée `audit` (hors transaction ORM, survit aux rollbacks) ; (4) **lecture API** via `AuditLogProvider` DBAL (pas d'entité ORM) + `DbalPaginator`. Front Nuxt : service + page consultation gated `core.audit_log.view`.
**Tech Stack:** Symfony 8, API Platform 4, Doctrine ORM/DBAL, PostgreSQL 16, PHP 8.4, PHPUnit, symfony/uid (vendoré), Nuxt 4 / Vue 3 / Pinia / @nuxtjs/i18n.
## Global Constraints
- **Aucune mention de Claude/Anthropic/IA** dans les écritures Git (commits, trailers, descriptions MR, merge). Messages factuels et techniques.
- **Additif uniquement** : aucune migration destructive (pas de DROP/ALTER sur tables existantes en `up()`).
- **PostgreSQL** : noms de colonnes toujours en minuscules snake_case dans le SQL brut.
- **Code** : `declare(strict_types=1)`, PSR-12, patterns API Platform / Doctrine existants. Variables et commentaires en anglais.
- **`config/reference.php`** auto-généré — NE JAMAIS committer.
- Toujours lire un fichier avant de le modifier ; reproduire le style existant.
- Branche : `feat/lst-61-audit-log` (empilée sur `feat/lst-57-rbac-fin`).
- Tests Docker : `docker exec -t -u www-data php-lesstime-fpm php vendor/bin/phpunit`.
---
## File Structure
**Créés :**
- `src/Shared/Domain/Attribute/Auditable.php` — marqueur classe
- `src/Shared/Domain/Attribute/AuditIgnore.php` — marqueur propriété
- `src/Module/Core/Infrastructure/Audit/AuditLogWriter.php` — écriture DBAL `audit`
- `src/Module/Core/Infrastructure/Audit/RequestIdProvider.php` — UUID par requête
- `src/Module/Core/Infrastructure/Doctrine/AuditListener.php` — capture onFlush/postFlush
- `src/Module/Core/Infrastructure/ApiPlatform/Resource/AuditLogResource.php`
- `src/Module/Core/Infrastructure/ApiPlatform/Resource/AuditLogEntityTypesResource.php`
- `src/Module/Core/Application/DTO/AuditLogOutput.php`
- `src/Module/Core/Infrastructure/ApiPlatform/State/Provider/AuditLogProvider.php`
- `src/Module/Core/Infrastructure/ApiPlatform/State/Provider/AuditLogEntityTypesProvider.php`
- `src/Module/Core/Infrastructure/ApiPlatform/Pagination/DbalPaginator.php`
- `migrations/Version20260619XXXXXX.php` — table `audit_log`
- `tests/Functional/Module/Core/AuditListenerTest.php`
- `tests/Functional/Module/Core/AuditLogApiTest.php`
- `frontend/modules/core/services/audit-logs.ts`
- `frontend/components/admin/AdminAuditTab.vue`
**Modifiés :**
- `config/packages/doctrine.yaml` — connexion `audit` + `schema_filter` audit_log
- `src/Module/Core/CoreModule.php` — permission `core.audit_log.view`
- `src/Module/Core/Domain/Entity/User.php``#[Auditable]` + `#[AuditIgnore]` password/apiToken
- `src/Module/Core/Domain/Entity/Role.php``#[Auditable]`
- `src/Module/Core/Domain/Entity/Permission.php``#[Auditable]`
- `tests/Unit/Module/Core/CoreModuleTest.php` — assert nouvelle permission
- `frontend/pages/admin.vue` — onglet Audit gated `core.audit_log.view`
- `frontend/i18n/locales/fr.json` — clés `admin.audit.*` + `audit.entity.*`
---
## Task A: Marquage + table + connexion DBAL audit
**Files:**
- Create: `src/Shared/Domain/Attribute/Auditable.php`, `src/Shared/Domain/Attribute/AuditIgnore.php`
- Create: `migrations/Version20260619XXXXXX.php`
- Modify: `config/packages/doctrine.yaml`
**Interfaces produced:** `App\Shared\Domain\Attribute\Auditable` (TARGET_CLASS), `App\Shared\Domain\Attribute\AuditIgnore` (TARGET_PROPERTY) ; service DBAL `doctrine.dbal.audit_connection` ; table `audit_log`.
- [ ] **Step A1: Attributs** — créer les deux fichiers :
```php
<?php
// src/Shared/Domain/Attribute/Auditable.php
declare(strict_types=1);
namespace App\Shared\Domain\Attribute;
use Attribute;
/**
* Marker placed on a Doctrine entity to enable audit tracking.
*
* Located in Shared (not Core) so every module can use it without a
* circular dependency on Core. Any migrated business entity that should be
* traced carries this attribute, with #[AuditIgnore] on sensitive fields.
*/
#[Attribute(Attribute::TARGET_CLASS)]
final class Auditable
{
}
```
```php
<?php
// src/Shared/Domain/Attribute/AuditIgnore.php
declare(strict_types=1);
namespace App\Shared\Domain\Attribute;
use Attribute;
/**
* Marker placed on an entity property to exclude it from audit tracking.
*
* Typical use: sensitive fields (password, apiToken). The AuditLogWriter also
* carries an exact-match blacklist on the most dangerous names as
* defense-in-depth, but the base rule is to annotate explicitly here.
*/
#[Attribute(Attribute::TARGET_PROPERTY)]
final class AuditIgnore
{
}
```
- [ ] **Step A2: Migration** — créer `migrations/Version20260619XXXXXX.php` (timestamp réel via `php bin/console make:migration` puis remplacer le contenu, OU horodatage manuel cohérent > 20260619145109) :
```php
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Audit log (LST-61) : append-only `audit_log` table.
*
* Not managed by Doctrine ORM (no entity). Written via raw DBAL by the
* AuditLogWriter on a dedicated `audit` connection to avoid re-entrant
* flushes from the Doctrine listener. Columns are lowercase snake_case.
* Additive only — no DROP/ALTER on existing tables.
*/
final class Version20260619XXXXXX extends AbstractMigration
{
public function getDescription(): string
{
return 'Audit log: create append-only audit_log table + indexes (additive)';
}
public function up(Schema $schema): void
{
$this->addSql(<<<'SQL'
CREATE TABLE audit_log (
id uuid NOT NULL,
entity_type VARCHAR(100) NOT NULL,
entity_id VARCHAR(64) NOT NULL,
action VARCHAR(10) NOT NULL,
changes JSONB NOT NULL DEFAULT '{}'::jsonb,
performed_by VARCHAR(100) NOT NULL,
performed_at TIMESTAMP(6) WITH TIME ZONE NOT NULL,
ip_address VARCHAR(45) DEFAULT NULL,
request_id VARCHAR(36) DEFAULT NULL,
PRIMARY KEY(id)
)
SQL);
$this->addSql('CREATE INDEX idx_audit_entity_time ON audit_log (entity_type, entity_id, performed_at)');
$this->addSql('CREATE INDEX idx_audit_performer ON audit_log (performed_by, performed_at)');
$this->addSql('CREATE INDEX idx_audit_time ON audit_log (performed_at)');
$this->addSql("COMMENT ON COLUMN audit_log.entity_type IS 'Audited entity type, format module.Entity (e.g. core.User)'");
$this->addSql("COMMENT ON COLUMN audit_log.entity_id IS 'Audited entity identifier (int or composite key serialized)'");
$this->addSql("COMMENT ON COLUMN audit_log.action IS 'create|update|delete'");
$this->addSql("COMMENT ON COLUMN audit_log.changes IS 'JSON diff: {field:{old,new}} for update, full snapshot for create/delete'");
$this->addSql("COMMENT ON COLUMN audit_log.performed_by IS 'User identifier or system'");
$this->addSql("COMMENT ON COLUMN audit_log.request_id IS 'UUID shared by all audit rows of a single HTTP request (null in CLI)'");
}
public function down(Schema $schema): void
{
$this->addSql('DROP TABLE audit_log');
}
}
```
- [ ] **Step A3: Connexion DBAL `audit`** — restructurer `config/packages/doctrine.yaml`. Remplacer le bloc `dbal` racine (connexion unique) par des connexions nommées, et propager le `dbname_suffix` de test aux deux connexions. **Le bloc `orm` reste inchangé** (l'EM par défaut se lie à `default_connection`).
Remplacer :
```yaml
dbal:
url: '%env(resolve:DATABASE_URL)%'
# IMPORTANT: You MUST configure your server version,
# either here or in the DATABASE_URL env var (see .env file)
#server_version: '16'
profiling_collect_backtrace: '%kernel.debug%'
```
par :
```yaml
dbal:
default_connection: default
connections:
# ORM uses `default`; AuditLogWriter uses `audit` (same DSN, separate
# service) to write outside the ORM transaction so audit rows survive
# an application-side rollback and avoid transactional entanglement.
default:
url: '%env(resolve:DATABASE_URL)%'
profiling_collect_backtrace: '%kernel.debug%'
# audit_log has no ORM entity (written via raw DBAL). Exclude it
# from schema comparison so migrations:diff / schema:validate stay
# clean. Creation/teardown stay driven by migrations.
schema_filter: '~^(?!audit_log$).+~'
audit:
url: '%env(resolve:DATABASE_URL)%'
```
Et remplacer le bloc `when@test` :
```yaml
when@test:
doctrine:
dbal:
# "TEST_TOKEN" is typically set by ParaTest
dbname_suffix: '_test%env(default::TEST_TOKEN)%'
```
par :
```yaml
when@test:
doctrine:
dbal:
# Propagate the _test suffix to BOTH connections: the audit
# connection must write to the test DB, not the dev DB.
connections:
default:
dbname_suffix: '_test%env(default::TEST_TOKEN)%'
audit:
dbname_suffix: '_test%env(default::TEST_TOKEN)%'
```
- [ ] **Step A4: Vérifier la non-régression** — la restructuration des connexions est le point sensible. Lancer la suite existante :
```bash
docker exec -t -u www-data php-lesstime-fpm php vendor/bin/phpunit
```
Expected: 147 tests toujours verts (aucune régression liée au changement de connexions).
- [ ] **Step A5: Appliquer la migration (dev + test)** :
```bash
docker exec -t -u www-data php-lesstime-fpm php bin/console doctrine:migrations:migrate -n
docker exec -t -u www-data php-lesstime-fpm php bin/console doctrine:migrations:migrate -n --env=test
docker exec -t -u www-data php-lesstime-fpm php bin/console doctrine:migrations:diff --env=test 2>&1 | grep -i "audit_log" || echo "OK: audit_log absent du diff (schema_filter actif)"
```
Expected: table créée, `audit_log` absente de tout diff généré.
- [ ] **Step A6: Commit**
```bash
git add src/Shared/Domain/Attribute config/packages/doctrine.yaml migrations/
git commit -m "feat(core) : add audit attributes, audit_log table and dedicated dbal connection"
```
---
## Task B: AuditLogWriter + RequestIdProvider
**Files:**
- Create: `src/Module/Core/Infrastructure/Audit/AuditLogWriter.php`
- Create: `src/Module/Core/Infrastructure/Audit/RequestIdProvider.php`
**Interfaces produced:** `AuditLogWriter::log(string $entityType, string $entityId, string $action, array $changes): void` ; `RequestIdProvider::getRequestId(): ?string`.
- [ ] **Step B1: RequestIdProvider** (verbatim Starseed) :
```php
<?php
declare(strict_types=1);
namespace App\Module\Core\Infrastructure\Audit;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\Uid\Uuid;
/**
* Provides an HTTP request identifier (UUID v4) shared by every audit row
* produced during a single main request. Null in CLI (fixtures, batch).
*/
final class RequestIdProvider
{
private ?string $requestId = null;
#[AsEventListener(event: 'kernel.request')]
public function onKernelRequest(RequestEvent $event): void
{
if (!$event->isMainRequest()) {
return;
}
$this->requestId = Uuid::v4()->toRfc4122();
}
public function getRequestId(): ?string
{
return $this->requestId;
}
}
```
- [ ] **Step B2: AuditLogWriter** (verbatim Starseed, connexion `audit`) :
```php
<?php
declare(strict_types=1);
namespace App\Module\Core\Infrastructure\Audit;
use DateTimeImmutable;
use DateTimeZone;
use Doctrine\DBAL\Connection;
use Doctrine\DBAL\Types\Types;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\Uid\Uuid;
/**
* Low-level service responsible for writing into the `audit_log` table.
*
* Uses a dedicated `audit` DBAL connection (same DSN as `default`) to write
* outside the ORM transaction: audit rows survive an application-side
* rollback and avoid transactional entanglement in batch (fixtures).
*
* Sensitive keys are stripped in defense-in-depth even when entities already
* declare those properties #[AuditIgnore]. SQL failures are swallowed by the
* caller (AuditListener wraps log() in try/catch) — audit must never crash a
* business flow.
*/
final class AuditLogWriter
{
/** @var list<string> keys always stripped from the `changes` payload */
private const array SENSITIVE_KEYS = ['password', 'plainPassword', 'apiToken', 'token', 'secret'];
public function __construct(
#[Autowire(service: 'doctrine.dbal.audit_connection')]
private readonly Connection $connection,
private readonly Security $security,
private readonly RequestStack $requestStack,
private readonly RequestIdProvider $requestIdProvider,
) {
}
/**
* @param string $entityType Format "module.Entity" (e.g. "core.User")
* @param string $entityId Entity id (int or serialized UUID)
* @param string $action create|update|delete
* @param array<string, mixed> $changes JSON payload (sensitive keys stripped)
*/
public function log(
string $entityType,
string $entityId,
string $action,
array $changes,
): void {
$filteredChanges = $this->stripSensitive($changes);
$this->connection->insert('audit_log', [
'id' => Uuid::v7()->toRfc4122(),
'entity_type' => $entityType,
'entity_id' => $entityId,
'action' => $action,
'changes' => $filteredChanges,
'performed_by' => $this->security->getUser()?->getUserIdentifier() ?? 'system',
'performed_at' => new DateTimeImmutable('now', new DateTimeZone('UTC')),
'ip_address' => $this->requestStack->getCurrentRequest()?->getClientIp(),
'request_id' => $this->requestIdProvider->getRequestId(),
], [
'id' => Types::GUID,
'changes' => Types::JSON,
'performed_at' => Types::DATETIMETZ_IMMUTABLE,
]);
}
/**
* Recursively removes sensitive keys from the payload.
*
* @param array<string, mixed> $data
*
* @return array<string, mixed>
*/
private function stripSensitive(array $data): array
{
foreach ($data as $key => $value) {
if (in_array($key, self::SENSITIVE_KEYS, true)) {
unset($data[$key]);
continue;
}
if (is_array($value)) {
$data[$key] = $this->stripSensitive($value);
}
}
return $data;
}
}
```
- [ ] **Step B3: Vérifier le câblage** (autowiring) :
```bash
docker exec -t -u www-data php-lesstime-fpm php bin/console debug:container App\\Module\\Core\\Infrastructure\\Audit\\AuditLogWriter 2>&1 | head -20
```
Expected: service trouvé, injection `doctrine.dbal.audit_connection` résolue.
- [ ] **Step B4: Commit**
```bash
git add src/Module/Core/Infrastructure/Audit/
git commit -m "feat(core) : add audit log writer and request id provider"
```
---
## Task C: AuditListener + marquage des entités Core
**Files:**
- Create: `src/Module/Core/Infrastructure/Doctrine/AuditListener.php`
- Modify: `src/Module/Core/Domain/Entity/User.php`, `Role.php`, `Permission.php`
- Test: `tests/Functional/Module/Core/AuditListenerTest.php`
**Interfaces consumed:** `AuditLogWriter`, attributs `Auditable`/`AuditIgnore`.
- [ ] **Step C1: Écrire le test fonctionnel (échec attendu)**`tests/Functional/Module/Core/AuditListenerTest.php`. Le test crée/modifie/supprime un User via l'EntityManager dans le kernel de test, puis lit `audit_log` via la connexion `audit`. (S'inspirer du style des tests fonctionnels existants — `RoleApiTest`, `UserRbacApiTest`.)
```php
<?php
declare(strict_types=1);
namespace App\Tests\Functional\Module\Core;
use App\Module\Core\Domain\Entity\User;
use Doctrine\DBAL\Connection;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
/**
* @internal
*/
final class AuditListenerTest extends KernelTestCase
{
private EntityManagerInterface $em;
private Connection $auditConnection;
protected function setUp(): void
{
self::bootKernel();
$container = self::getContainer();
$this->em = $container->get(EntityManagerInterface::class);
$this->auditConnection = $container->get('doctrine.dbal.audit_connection');
// Clean slate for deterministic assertions.
$this->auditConnection->executeStatement('DELETE FROM audit_log');
}
public function testCreateUserIsAudited(): void
{
$user = $this->makeUser('audit_create_user');
$this->em->persist($user);
$this->em->flush();
$rows = $this->fetchLogs('core.User', (string) $user->getId());
self::assertCount(1, $rows);
self::assertSame('create', $rows[0]['action']);
$changes = json_decode((string) $rows[0]['changes'], true);
self::assertArrayHasKey('username', $changes);
self::assertArrayNotHasKey('password', $changes, 'password must be excluded via #[AuditIgnore]');
self::assertArrayNotHasKey('apiToken', $changes, 'apiToken must be excluded via #[AuditIgnore]');
}
public function testUpdateUserIsAuditedWithDiff(): void
{
$user = $this->makeUser('audit_update_user');
$this->em->persist($user);
$this->em->flush();
$this->auditConnection->executeStatement('DELETE FROM audit_log');
$user->setFirstName('Changed');
$this->em->flush();
$rows = $this->fetchLogs('core.User', (string) $user->getId());
self::assertCount(1, $rows);
self::assertSame('update', $rows[0]['action']);
$changes = json_decode((string) $rows[0]['changes'], true);
self::assertArrayHasKey('firstName', $changes);
self::assertSame('Changed', $changes['firstName']['new']);
}
public function testDeleteUserIsAudited(): void
{
$user = $this->makeUser('audit_delete_user');
$this->em->persist($user);
$this->em->flush();
$id = (string) $user->getId();
$this->auditConnection->executeStatement('DELETE FROM audit_log');
$this->em->remove($user);
$this->em->flush();
$rows = $this->fetchLogs('core.User', $id);
self::assertCount(1, $rows);
self::assertSame('delete', $rows[0]['action']);
}
private function makeUser(string $username): User
{
$user = new User();
$user->setUsername($username);
$user->setPassword('hashed-secret');
$user->setRoles(['ROLE_USER']);
return $user;
}
/**
* @return list<array<string, mixed>>
*/
private function fetchLogs(string $entityType, string $entityId): array
{
return $this->auditConnection->fetchAllAssociative(
'SELECT action, changes FROM audit_log WHERE entity_type = :t AND entity_id = :id ORDER BY performed_at ASC',
['t' => $entityType, 'id' => $entityId],
);
}
protected function tearDown(): void
{
parent::tearDown();
unset($this->em, $this->auditConnection);
}
}
```
> **Note adaptation :** vérifier la signature réelle de `User` (setters disponibles : `setUsername`, `setPassword`, `setRoles`, `setFirstName`). Ajuster `makeUser()` aux champs NOT NULL réels de la table `user`. Si `User` exige d'autres champs obligatoires (ex. `createdAt` initialisé au constructeur — déjà le cas), ne rien ajouter.
- [ ] **Step C2: Run le test → échec** (listener absent, entités non marquées) :
```bash
docker exec -t -u www-data php-lesstime-fpm php vendor/bin/phpunit tests/Functional/Module/Core/AuditListenerTest.php
```
Expected: FAIL.
- [ ] **Step C3: Créer `AuditListener`** (verbatim Starseed, namespace `App\Module\Core\Infrastructure\Doctrine`). Copier intégralement le listener fourni dans le rapport Starseed (onFlush capture + postFlush écriture, swap-and-clear, gestion collections, snapshot create/delete, buildUpdateChanges, formatEntityType regex `App\Module\<module>\...\<Entity>`, caches Auditable/AuditIgnore). **Ne rien simplifier.**
- [ ] **Step C4: Marquer les entités Core.**
`src/Module/Core/Domain/Entity/User.php` — ajouter import + attribut classe + `#[AuditIgnore]` sur `password` et `apiToken` :
```php
use App\Shared\Domain\Attribute\Auditable;
use App\Shared\Domain\Attribute\AuditIgnore;
```
```php
#[Auditable]
#[ORM\Entity(repositoryClass: DoctrineUserRepository::class)]
#[ORM\Table(name: '`user`')]
class User implements ...
```
Sur la propriété `password` (ligne ~89-90) et `apiToken` (ligne ~99-100), ajouter `#[AuditIgnore]` au-dessus de la ligne `private ?string $password = null;` / `private ?string $apiToken = null;`.
`src/Module/Core/Domain/Entity/Role.php` — ajouter `use App\Shared\Domain\Attribute\Auditable;` et `#[Auditable]` au-dessus de `#[ORM\Entity...]`.
`src/Module/Core/Domain/Entity/Permission.php` — idem `#[Auditable]`.
- [ ] **Step C5: Run le test → succès** :
```bash
docker exec -t -u www-data php-lesstime-fpm php vendor/bin/phpunit tests/Functional/Module/Core/AuditListenerTest.php
```
Expected: PASS (3 tests).
- [ ] **Step C6: Suite complète + cs-fixer** :
```bash
docker exec -t -u www-data php-lesstime-fpm php vendor/bin/phpunit
make php-cs-fixer-allow-risky
```
Expected: tout vert.
- [ ] **Step C7: Commit**
```bash
git add src/Module/Core/Infrastructure/Doctrine/AuditListener.php src/Module/Core/Domain/Entity/ tests/Functional/Module/Core/AuditListenerTest.php
git commit -m "feat(core) : add doctrine audit listener and mark core entities auditable"
```
---
## Task D: API de lecture `/api/audit-logs` + permission
**Files:**
- Create: `AuditLogOutput.php`, `DbalPaginator.php`, `AuditLogProvider.php`, `AuditLogResource.php`, `AuditLogEntityTypesResource.php`, `AuditLogEntityTypesProvider.php`
- Modify: `src/Module/Core/CoreModule.php` (permission), `tests/Unit/Module/Core/CoreModuleTest.php`
- Test: `tests/Functional/Module/Core/AuditLogApiTest.php`
**Interfaces consumed:** table `audit_log`, connexion `doctrine.dbal.default_connection`, permission `core.audit_log.view`.
- [ ] **Step D1: Permission** — ajouter dans `CoreModule::permissions()` :
```php
['code' => 'core.audit_log.view', 'label' => 'Consulter le journal d\'audit'],
```
Mettre à jour `tests/Unit/Module/Core/CoreModuleTest.php` pour asserter la présence de ce code (la liste passe à 6 permissions).
- [ ] **Step D2: DTO + Paginator + Providers + Resources** — créer les 6 fichiers verbatim depuis le rapport Starseed :
- `src/Module/Core/Application/DTO/AuditLogOutput.php`
- `src/Module/Core/Infrastructure/ApiPlatform/Pagination/DbalPaginator.php`
- `src/Module/Core/Infrastructure/ApiPlatform/State/Provider/AuditLogProvider.php`
- `src/Module/Core/Infrastructure/ApiPlatform/State/Provider/AuditLogEntityTypesProvider.php`
- `src/Module/Core/Infrastructure/ApiPlatform/Resource/AuditLogResource.php`
- `src/Module/Core/Infrastructure/ApiPlatform/Resource/AuditLogEntityTypesResource.php`
**Adaptation pagination :** Lesstime n'a pas de `itemsPerPage`/`maximum_items_per_page` explicite dans `api_platform.yaml`. Le provider utilise `Pagination::getPage()`/`getLimit()` (défauts API Platform : 30/page). C'est acceptable. Conserver le clamp `max(1, page)`.
- [ ] **Step D3: Écrire le test API (échec attendu)**`tests/Functional/Module/Core/AuditLogApiTest.php`. S'aligner sur le helper d'auth des tests existants (login admin/admin via cookie JWT, cf. `RoleApiTest`). Tests :
- admin authentifié : `GET /api/audit-logs` → 200, structure hydra paginée.
- filtre `?action=update` → ne renvoie que des updates.
- filtre `?entity_type=core.User`.
- `?action=bogus` → 400.
- utilisateur sans permission (alice) : 403.
- non authentifié : 401.
Préparer des données : créer/modifier un User via l'EM avant les assertions (le listener écrit), OU insérer directement des lignes via la connexion `audit`.
- [ ] **Step D4: Run → échec, puis vérifier la route** :
```bash
docker exec -t -u www-data php-lesstime-fpm php bin/console debug:router 2>&1 | grep -i audit
docker exec -t -u www-data php-lesstime-fpm php vendor/bin/phpunit tests/Functional/Module/Core/AuditLogApiTest.php
```
Expected: routes `/api/audit-logs`, `/api/audit-logs/{id}`, `/api/audit-log-entity-types` présentes ; test passe une fois les providers branchés.
- [ ] **Step D5: sync-permissions** (enregistre `core.audit_log.view` en base dev + test) :
```bash
docker exec -t -u www-data php-lesstime-fpm php bin/console app:sync-permissions
docker exec -t -u www-data php-lesstime-fpm php bin/console app:sync-permissions --env=test
```
- [ ] **Step D6: Suite complète + cs-fixer**
```bash
docker exec -t -u www-data php-lesstime-fpm php vendor/bin/phpunit
make php-cs-fixer-allow-risky
```
- [ ] **Step D7: Commit**
```bash
git add src/Module/Core/ tests/
git commit -m "feat(core) : expose read-only audit-logs api with dbal provider and pagination"
```
---
## Task E: Front — page consultation gated RBAC
**Files:**
- Create: `frontend/modules/core/services/audit-logs.ts`, `frontend/components/admin/AdminAuditTab.vue`
- Modify: `frontend/pages/admin.vue`, `frontend/i18n/locales/fr.json`
**Interfaces consumed:** `GET /api/audit-logs`, composable `usePermissions` (livré en 1.2), pattern onglet admin (cf. `AdminRoleTab.vue` créé en 1.2).
- [ ] **Step E1: Service**`frontend/modules/core/services/audit-logs.ts` : fonction `fetchAuditLogs(params)` via `useApi()` (suivre `roles.ts`/`permissions.ts` créés en 1.2). Types : `AuditLogItem { id, entityType, entityId, action, changes, performedBy, performedAt, ipAddress, requestId }`.
- [ ] **Step E2: Composant onglet**`frontend/components/admin/AdminAuditTab.vue` : tableau paginé (colonnes date, utilisateur, type d'entité, action, id), filtre par `entityType` et `action`. Labels via i18n `audit.entity.*` et `audit.action.*`. Reproduire le style de `AdminRoleTab.vue`.
- [ ] **Step E3: Onglet dans admin.vue** — ajouter un onglet « Audit » gated `can('core.audit_log.view')` (suivre le gating de l'onglet rôles ajouté en 1.2).
- [ ] **Step E4: i18n**`frontend/i18n/locales/fr.json` : ajouter `admin.audit.*` (titre, colonnes, filtres) et `audit.entity.core.User` = « Utilisateur », `audit.entity.core.Role` = « Rôle », `audit.entity.core.Permission` = « Permission » ; `audit.action.create/update/delete`.
- [ ] **Step E5: Vérifier la route déterministe (SPA)** :
```bash
cd frontend && npx nuxt build 2>&1 | tail -5
grep -o 'name:"admin"' .output/server/chunks/build/client.precomputed.mjs | head -1
```
Expected: build OK (la page admin reste enregistrée).
- [ ] **Step E6: Commit**
```bash
git add frontend/
git commit -m "feat(core) : add audit log consultation tab in admin gated by permission"
```
---
## Task F: Validation finale + statut
- [ ] **Step F1: Suite complète verte + login fumée**
```bash
docker exec -t -u www-data php-lesstime-fpm php vendor/bin/phpunit
```
Vérifier login admin → 204 + `GET /api/me` 200 + `GET /api/audit-logs` 200 (cURL ou via test).
- [ ] **Step F2: migrations:diff propre** (audit_log absente du diff) :
```bash
docker exec -t -u www-data php-lesstime-fpm php bin/console doctrine:migrations:diff --env=test 2>&1 | grep -ci audit_log
```
Expected: 0.
- [ ] **Step F3: Learnings** — append session #61 à `.claude/skills/ticket-executor/LEARNINGS.md`, commit `docs : log LST-61 audit log session learnings`.
- [ ] **Step F4: Push branche + MR empilée sur #57** (Gitea, base `feat/lst-57-rbac-fin`), draft puis un-draft via API si voulu.
- [ ] **Step F5: Ticket #61 (id 647) → « En attente de validation » (statut 4)**, stopper le timer, informer l'utilisateur.
---
## Self-Review (couverture spec)
| Critère d'acceptation | Tâche |
|---|---|
| CRUD des entités `#[Auditable]` tracé | C (listener + test create/update/delete) |
| Endpoint `/api/audit-logs` paginé/filtrable | D (provider DBAL + DbalPaginator + filtres) |
| `make test` vert, aucune migration destructive | A (migration additive), C/D/F (suite) |
| `#[Auditable]`/`#[AuditIgnore]` dans Shared | A1 |
| Table `audit_log` (qui/quoi/quand/diff/requestId) + COMMENT | A2 |
| `#[AuditIgnore]` champs sensibles (password, apiToken) | C4 + B2 blacklist |
| Front consultation + i18n `audit.entity.*` gated RBAC | E |
**Décision de scope :** `#[Auditable]` posé sur les **entités migrées** (User, Role, Permission) conformément au libellé du ticket. Les entités métier legacy (`src/Entity/*`) ne sont pas marquées ici — elles le seront lors de leur migration en modules (phases 2.x+). L'infra est prête à les auditer sans modification dès qu'elles portent l'attribut.
@@ -0,0 +1,732 @@
# LST-63 (1.1) — Module Core : Identité (User/Auth/JWT) & Notifications — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Migrer l'identité (`User` + Auth/JWT + password hashing + `MeProvider`) et les notifications dans `src/Module/Core/`, exposer le contrat `UserInterface` enrichi + `NotifierInterface`, déclarer `CoreModule` (REQUIRED), et créer le premier vrai layer front `modules/core/`**sans aucune migration destructive et sans casser le login à aucune étape**.
**Architecture:** Strangler 100 % additif, phasé. On déplace physiquement la classe `User` vers `App\Module\Core\Domain\Entity\User` (table `user` inchangée → zéro migration), on re-pointe `resolve_target_entities` et le provider de sécurité, puis on bascule les 8 relations d'entités et les 26 consommateurs du concret `App\Entity\User` vers le **contrat** `App\Shared\Domain\Contract\UserInterface` (enrichi des accessors réellement utilisés). Les notifications passent par un `NotifierInterface` (impl Core). Chaque phase laisse `make test` vert ET le login JWT fonctionnel (re-vérifié par curl).
**Tech Stack:** PHP 8.4 / Symfony 8 / API Platform 4 / Doctrine ORM / lexik/jwt-authentication / PostgreSQL 16 / PHPUnit 13 — front Nuxt 4.3 / Vue 3.5 / Pinia 3.
## Global Constraints
- **`declare(strict_types=1);`** en tête de chaque fichier PHP.
- **Zéro migration destructive** : le déplacement de namespace ne change ni la table (`user`) ni les colonnes → `doctrine:migrations:diff` doit produire un diff VIDE. Si un diff non vide apparaît, c'est un bug (mapping mal recopié) — corriger, ne pas générer la migration.
- **Login JWT fonctionnel à chaque phase** : vérif curl obligatoire (voir « Vérification login » ci-dessous) après toute phase touchant `User`/sécurité.
- **AC ticket** : (1) login/JWT OK via le module ; (2) aucun `use App\Entity\User;` hors `src/Module/Core/` ; (3) `make test` vert, aucune migration destructive.
- **Commits** : `<type>(<scope>) : <message>` (espaces autour du `:`). **Jamais** de mention IA/Claude/Anthropic.
- **`config/reference.php`** : auto-généré, **jamais committé** (apparaît modifié dans `git status`).
- **Tests** : `docker exec -t -u www-data php-lesstime-fpm php vendor/bin/phpunit`. Baseline avant ce ticket : **115 tests, 227 assertions** (16 PHPUnit Notices préexistantes, non bloquantes).
- **Front** : `nuxt typecheck` n'est PAS un gate vert sur ce stack (cf. plan LST-62) — gate front = zéro `Cannot find module`, auto-imports présents dans `.nuxt/imports.d.ts`, smoke runtime.
- **PostgreSQL** : noms de colonnes en minuscules dans le SQL brut.
## Vérification login (à exécuter après chaque phase back touchant User/sécurité)
```bash
# Doit renvoyer http=204 (cookie BEARER posé) puis le profil courant
curl -s -c /tmp/cj.txt -X POST http://localhost:8082/api/login_check \
-H "Content-Type: application/json" -d '{"username":"alice","password":"alice"}' \
-o /dev/null -w "login http=%{http_code}\n"
curl -s -b /tmp/cj.txt http://localhost:8082/api/me -w "\nme http=%{http_code}\n" | head -c 400
# MCP apiToken (ApiTokenAuthenticator) — admin
curl -s -X POST http://localhost:8082/_mcp -H "Authorization: Bearer dev-mcp-token-for-testing-only-do-not-use-in-production" \
-H "Content-Type: application/json" -d '{"jsonrpc":"2.0","id":1,"method":"ping"}' -o /dev/null -w "mcp http=%{http_code}\n"
```
Attendu : `login http=204`, `me http=200` avec le JSON de l'utilisateur (`username`, `roles`), MCP répond (200). **Si l'un casse, arrêter la phase et corriger avant de committer.**
## Décisions de conception (actées, à valider PO a posteriori)
1. **`UserInterface` enrichi (contrat de lecture)** — plutôt que de garder `App\Entity\User` partout, on enrichit `App\Shared\Domain\Contract\UserInterface` des accessors **réellement consommés** hors Core (lecture). Les setters/écriture restent sur le concret (Core uniquement). Cela permet de typer les 8 relations et les 26 consommateurs sur le contrat sans casse.
2. **Move physique, table inchangée**`User` change de namespace mais garde `#[ORM\Table(name: '`user`')]` et toutes ses colonnes → aucune migration. La classe reste une entité Doctrine mappée (nouveau dir de mapping `Core`).
3. **Relations via le contrat** — les 8 entités passent à `targetEntity: UserInterface::class` + type `?UserInterface`, résolu par `resolve_target_entities → Core\User`. C'est le pattern Starseed.
4. **Notification dans Core + `NotifierInterface`**`Notification` migre dans Core (couplée à l'identité) ; la création de notif passe par `NotifierInterface` (impl Core), `TaskNotificationListener` (qui reste legacy en Phase D) en dépend par contrat. L'API REST `/api/notifications` est préservée à l'identique.
5. **Front layer `modules/core/`** — login, profile, admin users **déplacés** de `frontend/pages/` vers `frontend/modules/core/pages/` (premier layer réel ; le scan `readdirSync('modules/')` de LST-62 l'enregistre automatiquement). Le routage Nuxt est préservé (mêmes chemins d'URL).
---
## Phase A — Squelette Core + contrats (100 % additif, app inchangée)
### Task 1: `CoreModule` + `UserRepositoryInterface` + `NotifierInterface` + contrat `UserInterface` enrichi
**Files:**
- Create: `src/Module/Core/CoreModule.php`
- Create: `src/Module/Core/Domain/Repository/UserRepositoryInterface.php`
- Create: `src/Shared/Domain/Contract/NotifierInterface.php`
- Modify: `src/Shared/Domain/Contract/UserInterface.php` (enrichir)
- Create: `tests/Unit/Module/Core/CoreModuleTest.php`
**Interfaces:**
- Produces :
- `App\Module\Core\CoreModule implements ModuleInterface` : `id()='core'`, `label()='Core'`, `isRequired()=true`, `permissions()` (stub pour 1.2, voir code).
- `App\Module\Core\Domain\Repository\UserRepositoryInterface` : `findByRole(string $role): array`, `findActiveEmployees(\DateTimeInterface $date): array`, `findOneByUsername(string $username): ?UserInterface`.
- `App\Shared\Domain\Contract\NotifierInterface` : `notify(UserInterface $user, string $type, string $title, string $message): void`.
- `UserInterface` enrichi (lecture) : `getId(): ?int`, `getUserIdentifier(): string`, `getUsername(): string`, `getRoles(): array`, `getFirstName(): ?string`, `getLastName(): ?string`, `getAvatarUrl(): ?string`, `isEmployee(): bool`.
- Consumes : `App\Shared\Domain\Module\ModuleInterface` (existant).
- [ ] **Step 1: Écrire le test unitaire `CoreModule`**
`tests/Unit/Module/Core/CoreModuleTest.php` :
```php
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Module\Core;
use App\Module\Core\CoreModule;
use App\Shared\Domain\Module\ModuleInterface;
use PHPUnit\Framework\TestCase;
/**
* @internal
*/
final class CoreModuleTest extends TestCase
{
public function testItIsAModule(): void
{
self::assertInstanceOf(ModuleInterface::class, new CoreModule());
}
public function testIdentity(): void
{
self::assertSame('core', CoreModule::id());
self::assertTrue(CoreModule::isRequired());
self::assertNotSame('', CoreModule::label());
}
public function testPermissionsAreWellFormed(): void
{
foreach (CoreModule::permissions() as $permission) {
self::assertArrayHasKey('code', $permission);
self::assertArrayHasKey('label', $permission);
}
}
}
```
- [ ] **Step 2: Lancer le test, vérifier l'échec**
Run: `docker exec -t -u www-data php-lesstime-fpm php vendor/bin/phpunit tests/Unit/Module/Core/CoreModuleTest.php`
Expected: FAIL (classe `CoreModule` inexistante).
- [ ] **Step 3: Créer `CoreModule`**
`src/Module/Core/CoreModule.php` :
```php
<?php
declare(strict_types=1);
namespace App\Module\Core;
use App\Shared\Domain\Module\ModuleInterface;
final class CoreModule implements ModuleInterface
{
public static function id(): string
{
return 'core';
}
public static function label(): string
{
return 'Core';
}
public static function isRequired(): bool
{
return true;
}
/**
* Permissions posées pour le RBAC fin (1.2). Inertes tant que 1.2 n'est pas livré.
*
* @return list<array{code: string, label: string}>
*/
public static function permissions(): array
{
return [
['code' => 'core.user.read', 'label' => 'Consulter les utilisateurs'],
['code' => 'core.user.manage', 'label' => 'Gérer les utilisateurs'],
['code' => 'core.notification.read', 'label' => 'Consulter ses notifications'],
];
}
}
```
> ⚠️ Confirmer la signature EXACTE de `ModuleInterface` (`src/Shared/Domain/Module/ModuleInterface.php`) avant d'écrire : la cartographie indique `id()`, `label()`, `isRequired()`, `permissions()` statiques. Si une méthode diffère (ex. non statique), aligner `CoreModule` ET le test dessus.
- [ ] **Step 4: Lancer le test, vérifier le vert**
Run: `docker exec -t -u www-data php-lesstime-fpm php vendor/bin/phpunit tests/Unit/Module/Core/CoreModuleTest.php`
Expected: PASS (3 tests).
- [ ] **Step 5: Enrichir le contrat `UserInterface`**
Remplace `src/Shared/Domain/Contract/UserInterface.php` par :
```php
<?php
declare(strict_types=1);
namespace App\Shared\Domain\Contract;
/**
* Contrat de LECTURE de l'identité, consommé hors du module Core.
* Les écritures (setPassword, setters HR…) restent sur le concret Core\Domain\Entity\User.
*/
interface UserInterface
{
public function getId(): ?int;
public function getUserIdentifier(): string;
public function getUsername(): string;
/** @return list<string> */
public function getRoles(): array;
public function getFirstName(): ?string;
public function getLastName(): ?string;
public function getAvatarUrl(): ?string;
public function isEmployee(): bool;
}
```
> ⚠️ Cet enrichissement DOIT correspondre à des méthodes existantes de l'entité `User` (la cartographie confirme `getId`, `getUserIdentifier`, `getUsername`, `getRoles`, `getFirstName`, `getLastName`, `getAvatarUrl`, `isEmployee`). Si une signature diffère (ex. `getAvatarUrl(): string` non-nullable), aligner le contrat sur le réel. Ne PAS ajouter au contrat une méthode absente de `User`.
- [ ] **Step 6: Créer `UserRepositoryInterface`**
`src/Module/Core/Domain/Repository/UserRepositoryInterface.php` :
```php
<?php
declare(strict_types=1);
namespace App\Module\Core\Domain\Repository;
use App\Shared\Domain\Contract\UserInterface;
interface UserRepositoryInterface
{
/**
* @return list<UserInterface>
*/
public function findByRole(string $role): array;
/**
* @return list<UserInterface>
*/
public function findActiveEmployees(\DateTimeInterface $date): array;
public function findOneByUsername(string $username): ?UserInterface;
}
```
- [ ] **Step 7: Créer `NotifierInterface`**
`src/Shared/Domain/Contract/NotifierInterface.php` :
```php
<?php
declare(strict_types=1);
namespace App\Shared\Domain\Contract;
interface NotifierInterface
{
public function notify(UserInterface $user, string $type, string $title, string $message): void;
}
```
- [ ] **Step 8: Suite complète + commit**
Run: `docker exec -t -u www-data php-lesstime-fpm php vendor/bin/phpunit`
Expected: PASS (115 + 3 = 118 tests). L'enrichissement du contrat ne casse rien (l'entité `User` implémente déjà ces méthodes ; `resolve_target_entities` pointe encore `App\Entity\User`).
Run: `make php-cs-fixer-allow-risky`
```bash
git add src/Module/Core/CoreModule.php src/Module/Core/Domain/Repository/UserRepositoryInterface.php src/Shared/Domain/Contract/NotifierInterface.php src/Shared/Domain/Contract/UserInterface.php tests/Unit/Module/Core/CoreModuleTest.php
git commit -m "feat(core) : add CoreModule, user repository contract, notifier contract and enriched user contract"
```
---
## Phase B — Déplacer `User` + Auth dans Core (re-pointage, zéro migration)
### Task 2: Déplacer la classe `User` vers Core + mapping Doctrine + provider sécurité
**Files:**
- Move: `src/Entity/User.php``src/Module/Core/Domain/Entity/User.php` (namespace `App\Module\Core\Domain\Entity`)
- Modify: `config/packages/doctrine.yaml` (mapping `Core` + `resolve_target_entities`)
- Modify: `config/packages/security.yaml` (`app_user_provider.entity.class`)
- Modify: `config/packages/api_platform.yaml` (mapping paths : ajouter le dir entité Core)
**Interfaces:**
- Produces : entité `App\Module\Core\Domain\Entity\User` (table `user` inchangée), résolue par `resolve_target_entities`.
- [ ] **Step 1: Déplacer le fichier (git mv) et changer le namespace**
```bash
cd /home/matthieu/dev_malio/Lesstime
mkdir -p src/Module/Core/Domain/Entity
git mv src/Entity/User.php src/Module/Core/Domain/Entity/User.php
```
Puis éditer `src/Module/Core/Domain/Entity/User.php` :
- `namespace App\Entity;``namespace App\Module\Core\Domain\Entity;`
- Adapter les `use` internes devenus nécessaires (l'entité référençait `UserRepository`, `MeProvider`, `UserPasswordHasherProcessor`, l'enum `ContractType`, le contrat `UserInterface as SharedUserInterface`). Mettre les `use` complets vers leurs emplacements ACTUELS (la plupart bougent en Tasks 3/4 ; pour cette task, pointer encore vers `App\Repository\UserRepository`, `App\State\MeProvider`, `App\State\UserPasswordHasherProcessor`, `App\Entity\Enum\ContractType` ou l'emplacement réel — vérifier les `use` d'origine et les conserver tels quels tant que ces classes n'ont pas bougé).
- Garder VERBATIM : tous les attributs `#[ORM\...]` (dont `#[ORM\Table(name: '`user`')]`), `#[ApiResource(...)]`, `#[ApiProperty(...)]`, toutes les propriétés/méthodes, `implements UserInterface, PasswordAuthenticatedUserInterface, SharedUserInterface`.
> ⚠️ Lire le fichier d'origine en entier AVANT de déplacer pour relever tous les `use`. Ne changer QUE le `namespace` et, si besoin, garder les `use` pointant vers les emplacements actuels des classes non encore déplacées.
- [ ] **Step 2: Mapping Doctrine + resolve_target_entities**
Dans `config/packages/doctrine.yaml`, sous `orm:` :
- `resolve_target_entities` :
```yaml
resolve_target_entities:
App\Shared\Domain\Contract\UserInterface: App\Module\Core\Domain\Entity\User
```
- Ajouter un mapping pour les entités Core (en plus du mapping `App` existant qui scanne `src/Entity`) :
```yaml
mappings:
App:
type: attribute
is_bundle: false
dir: '%kernel.project_dir%/src/Entity'
prefix: 'App\Entity'
alias: App
Core:
type: attribute
is_bundle: false
dir: '%kernel.project_dir%/src/Module/Core/Domain/Entity'
prefix: 'App\Module\Core\Domain\Entity'
```
> Le mapping `App` (src/Entity) ne contient plus `User.php` (déplacé) → cohérent. Aucune entité orpheline.
- [ ] **Step 3: Provider de sécurité**
Dans `config/packages/security.yaml` :
```yaml
providers:
app_user_provider:
entity:
class: App\Module\Core\Domain\Entity\User
property: username
```
- [ ] **Step 4: API Platform mapping paths**
Dans `config/packages/api_platform.yaml`, ajouter au `mapping.paths` le dossier entité Core (l'`#[ApiResource]` est porté par l'entité `User` déplacée) :
```yaml
- '%kernel.project_dir%/src/Module/Core/Domain/Entity'
```
> Conserver tous les paths existants. Si `api_platform.yaml` n'a pas de `mapping.paths` explicite (auto-discovery), vérifier que les Resources sous `src/Module/...` sont bien découvertes (comme `src/Shared/...` l'a été en #56 — cf. LEARNINGS : API Platform 4 auto-découvre). Si la découverte auto suffit, NE PAS ajouter de path ; sinon ajouter celui ci-dessus.
- [ ] **Step 5: Vider le cache + vérifier qu'AUCUNE migration n'est nécessaire**
```bash
docker exec -t -u www-data php-lesstime-fpm php bin/console cache:clear
docker exec -t -u www-data php-lesstime-fpm php bin/console doctrine:schema:validate
docker exec -t -u www-data php-lesstime-fpm php bin/console doctrine:migrations:diff --no-interaction 2>&1 | tail -20
```
Expected : schema VALID (mapping ok, sync DB ok). Le `diff` doit annoncer **« No changes detected »** (table/colonnes identiques). **Si une migration est générée, la SUPPRIMER** (`git status` → retirer le fichier sous `migrations/`) : un diff non vide = mapping mal recopié, corriger l'entité.
- [ ] **Step 6: Vérif login + suite complète**
Exécuter le bloc « Vérification login » (curl) → `login http=204`, `me http=200`, MCP 200.
Run: `docker exec -t -u www-data php-lesstime-fpm php vendor/bin/phpunit`
Expected: PASS (118). Les consommateurs importent encore `App\Entity\User`**ERREUR attendue** : la classe n'existe plus à cet emplacement. ⇒ Cette task NE PASSE PAS seule ; elle est indissociable de la Task 3 (rewire). **Voir note ci-dessous.**
> 🔴 **Note d'ordonnancement** : déplacer `User` casse les 26 `use App\Entity\User;`. Pour garder l'app bootable entre Task 2 et Task 3, **ajouter un alias de compatibilité TEMPORAIRE** au tout début de Task 2 et le retirer en fin de Task 3 :
> Créer `src/Module/Core/_compat_user_alias.php` (chargé via `composer.json` `autoload.files`) :
> ```php
> <?php
> declare(strict_types=1);
> if (!class_exists(\App\Entity\User::class, false)) {
> class_alias(\App\Module\Core\Domain\Entity\User::class, \App\Entity\User::class);
> }
> ```
> Ajouter `"files": ["src/Module/Core/_compat_user_alias.php"]` sous `autoload` dans `composer.json`, puis `composer dump-autoload`. Cela garde les 26 consommateurs fonctionnels (et Doctrine `targetEntity: User::class` résolu via l'alias) le temps de la Task 3. **L'alias est SUPPRIMÉ en Task 3 Step final** (avec le retrait du fichier, l'entrée composer et un nouveau `dump-autoload`) une fois tous les consommateurs basculés sur le contrat. La verif login de cette Step utilise donc l'alias — c'est attendu.
- [ ] **Step 7: php-cs-fixer + commit (Phase B, avec alias temporaire)**
Run: `make php-cs-fixer-allow-risky`
```bash
git add src/Module/Core/Domain/Entity/User.php src/Module/Core/_compat_user_alias.php composer.json composer.lock config/packages/doctrine.yaml config/packages/security.yaml config/packages/api_platform.yaml
git commit -m "feat(core) : move user entity into core module and repoint security/doctrine (temp legacy alias)"
```
---
## Phase C — Basculer relations + consommateurs sur le contrat, retirer l'alias
### Task 3: Relations d'entités → `UserInterface::class`
**Files (8 entités):**
- Modify: `src/Entity/Task.php` (assignee ManyToOne, collaborators ManyToMany)
- Modify: `src/Entity/TimeEntry.php` (user)
- Modify: `src/Entity/AbsenceRequest.php` (user)
- Modify: `src/Entity/AbsenceBalance.php` (user)
- Modify: `src/Entity/TaskDocument.php` (user)
- Modify: `src/Entity/TaskMailLink.php` (user)
- Modify: `src/Module/Core/Domain/Entity/Notification.php` (user) — **après son déplacement en Phase D** ; en Phase C, `Notification` est encore `src/Entity/Notification.php`, la traiter ici aussi.
> Pour CHAQUE relation vers User : remplacer `use App\Entity\User;` par `use App\Shared\Domain\Contract\UserInterface;`, le `targetEntity: User::class` par `targetEntity: UserInterface::class`, et le type de propriété/param `?User` → `?UserInterface` (idem getters/setters). Doctrine résout via `resolve_target_entities`. La colonne FK et son nom restent identiques → **aucune migration**.
- [ ] **Step 1: Modifier les relations (entité par entité)**
Pour chaque fichier ci-dessus, lire puis appliquer le remplacement décrit. Exemple `Task.php` (assignee) :
```php
// avant
use App\Entity\User;
#[ORM\ManyToOne(targetEntity: User::class)]
private ?User $assignee = null;
public function getAssignee(): ?User { return $this->assignee; }
public function setAssignee(?User $assignee): static { $this->assignee = $assignee; return $this; }
// collaborators
#[ORM\ManyToMany(targetEntity: User::class)]
private Collection $collaborators;
// après
use App\Shared\Domain\Contract\UserInterface;
#[ORM\ManyToOne(targetEntity: UserInterface::class)]
private ?UserInterface $assignee = null;
public function getAssignee(): ?UserInterface { return $this->assignee; }
public function setAssignee(?UserInterface $assignee): static { $this->assignee = $assignee; return $this; }
#[ORM\ManyToMany(targetEntity: UserInterface::class)]
private Collection $collaborators;
```
> ⚠️ Conserver tous les autres attributs de relation (`inversedBy`, `joinTable`, `joinColumn`, `nullable`, `onDelete`, Groups…) VERBATIM. Ne changer que le type et `targetEntity`.
- [ ] **Step 2: Valider le schéma (toujours zéro migration)**
```bash
docker exec -t -u www-data php-lesstime-fpm php bin/console cache:clear
docker exec -t -u www-data php-lesstime-fpm php bin/console doctrine:schema:validate
docker exec -t -u www-data php-lesstime-fpm php bin/console doctrine:migrations:diff --no-interaction 2>&1 | tail -5
```
Expected : « No changes detected ». Sinon corriger (un `joinColumn`/`onDelete` a été perdu).
### Task 4: Consommateurs (26 fichiers) → contrat + repository interface, MeProvider/Processor dans Core, retrait alias
**Files:** les 26 fichiers listés dans la cartographie (Controllers, Repositories, State, Services, EventListener, Security, DataFixtures, Mcp). Déplacements vers Core :
- Move: `src/Repository/UserRepository.php``src/Module/Core/Infrastructure/Doctrine/DoctrineUserRepository.php` (implémente `UserRepositoryInterface`, namespace `App\Module\Core\Infrastructure\Doctrine`)
- Move: `src/State/MeProvider.php``src/Module/Core/Infrastructure/ApiPlatform/State/MeProvider.php`
- Move: `src/State/UserPasswordHasherProcessor.php``src/Module/Core/Infrastructure/ApiPlatform/State/UserPasswordHasherProcessor.php`
- Modify: l'`#[ApiResource]` de l'entité `User` (les `provider:`/`processor:` pointent vers les nouveaux FQCN Core).
- Delete (en fin de task): `src/Module/Core/_compat_user_alias.php` + entrée `composer.json`.
- [ ] **Step 1: Déplacer le repository et l'aligner sur l'interface**
```bash
mkdir -p src/Module/Core/Infrastructure/Doctrine src/Module/Core/Infrastructure/ApiPlatform/State
git mv src/Repository/UserRepository.php src/Module/Core/Infrastructure/Doctrine/DoctrineUserRepository.php
git mv src/State/MeProvider.php src/Module/Core/Infrastructure/ApiPlatform/State/MeProvider.php
git mv src/State/UserPasswordHasherProcessor.php src/Module/Core/Infrastructure/ApiPlatform/State/UserPasswordHasherProcessor.php
```
Éditer `DoctrineUserRepository.php` : `namespace App\Module\Core\Infrastructure\Doctrine;`, `class DoctrineUserRepository extends ServiceEntityRepository implements UserRepositoryInterface`, `use App\Module\Core\Domain\Entity\User;`, `use App\Module\Core\Domain\Repository\UserRepositoryInterface;`, et passer `User::class` au constructeur parent. Ajouter `findOneByUsername()` si absent (`return $this->findOneBy(['username' => $username]);`). Conserver `findByRole()` (SQL natif `roles::text LIKE`) et `findActiveEmployees()`.
Éditer `User.php` : `#[ORM\Entity(repositoryClass: DoctrineUserRepository::class)]` avec le bon `use`.
Éditer `MeProvider.php` / `UserPasswordHasherProcessor.php` : nouveaux namespaces ; `use App\Module\Core\Domain\Entity\User;` (le processor manipule le concret — c'est dans Core, autorisé).
Mettre à jour les `provider:`/`processor:` dans l'`#[ApiResource]` de `User` vers les nouveaux FQCN.
- [ ] **Step 2: Lier l'interface repository au service Doctrine**
Dans `config/services.yaml`, alias pour l'injection par interface :
```yaml
App\Module\Core\Domain\Repository\UserRepositoryInterface: '@App\Module\Core\Infrastructure\Doctrine\DoctrineUserRepository'
```
- [ ] **Step 3: Basculer les 25 autres consommateurs sur le contrat**
Pour chaque fichier important `App\Entity\User` (hors Core), remplacer `use App\Entity\User;` par `use App\Shared\Domain\Contract\UserInterface;` et le type-hint `User` par `UserInterface` (params, retours, propriétés, `@var`, expressions). Cas particuliers :
- `src/Repository/{Notification,AbsenceBalance,AbsenceRequest,TimeEntry}Repository.php` : les signatures `countUnreadByUser(User $user)` etc. → `UserInterface`. Ne pas changer la logique DQL (`n.user = :user` fonctionne avec l'instance).
- `src/State/Absence*`, `TaskDocumentProvider`, `src/Service/AbsenceBalanceService`, `src/Security/MailAccessChecker`, `src/EventListener/TaskNotificationListener` (sera retravaillé en Phase D mais peut déjà passer au contrat ici), `src/Controller/*` (7), `src/Mcp/Tool/Absence/ReviewAbsenceRequestTool`, `src/Mcp/Tool/Serializer` : remplacer le type-hint.
- `src/DataFixtures/AppFixtures.php` : **garde le concret** `App\Module\Core\Domain\Entity\User` (les fixtures INSTANCIENT `new User()` et appellent des setters d'écriture — c'est légitime ; importer le concret Core, pas le contrat). C'est hors `src/Module/Core/` mais c'est de l'écriture d'identité → exception documentée (les fixtures sont un cas d'amorçage, pas un consommateur métier).
> Liste de contrôle : après cette step, `grep -rn "use App\\\\Entity\\\\User;" src/` ne doit retourner QUE `src/DataFixtures/AppFixtures.php` (qui importe désormais le FQCN Core, donc 0 occurrence de `App\Entity\User`). Viser **0 occurrence de `App\Entity\User`** dans tout `src/`.
- [ ] **Step 4: Retirer l'alias de compatibilité**
```bash
git rm src/Module/Core/_compat_user_alias.php
```
Retirer l'entrée `"files": [...]` ajoutée sous `autoload` dans `composer.json` (Task 2), puis :
```bash
docker exec -t -u www-data php-lesstime-fpm composer dump-autoload
docker exec -t -u www-data php-lesstime-fpm php bin/console cache:clear
```
- [ ] **Step 5: `grep` de garde (AC 2) + schéma + tests + login**
```bash
grep -rn "App\\\\Entity\\\\User" src/ config/ ; echo "(doit être VIDE)"
docker exec -t -u www-data php-lesstime-fpm php bin/console doctrine:schema:validate
docker exec -t -u www-data php-lesstime-fpm php bin/console doctrine:migrations:diff --no-interaction 2>&1 | tail -5
docker exec -t -u www-data php-lesstime-fpm php vendor/bin/phpunit
```
Expected : grep VIDE, schéma valide, « No changes detected », **118 tests verts**. Puis bloc « Vérification login » (login 204, me 200, MCP 200).
- [ ] **Step 6: php-cs-fixer + commit (Phase C)**
Run: `make php-cs-fixer-allow-risky`
```bash
git add -A -- src config composer.json composer.lock
git commit -m "refactor(core) : wire user relations and consumers to the shared contract, drop legacy alias"
```
> ⚠️ NE PAS `git add config/reference.php`. Vérifier `git status` avant le commit ; si `reference.php` est listé, l'exclure du `git add` (stager explicitement les fichiers voulus).
---
## Phase D — Notifications via `NotifierInterface` (impl Core)
### Task 5: Déplacer `Notification` dans Core + `Notifier` (impl) + recâbler le listener
**Files:**
- Move: `src/Entity/Notification.php``src/Module/Core/Domain/Entity/Notification.php`
- Move: `src/Repository/NotificationRepository.php``src/Module/Core/Infrastructure/Doctrine/DoctrineNotificationRepository.php`
- Move: `src/State/NotificationProvider.php``src/Module/Core/Infrastructure/ApiPlatform/State/NotificationProvider.php`
- Create: `src/Module/Core/Infrastructure/Notifier.php` (implements `NotifierInterface`)
- Modify: `src/EventListener/TaskNotificationListener.php` (dépend de `NotifierInterface`)
- Modify: `config/packages/doctrine.yaml` (le mapping `Core` couvre déjà `Domain/Entity` → Notification incluse automatiquement)
- Modify: `tests/` — ajouter `tests/Unit/Module/Core/NotifierTest.php` (ou Functional) si testable unitairement.
- [ ] **Step 1: Écrire un test du `Notifier`**
`tests/Functional/Module/Core/NotifierTest.php` (crée une notif et vérifie la persistance) :
```php
<?php
declare(strict_types=1);
namespace App\Tests\Functional\Module\Core;
use App\Module\Core\Domain\Entity\User;
use App\Shared\Domain\Contract\NotifierInterface;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
/**
* @internal
*/
final class NotifierTest extends KernelTestCase
{
public function testNotifyPersistsANotificationForTheUser(): void
{
self::bootKernel();
$em = self::getContainer()->get(EntityManagerInterface::class);
$notifier = self::getContainer()->get(NotifierInterface::class);
$user = $em->getRepository(User::class)->findOneBy(['username' => 'alice']);
self::assertNotNull($user);
$notifier->notify($user, 'task_assigned', 'Titre', 'Message');
$count = (int) $em->createQuery(
'SELECT COUNT(n.id) FROM App\\Module\\Core\\Domain\\Entity\\Notification n WHERE n.user = :u AND n.title = :t'
)->setParameter('u', $user)->setParameter('t', 'Titre')->getSingleScalarResult();
self::assertSame(1, $count);
}
}
```
- [ ] **Step 2: Lancer, vérifier l'échec**`NotifierInterface` non instanciable / `Notification` introuvable au nouveau namespace.
Run: `docker exec -t -u www-data php-lesstime-fpm php vendor/bin/phpunit tests/Functional/Module/Core/NotifierTest.php`
Expected: FAIL.
- [ ] **Step 3: Déplacer `Notification` + repository + provider**
```bash
git mv src/Entity/Notification.php src/Module/Core/Domain/Entity/Notification.php
git mv src/Repository/NotificationRepository.php src/Module/Core/Infrastructure/Doctrine/DoctrineNotificationRepository.php
git mv src/State/NotificationProvider.php src/Module/Core/Infrastructure/ApiPlatform/State/NotificationProvider.php
```
- `Notification.php` : `namespace App\Module\Core\Domain\Entity;`, `use App\Shared\Domain\Contract\UserInterface;`, relation `user``targetEntity: UserInterface::class` + type `?UserInterface`, `repositoryClass: DoctrineNotificationRepository::class`, conserver `#[ORM\Table(name:'notification')]` + index VERBATIM, ApiResource (provider → nouveau FQCN). **Table/colonnes inchangées.**
- `DoctrineNotificationRepository.php` : namespace Core, `use App\Module\Core\Domain\Entity\Notification;`, signatures `UserInterface`.
- `NotificationProvider.php` : namespace Core, mêmes dépendances.
- [ ] **Step 4: Implémenter `Notifier`**
`src/Module/Core/Infrastructure/Notifier.php` :
```php
<?php
declare(strict_types=1);
namespace App\Module\Core\Infrastructure;
use App\Module\Core\Domain\Entity\Notification;
use App\Shared\Domain\Contract\NotifierInterface;
use App\Shared\Domain\Contract\UserInterface;
use Doctrine\ORM\EntityManagerInterface;
final readonly class Notifier implements NotifierInterface
{
public function __construct(private EntityManagerInterface $em) {}
public function notify(UserInterface $user, string $type, string $title, string $message): void
{
$notification = new Notification();
$notification->setUser($user);
$notification->setType($type);
$notification->setTitle($title);
$notification->setMessage($message);
$this->em->persist($notification);
$this->em->flush();
}
}
```
> ⚠️ Aligner sur les setters réels de `Notification` (la cartographie indique `user`, `type`, `title`, `message`, `isRead` default false, `createdAt`). Si `createdAt` n'est pas auto (prePersist), le poser ici. Si `setUser` attend le concret, accepter `UserInterface` (resolve_target_entities) — vérifier le type du setter.
- [ ] **Step 5: Recâbler `TaskNotificationListener` sur `NotifierInterface`**
Lire le listener ; remplacer la création directe de `Notification` (`new Notification()` + persist) par l'injection et l'appel de `NotifierInterface::notify(...)`. **Attention** : le listener tourne sur `onFlush`/`postFlush` — un `flush()` dans `notify()` pendant un `onFlush` est dangereux. Conserver le pattern existant (accumulation en `onFlush`, écriture en `postFlush`). Si `notify()` flush, l'appeler UNIQUEMENT en `postFlush` (jamais pendant `onFlush`). Préserver le comportement exact (mêmes types `task_assigned`/`task_collaborator_added`, mêmes destinataires). Adapter le test existant du listener s'il y en a un.
> Si l'intrication onFlush/postFlush rend `NotifierInterface` inadapté (flush imbriqué), documenter et garder le listener en écriture directe via le repository Core, mais TOUJOURS dépendre du contrat pour le type User. Le but AC est « Notification exposée via NotifierInterface » : `NotifierInterface` doit exister et être l'API publique pour les autres modules ; le listener interne Core peut écrire directement.
- [ ] **Step 6: Tests + login + endpoints notifications**
Run: `docker exec -t -u www-data php-lesstime-fpm php vendor/bin/phpunit`
Expected: PASS (118 + 1 = 119). Vérifier `doctrine:migrations:diff` → « No changes detected ». Bloc login. Puis curl notifications :
```bash
curl -s -b /tmp/cj.txt "http://localhost:8082/api/notifications" -w "\nnotif http=%{http_code}\n" | head -c 200
curl -s -b /tmp/cj.txt "http://localhost:8082/api/notifications/unread-count" -w "\nunread http=%{http_code}\n"
```
Expected : 200 sur les deux.
- [ ] **Step 7: php-cs-fixer + commit**
Run: `make php-cs-fixer-allow-risky`
```bash
git add -A -- src config tests
git commit -m "feat(core) : move notification into core and expose notifier contract"
```
---
## Phase E — Déclarer `CoreModule` actif
### Task 6: Enregistrer Core dans `config/modules.php`
**Files:**
- Modify: `config/modules.php`
- Modify: `tests/Functional/Shared/ModulesEndpointTest.php` (ou équivalent — adapter l'assertion à la présence de `core`)
- [ ] **Step 1: Adapter/écrire le test de l'endpoint modules**
Vérifier le test existant de `/api/modules` (cartographie : `ModulesProvider`/`ModulesResource` créés en #56). Ajouter une assertion :
```php
public function testCoreModuleIsActive(): void
{
$client = self::createClient();
// /api/modules est public (GET) d'après security.yaml
$client->request('GET', '/api/modules');
self::assertResponseIsSuccessful();
$data = json_decode($client->getResponse()->getContent(), true);
self::assertContains('core', $data['modules']);
}
```
> Adapter le nom de classe/fichier de test à l'existant (#56). Si aucun test fonctionnel modules n'existe, créer `tests/Functional/Shared/ModulesEndpointTest.php`.
- [ ] **Step 2: Lancer, vérifier l'échec** (modules.php retourne `[]`).
- [ ] **Step 3: Activer Core**
`config/modules.php` :
```php
<?php
declare(strict_types=1);
use App\Module\Core\CoreModule;
return [
CoreModule::class,
];
```
- [ ] **Step 4: Tests + curl**
Run: `docker exec -t -u www-data php-lesstime-fpm php vendor/bin/phpunit`
Expected: PASS. Curl :
```bash
curl -s http://localhost:8082/api/modules | head -c 200 # doit contenir "core"
```
- [ ] **Step 5: commit**
```bash
git add config/modules.php tests/
git commit -m "feat(core) : activate core module in modules registry"
```
---
## Phase F — Layer front `modules/core/`
### Task 7: Déplacer login / profile / admin users dans `frontend/modules/core/`
**Files:**
- Create: `frontend/modules/core/nuxt.config.ts` (`export default defineNuxtConfig({})`)
- Move: `frontend/pages/login.vue``frontend/modules/core/pages/login.vue`
- Move: `frontend/pages/profile.vue``frontend/modules/core/pages/profile.vue`
- Move: `frontend/pages/admin/**` (gestion users) → `frontend/modules/core/pages/admin/**`
- Move (si pertinent): composants/services liés à l'identité (ex. `frontend/components/user/**`, `frontend/components/admin/**`, `frontend/services/user.ts`) → `frontend/modules/core/{components,services}/**`
> ⚠️ AVANT de déplacer, LIRE `frontend/pages/` et `frontend/components/` pour identifier précisément les pages/compos d'identité. Le scan `readdirSync('modules/')` (LST-62) ajoute `./modules/core` à `extends` et `modules/core/composables`/`stores` à `imports.dirs`. Les `pages/` d'un layer Nuxt sont fusionnées automatiquement → **les URLs (`/login`, `/profile`, `/admin/...`) restent identiques**. Vérifier qu'aucune page déplacée n'utilise un import PAR CHEMIN cassé (auto-import sinon).
- [ ] **Step 1: Créer le layer + déplacer les pages d'identité**
```bash
cd /home/matthieu/dev_malio/Lesstime/frontend
mkdir -p modules/core/pages
printf 'export default defineNuxtConfig({})\n' > modules/core/nuxt.config.ts
git mv pages/login.vue modules/core/pages/login.vue
git mv pages/profile.vue modules/core/pages/profile.vue
# admin users : adapter au réel (git mv pages/admin/... modules/core/pages/admin/...)
```
> Lister `frontend/pages/admin/` d'abord ; déplacer UNIQUEMENT les pages de gestion des utilisateurs (pas les pages admin d'autres domaines). En cas de doute, déplacer seulement login + profile en 1.1 et laisser admin users (documenter).
- [ ] **Step 2: Corriger les imports par chemin éventuels**
Run: `cd /home/matthieu/dev_malio/Lesstime/frontend && grep -rn "pages/login\|pages/profile\|~/pages" --include=*.ts --include=*.vue . | grep -v node_modules`
Corriger toute référence cassée (les redirections `navigateTo('/login')` restent valides — c'est une URL, pas un chemin de fichier).
- [ ] **Step 3: Gate front (cf. LST-62) + smoke**
Run: `cd frontend && npx nuxt typecheck 2>&1 | grep "Cannot find module" | grep -E "modules/core|login|profile"` → doit être VIDE.
Run: `grep -E "login|profile" frontend/.nuxt/routes.* 2>/dev/null` ou démarrer `make dev-nuxt` et confirmer que `/login`, `/profile` répondent (la fusion des pages du layer est effective).
> Smoke runtime (login via navigateur) : laisser au PO si pas de navigateur côté exécutant.
- [ ] **Step 4: commit**
```bash
git add -A -- frontend
git commit -m "feat(core) : add core front layer with login, profile and admin users pages"
```
---
## Acceptance check (après toutes les phases)
- [ ] **AC1** Login/JWT OK via le module : `login http=204`, `/api/me` 200, MCP apiToken 200, `/api/notifications` 200.
- [ ] **AC2** `grep -rn "App\\Entity\\User" src/ config/`**VIDE** (User vit dans `src/Module/Core/Domain/Entity/`, consommé via contrat ; fixtures importent le FQCN Core).
- [ ] **AC3** `make test` vert (≈119 tests), `doctrine:schema:validate` OK, `doctrine:migrations:diff` = « No changes detected » (**aucune migration destructive ni même additive**).
- [ ] `/api/modules` renvoie `core` ; `CoreModule::isRequired() === true`.
- [ ] `resolve_target_entities: UserInterface → App\Module\Core\Domain\Entity\User`.
- [ ] Front : layer `modules/core/` détecté ; `/login`, `/profile` (+ admin users) accessibles aux mêmes URLs ; aucun `Cannot find module`.
- [ ] `config/reference.php` jamais committé.
## Notes pour le ticket suivant (1.2 — RBAC fin)
`CoreModule::permissions()` est déjà posé (stub). 1.2 ajoutera `Role`/`Permission`, `app:sync-permissions`, `PermissionVoter`, et fera filtrer `SidebarProvider` **par permission** (en plus du module actif + du gate rôle minimal posé en 0.2). Le contrat `UserInterface` enrichi est prêt à recevoir `getPermissions()` si besoin.
+160
View File
@@ -0,0 +1,160 @@
<template>
<div>
<div class="flex items-center justify-between">
<h2 class="text-lg font-bold text-neutral-900">{{ $t('admin.audit.title') }}</h2>
</div>
<div class="mt-4 flex flex-wrap gap-4">
<MalioSelect
v-model="entityTypeFilter"
:options="entityTypeOptions"
:label="$t('admin.audit.filterEntityType')"
:empty-option-label="$t('admin.audit.filterEntityTypeAll')"
group-class="w-64"
/>
<MalioSelect
v-model="actionFilter"
:options="actionOptions"
:label="$t('admin.audit.filterAction')"
:empty-option-label="$t('admin.audit.filterActionAll')"
group-class="w-64"
/>
</div>
<DataTable
:columns="columns"
:items="rows"
:loading="isLoading"
:empty-message="$t('admin.audit.empty')"
>
<template #cell-performedAt="{ item }">
{{ formatDate(item.performedAt) }}
</template>
<template #cell-entityType="{ item }">
{{ entityTypeLabel(item.entityType) }}
</template>
<template #cell-action="{ item }">
{{ actionLabel(item.action) }}
</template>
</DataTable>
<div class="mt-4 flex items-center justify-between">
<span class="text-sm text-neutral-500">{{ $t('admin.audit.page', { page }) }}</span>
<div class="flex gap-2">
<MalioButton
variant="secondary"
button-class="w-auto px-4"
:label="$t('admin.audit.previous')"
:disabled="page <= 1 || isLoading"
@click="goToPage(page - 1)"
/>
<MalioButton
variant="secondary"
button-class="w-auto px-4"
:label="$t('admin.audit.next')"
:disabled="!hasNextPage || isLoading"
@click="goToPage(page + 1)"
/>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import type { AuditLogAction, AuditLogItem } from '~/modules/core/services/audit-logs'
import { useAuditLogService } from '~/modules/core/services/audit-logs'
import type { DataTableColumn } from '~/components/ui/DataTable.vue'
const { t, te } = useI18n()
const PAGE_SIZE = 30
const columns = computed<DataTableColumn[]>(() => [
{ key: 'performedAt', label: t('admin.audit.date'), primary: true },
{ key: 'performedBy', label: t('admin.audit.performedBy') },
{ key: 'entityType', label: t('admin.audit.entityType') },
{ key: 'action', label: t('admin.audit.action') },
{ key: 'entityId', label: t('admin.audit.entityId') },
])
const actionOptions = computed<{ value: AuditLogAction, label: string }[]>(() => [
{ value: 'create', label: t('audit.action.create') },
{ value: 'update', label: t('audit.action.update') },
{ value: 'delete', label: t('audit.action.delete') },
])
const auditLogService = useAuditLogService()
const rows = ref<AuditLogItem[]>([])
const entityTypes = ref<string[]>([])
const totalItems = ref(0)
const page = ref(1)
const isLoading = ref(true)
const entityTypeFilter = ref<string | null>(null)
const actionFilter = ref<AuditLogAction | null>(null)
const entityTypeOptions = computed<{ value: string, label: string }[]>(() =>
entityTypes.value.map((value) => ({ value, label: entityTypeLabel(value) })),
)
// PAGE_SIZE must match the API default page size. The full-page guard keeps the
// "next" button accurate even on the last (partial) page.
const hasNextPage = computed(() => rows.value.length >= PAGE_SIZE && page.value * PAGE_SIZE < totalItems.value)
function entityTypeLabel(value: string): string {
const key = `audit.entity.${value}`
return te(key) ? t(key) : value
}
function actionLabel(action: AuditLogAction): string {
return t(`audit.action.${action}`)
}
function formatDate(value: string): string {
return new Date(value).toLocaleString('fr-FR', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
})
}
async function loadItems() {
isLoading.value = true
try {
const result = await auditLogService.list({
page: page.value,
entityType: entityTypeFilter.value ?? undefined,
action: actionFilter.value ?? undefined,
})
rows.value = result.items
totalItems.value = result.totalItems
} finally {
isLoading.value = false
}
}
async function loadEntityTypes() {
entityTypes.value = await auditLogService.entityTypes()
}
function goToPage(target: number) {
if (target < 1) {
return
}
page.value = target
loadItems()
}
watch([entityTypeFilter, actionFilter], () => {
page.value = 1
loadItems()
})
onMounted(() => {
loadItems()
loadEntityTypes()
})
</script>
+116
View File
@@ -0,0 +1,116 @@
<template>
<div>
<div class="flex items-center justify-between">
<h2 class="text-lg font-bold text-neutral-900">{{ $t('admin.roles.title') }}</h2>
<MalioButton
icon-name="mdi:plus"
icon-position="left"
button-class="w-auto px-4"
:label="$t('admin.roles.addRole')"
@click="openCreate"
/>
</div>
<DataTable
:columns="columns"
:items="items"
:loading="isLoading"
:empty-message="$t('admin.roles.empty')"
@row-click="openEdit"
>
<template #cell-isSystem="{ item }">
<span
v-if="item.isSystem"
class="rounded-full bg-primary-100 px-2 py-0.5 text-xs font-semibold text-primary-600"
>
{{ $t('admin.roles.system') }}
</span>
</template>
<template #cell-permissions="{ item }">
<span class="text-neutral-600">{{ item.permissions.length }}</span>
</template>
<template #actions="{ item }">
<MalioButtonIcon
v-if="!item.isSystem"
icon="mdi:delete-outline"
:aria-label="$t('common.delete')"
variant="ghost"
icon-size="20"
button-class="text-neutral-400 hover:text-red-500"
@click.stop="handleDelete(item.id)"
/>
</template>
</DataTable>
<RoleDrawer
v-model="drawerOpen"
:item="selectedItem"
:permissions="permissions"
@saved="onSaved"
/>
</div>
</template>
<script setup lang="ts">
import type { Role } from '~/modules/core/services/roles'
import { useRoleService } from '~/modules/core/services/roles'
import type { Permission } from '~/modules/core/services/permissions'
import { usePermissionService } from '~/modules/core/services/permissions'
import type { DataTableColumn } from '~/components/ui/DataTable.vue'
const { t } = useI18n()
const columns = computed<DataTableColumn[]>(() => [
{ key: 'label', label: t('admin.roles.label'), primary: true },
{ key: 'code', label: t('admin.roles.code') },
{ key: 'permissions', label: t('admin.roles.permissions') },
{ key: 'isSystem', label: '' },
])
const roleService = useRoleService()
const permissionService = usePermissionService()
const items = ref<Role[]>([])
const permissions = ref<Permission[]>([])
const isLoading = ref(true)
const drawerOpen = ref(false)
const selectedItem = ref<Role | null>(null)
async function loadItems() {
isLoading.value = true
try {
items.value = await roleService.list()
} finally {
isLoading.value = false
}
}
async function loadPermissions() {
permissions.value = await permissionService.list()
}
function openCreate() {
selectedItem.value = null
drawerOpen.value = true
}
function openEdit(item: Role) {
selectedItem.value = item
drawerOpen.value = true
}
async function handleDelete(id: number) {
await roleService.remove(id)
await loadItems()
}
async function onSaved() {
await loadItems()
}
onMounted(() => {
loadItems()
loadPermissions()
})
</script>
+186
View File
@@ -0,0 +1,186 @@
<template>
<MalioDrawer v-model="isOpen">
<template #header>
<h2 class="text-xl font-bold">
{{ isEditing ? $t('admin.roles.editRole') : $t('admin.roles.addRole') }}
</h2>
</template>
<form class="flex flex-col gap-3" @submit.prevent="handleSubmit">
<MalioInputText
v-model="form.code"
:label="$t('admin.roles.code')"
input-class="w-full"
:disabled="isEditing"
:hint="isEditing ? $t('admin.roles.codeImmutable') : $t('admin.roles.codeHint')"
:error="touched.code && !codeValid ? $t('admin.roles.codeInvalid') : ''"
@blur="touched.code = true"
/>
<MalioInputText
v-model="form.label"
:label="$t('admin.roles.label')"
input-class="w-full"
:error="touched.label && !form.label.trim() ? $t('admin.roles.labelRequired') : ''"
@blur="touched.label = true"
/>
<MalioInputTextArea
v-model="form.description"
:label="$t('admin.roles.description')"
input-class="w-full"
/>
<div class="mt-2">
<label class="text-sm font-semibold text-neutral-700">
{{ $t('admin.roles.permissions') }}
</label>
<p v-if="permissions.length === 0" class="mt-2 text-xs text-neutral-400">
{{ $t('admin.roles.noPermissions') }}
</p>
<div
v-for="group in groupedPermissions"
:key="group.module"
class="mt-3 rounded-lg border border-neutral-200 p-3"
>
<p class="mb-2 text-xs font-bold uppercase tracking-wide text-neutral-500">
{{ group.module }}
</p>
<div class="flex flex-col gap-2">
<label
v-for="perm in group.permissions"
:key="perm.id"
class="flex items-start gap-2 text-sm text-neutral-700"
>
<input
v-model="form.permissions"
type="checkbox"
:value="perm['@id']"
class="mt-0.5 rounded border-neutral-300"
/>
<span>
{{ perm.label }}
<span class="block text-xs text-neutral-400">{{ perm.code }}</span>
</span>
</label>
</div>
</div>
</div>
<div class="mt-4 flex justify-end">
<MalioButton
:label="$t('common.save')"
button-class="w-auto px-6"
:disabled="isSubmitting"
@click="handleSubmit"
/>
</div>
</form>
</MalioDrawer>
</template>
<script setup lang="ts">
import type { Role, RoleWrite } from '~/modules/core/services/roles'
import { useRoleService } from '~/modules/core/services/roles'
import type { Permission } from '~/modules/core/services/permissions'
const props = defineProps<{
modelValue: boolean
item: Role | null
permissions: Permission[]
}>()
const emit = defineEmits<{
(e: 'update:modelValue', value: boolean): void
(e: 'saved'): void
}>()
const isOpen = computed({
get: () => props.modelValue,
set: (v) => emit('update:modelValue', v),
})
const isEditing = computed(() => !!props.item)
const isSubmitting = ref(false)
const form = reactive({
code: '',
label: '',
description: '',
permissions: [] as string[],
})
const touched = reactive({
code: false,
label: false,
})
const codeValid = computed(() => /^[a-z][a-z0-9_]*$/.test(form.code))
const groupedPermissions = computed(() => {
const byModule = new Map<string, Permission[]>()
for (const perm of props.permissions) {
const list = byModule.get(perm.module) ?? []
list.push(perm)
byModule.set(perm.module, list)
}
return [...byModule.entries()]
.map(([module, permissions]) => ({ module, permissions }))
.sort((a, b) => a.module.localeCompare(b.module))
})
watch(() => props.modelValue, (open) => {
if (open) {
if (props.item) {
form.code = props.item.code
form.label = props.item.label
form.description = props.item.description ?? ''
form.permissions = props.item.permissions
.map((p) => p['@id'])
.filter((iri): iri is string => !!iri)
} else {
form.code = ''
form.label = ''
form.description = ''
form.permissions = []
}
touched.code = false
touched.label = false
}
})
const { create, update } = useRoleService()
async function handleSubmit() {
touched.code = true
touched.label = true
if (!form.label.trim()) {
return
}
if (!isEditing.value && !codeValid.value) {
return
}
isSubmitting.value = true
try {
if (isEditing.value && props.item) {
const payload: Partial<RoleWrite> = {
label: form.label.trim(),
description: form.description.trim() || null,
permissions: form.permissions,
}
await update(props.item.id, payload)
} else {
const payload: RoleWrite = {
code: form.code.trim(),
label: form.label.trim(),
description: form.description.trim() || null,
permissions: form.permissions,
}
await create(payload)
}
emit('saved')
isOpen.value = false
} finally {
isSubmitting.value = false
}
}
</script>
+51
View File
@@ -195,6 +195,57 @@
"addUser": "Ajouter un utilisateur",
"editUser": "Modifier un utilisateur"
},
"admin": {
"roles": {
"title": "Rôles",
"addRole": "Ajouter un rôle",
"editRole": "Modifier un rôle",
"empty": "Aucun rôle trouvé.",
"system": "Système",
"code": "Code",
"codeHint": "Identifiant technique en snake_case (immuable).",
"codeImmutable": "Le code ne peut pas être modifié après création.",
"codeInvalid": "Code invalide (attendu snake_case : minuscules, chiffres et underscores).",
"label": "Libellé",
"labelRequired": "Le libellé est requis.",
"description": "Description",
"permissions": "Permissions",
"noPermissions": "Aucune permission disponible.",
"created": "Rôle créé avec succès.",
"updated": "Rôle mis à jour avec succès.",
"deleted": "Rôle supprimé avec succès."
},
"audit": {
"title": "Audit",
"empty": "Aucune entrée d'audit trouvée.",
"date": "Date",
"performedBy": "Utilisateur",
"entityType": "Type d'entité",
"action": "Action",
"entityId": "Identifiant",
"filterEntityType": "Type d'entité",
"filterEntityTypeAll": "Tous les types",
"filterAction": "Action",
"filterActionAll": "Toutes les actions",
"previous": "Précédent",
"next": "Suivant",
"page": "Page {page}"
}
},
"audit": {
"entity": {
"core": {
"User": "Utilisateur",
"Role": "Rôle",
"Permission": "Permission"
}
},
"action": {
"create": "Création",
"update": "Modification",
"delete": "Suppression"
}
},
"timeEntries": {
"created": "Temps enregistré",
"updated": "Temps modifié",
@@ -0,0 +1,27 @@
export function usePermissions() {
const auth = useAuthStore()
function isAdmin(): boolean {
return auth.user?.roles?.includes('ROLE_ADMIN') ?? false
}
function can(code: string): boolean {
if (!auth.user) {
return false
}
if (isAdmin()) {
return true
}
return auth.user.effectivePermissions?.includes(code) ?? false
}
function canAny(codes: string[]): boolean {
return codes.some((c) => can(c))
}
function canAll(codes: string[]): boolean {
return codes.every((c) => can(c))
}
return { can, canAny, canAll, isAdmin }
}
+1
View File
@@ -0,0 +1 @@
export default defineNuxtConfig({})
@@ -0,0 +1,65 @@
import type { HydraCollection } from '~/utils/api'
import { extractHydraMembers } from '~/utils/api'
export type AuditLogAction = 'create' | 'update' | 'delete'
export type AuditLogItem = {
id: string
'@id'?: string
entityType: string
entityId: string
action: AuditLogAction
changes: Record<string, unknown>
performedBy: string
performedAt: string
ipAddress: string | null
requestId: string | null
}
export type AuditLogQuery = {
page?: number
entityType?: string
action?: AuditLogAction
}
export type AuditLogPage = {
items: AuditLogItem[]
totalItems: number
}
export type AuditLogEntityTypes = {
'@id'?: string
entityTypes: string[]
}
export function useAuditLogService() {
const api = useApi()
async function list(params: AuditLogQuery = {}): Promise<AuditLogPage> {
const query: Record<string, unknown> = {}
if (params.page !== undefined) {
query.page = params.page
}
if (params.entityType) {
query.entity_type = params.entityType
}
if (params.action) {
query.action = params.action
}
const data = await api.get<HydraCollection<AuditLogItem>>('/audit-logs', query)
return {
items: extractHydraMembers(data),
totalItems: data['hydra:totalItems'] ?? data['totalItems'] ?? 0,
}
}
async function entityTypes(): Promise<string[]> {
// `/audit-log-entity-types` is a single API Platform item resource
// (not a hydra collection): it returns `{ entityTypes: string[] }`.
const data = await api.get<AuditLogEntityTypes>('/audit-log-entity-types')
return data.entityTypes ?? []
}
return { list, entityTypes }
}
@@ -0,0 +1,22 @@
import type { HydraCollection } from '~/utils/api'
import { extractHydraMembers } from '~/utils/api'
export type Permission = {
id: number
'@id'?: string
code: string
label: string
module: string
orphan?: boolean
}
export function usePermissionService() {
const api = useApi()
async function list(): Promise<Permission[]> {
const data = await api.get<HydraCollection<Permission>>('/permissions')
return extractHydraMembers(data)
}
return { list }
}
+50
View File
@@ -0,0 +1,50 @@
import type { Permission } from './permissions'
import type { HydraCollection } from '~/utils/api'
import { extractHydraMembers } from '~/utils/api'
export type Role = {
id: number
'@id'?: string
code: string
label: string
description?: string | null
isSystem: boolean
permissions: Permission[]
}
export type RoleWrite = {
code?: string
label: string
description?: string | null
/** IRIs of the granted permissions (e.g. /api/permissions/3). */
permissions: string[]
}
export function useRoleService() {
const api = useApi()
async function list(): Promise<Role[]> {
const data = await api.get<HydraCollection<Role>>('/roles')
return extractHydraMembers(data)
}
async function create(payload: RoleWrite): Promise<Role> {
return api.post<Role>('/roles', payload as Record<string, unknown>, {
toastSuccessKey: 'admin.roles.created',
})
}
async function update(id: number, payload: Partial<RoleWrite>): Promise<Role> {
return api.patch<Role>(`/roles/${id}`, payload as Record<string, unknown>, {
toastSuccessKey: 'admin.roles.updated',
})
}
async function remove(id: number): Promise<void> {
await api.delete(`/roles/${id}`, {}, {
toastSuccessKey: 'admin.roles.deleted',
})
}
return { list, create, update, remove }
}
+15 -1
View File
@@ -6,7 +6,7 @@
<div class="mt-6 border-b border-neutral-200 overflow-x-auto">
<nav class="flex gap-4 sm:gap-6">
<button
v-for="tab in tabs"
v-for="tab in visibleTabs"
:key="tab.key"
class="whitespace-nowrap px-1 pb-3 text-sm font-semibold transition"
:class="activeTab === tab.key
@@ -27,6 +27,8 @@
<AdminPriorityTab v-if="activeTab === 'priorities'" />
<AdminTagTab v-if="activeTab === 'tags'" />
<AdminUserTab v-if="activeTab === 'users'" />
<AdminRoleTab v-if="activeTab === 'roles' && canViewRoles" />
<AdminAuditTab v-if="activeTab === 'audit' && canViewAudit" />
<AdminGiteaTab v-if="activeTab === 'gitea'" />
<AdminBookStackTab v-if="activeTab === 'bookstack'" />
<AdminZimbraTab v-if="activeTab === 'zimbra'" />
@@ -41,6 +43,12 @@
definePageMeta({ middleware: ['admin'] })
useHead({ title: 'Administration' })
const { can } = usePermissions()
const { t } = useI18n()
const canViewRoles = computed(() => can('core.roles.view'))
const canViewAudit = computed(() => can('core.audit_log.view'))
const tabs = [
{ key: 'clients', label: 'Clients' },
{ key: 'workflows', label: 'Workflows' },
@@ -48,6 +56,8 @@ const tabs = [
{ key: 'priorities', label: 'Priorités' },
{ key: 'tags', label: 'Tags' },
{ key: 'users', label: 'Utilisateurs' },
{ key: 'roles', label: t('admin.roles.title'), permission: 'core.roles.view' },
{ key: 'audit', label: t('admin.audit.title'), permission: 'core.audit_log.view' },
{ key: 'gitea', label: 'Gitea' },
{ key: 'bookstack', label: 'BookStack' },
{ key: 'zimbra', label: 'Zimbra' },
@@ -58,5 +68,9 @@ const tabs = [
type TabKey = typeof tabs[number]['key']
const visibleTabs = computed(() =>
tabs.filter((tab) => !('permission' in tab) || can(tab.permission)),
)
const activeTab = ref<TabKey>('clients')
</script>
+1
View File
@@ -7,6 +7,7 @@ export type UserData = {
firstName?: string | null
lastName?: string | null
roles: string[]
effectivePermissions?: string[]
avatarUrl?: string | null
apiToken?: string | null
// HR / absence management
+74
View File
@@ -0,0 +1,74 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* RBAC fin (LST-57) : permission, role and their many-to-many relations with user.
* Additive only — no DROP/ALTER on existing tables.
*/
final class Version20260619145109 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add RBAC permission and role entities with user relations (additive)';
}
public function up(Schema $schema): void
{
$this->addSql('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 NOT NULL, PRIMARY KEY (id))');
$this->addSql('CREATE UNIQUE INDEX UNIQ_E04992AA77153098 ON permission (code)');
$this->addSql('CREATE INDEX idx_permission_module ON permission (module)');
$this->addSql('CREATE INDEX idx_permission_orphan ON permission (orphan)');
$this->addSql('COMMENT ON COLUMN permission.code IS \'Permission code (module.resource[.sub].action)\'');
$this->addSql('COMMENT ON COLUMN permission.label IS \'Human-readable permission label\'');
$this->addSql('COMMENT ON COLUMN permission.module IS \'Owning module id (e.g. core)\'');
$this->addSql('COMMENT ON COLUMN permission.orphan IS \'True when the permission is no longer declared by any active module\'');
$this->addSql('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 NOT NULL, PRIMARY KEY (id))');
$this->addSql('CREATE UNIQUE INDEX UNIQ_57698A6A77153098 ON "role" (code)');
$this->addSql('CREATE INDEX idx_role_is_system ON "role" (is_system)');
$this->addSql('COMMENT ON COLUMN "role".code IS \'Immutable role code (snake_case)\'');
$this->addSql('COMMENT ON COLUMN "role".label IS \'Human-readable role label\'');
$this->addSql('COMMENT ON COLUMN "role".description IS \'Optional role description\'');
$this->addSql('COMMENT ON COLUMN "role".is_system IS \'True for built-in roles that cannot be deleted\'');
$this->addSql('CREATE TABLE role_permission (role_id INT NOT NULL, permission_id INT NOT NULL, PRIMARY KEY (role_id, permission_id))');
$this->addSql('CREATE INDEX IDX_6F7DF886D60322AC ON role_permission (role_id)');
$this->addSql('CREATE INDEX IDX_6F7DF886FED90CCA ON role_permission (permission_id)');
$this->addSql('CREATE TABLE user_role (user_id INT NOT NULL, role_id INT NOT NULL, PRIMARY KEY (user_id, role_id))');
$this->addSql('CREATE INDEX IDX_2DE8C6A3A76ED395 ON user_role (user_id)');
$this->addSql('CREATE INDEX IDX_2DE8C6A3D60322AC ON user_role (role_id)');
$this->addSql('CREATE TABLE user_permission (user_id INT NOT NULL, permission_id INT NOT NULL, PRIMARY KEY (user_id, permission_id))');
$this->addSql('CREATE INDEX IDX_472E5446A76ED395 ON user_permission (user_id)');
$this->addSql('CREATE INDEX IDX_472E5446FED90CCA ON user_permission (permission_id)');
$this->addSql('ALTER TABLE role_permission ADD CONSTRAINT FK_6F7DF886D60322AC FOREIGN KEY (role_id) REFERENCES "role" (id) ON DELETE CASCADE');
$this->addSql('ALTER TABLE role_permission ADD CONSTRAINT FK_6F7DF886FED90CCA FOREIGN KEY (permission_id) REFERENCES permission (id) ON DELETE CASCADE');
$this->addSql('ALTER TABLE user_role ADD CONSTRAINT FK_2DE8C6A3A76ED395 FOREIGN KEY (user_id) REFERENCES "user" (id) ON DELETE CASCADE');
$this->addSql('ALTER TABLE user_role ADD CONSTRAINT FK_2DE8C6A3D60322AC FOREIGN KEY (role_id) REFERENCES "role" (id) ON DELETE CASCADE');
$this->addSql('ALTER TABLE user_permission ADD CONSTRAINT FK_472E5446A76ED395 FOREIGN KEY (user_id) REFERENCES "user" (id) ON DELETE CASCADE');
$this->addSql('ALTER TABLE user_permission ADD CONSTRAINT FK_472E5446FED90CCA FOREIGN KEY (permission_id) REFERENCES permission (id) ON DELETE CASCADE');
}
public function down(Schema $schema): void
{
$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 role_permission');
$this->addSql('DROP TABLE user_role');
$this->addSql('DROP TABLE user_permission');
$this->addSql('DROP TABLE permission');
$this->addSql('DROP TABLE "role"');
}
}
+56
View File
@@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Audit log (LST-61) : append-only `audit_log` table.
*
* Not managed by Doctrine ORM (no entity). Written via raw DBAL by the
* AuditLogWriter on a dedicated `audit` connection to avoid re-entrant
* flushes from the Doctrine listener. Columns are lowercase snake_case.
* Additive only no DROP/ALTER on existing tables.
*/
final class Version20260619185448 extends AbstractMigration
{
public function getDescription(): string
{
return 'Audit log: create append-only audit_log table + indexes (additive)';
}
public function up(Schema $schema): void
{
$this->addSql(<<<'SQL'
CREATE TABLE audit_log (
id uuid NOT NULL,
entity_type VARCHAR(100) NOT NULL,
entity_id VARCHAR(64) NOT NULL,
action VARCHAR(10) NOT NULL,
changes JSONB NOT NULL DEFAULT '{}'::jsonb,
performed_by VARCHAR(100) NOT NULL,
performed_at TIMESTAMP(6) WITH TIME ZONE NOT NULL,
ip_address VARCHAR(45) DEFAULT NULL,
request_id VARCHAR(36) DEFAULT NULL,
PRIMARY KEY(id)
)
SQL);
$this->addSql('CREATE INDEX idx_audit_entity_time ON audit_log (entity_type, entity_id, performed_at)');
$this->addSql('CREATE INDEX idx_audit_performer ON audit_log (performed_by, performed_at)');
$this->addSql('CREATE INDEX idx_audit_time ON audit_log (performed_at)');
$this->addSql("COMMENT ON COLUMN audit_log.entity_type IS 'Audited entity type, format module.Entity (e.g. core.User)'");
$this->addSql("COMMENT ON COLUMN audit_log.entity_id IS 'Audited entity identifier (int or composite key serialized)'");
$this->addSql("COMMENT ON COLUMN audit_log.action IS 'create|update|delete'");
$this->addSql("COMMENT ON COLUMN audit_log.changes IS 'JSON diff: {field:{old,new}} for update, full snapshot for create/delete'");
$this->addSql("COMMENT ON COLUMN audit_log.performed_by IS 'User identifier or system'");
$this->addSql("COMMENT ON COLUMN audit_log.request_id IS 'UUID shared by all audit rows of a single HTTP request (null in CLI)'");
}
public function down(Schema $schema): void
{
$this->addSql('DROP TABLE audit_log');
}
}
+2 -2
View File
@@ -5,8 +5,8 @@ declare(strict_types=1);
namespace App\Command;
use App\Enum\AbsenceType;
use App\Module\Core\Infrastructure\Doctrine\DoctrineUserRepository;
use App\Repository\AbsenceBalanceRepository;
use App\Repository\UserRepository;
use App\Service\AbsenceBalanceService;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
@@ -37,7 +37,7 @@ use function sprintf;
class AccrueLeaveCommand extends Command
{
public function __construct(
private readonly UserRepository $userRepository,
private readonly DoctrineUserRepository $userRepository,
private readonly AbsenceBalanceRepository $balanceRepository,
private readonly AbsenceBalanceService $balanceService,
private readonly EntityManagerInterface $entityManager,
+2 -2
View File
@@ -4,7 +4,7 @@ declare(strict_types=1);
namespace App\Command;
use App\Repository\UserRepository;
use App\Module\Core\Infrastructure\Doctrine\DoctrineUserRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
@@ -22,7 +22,7 @@ use function sprintf;
class GenerateApiTokenCommand extends Command
{
public function __construct(
private readonly UserRepository $userRepository,
private readonly DoctrineUserRepository $userRepository,
private readonly EntityManagerInterface $entityManager,
) {
parent::__construct();
@@ -4,13 +4,13 @@ declare(strict_types=1);
namespace App\Controller\Absence;
use App\Entity\User;
use App\Enum\AbsenceType;
use App\Enum\HalfDay;
use App\Repository\AbsenceBalanceRepository;
use App\Repository\AbsencePolicyRepository;
use App\Service\AbsenceBalanceService;
use App\Service\AbsenceDayCalculator;
use App\Shared\Domain\Contract\UserInterface;
use DateTimeImmutable;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Bundle\SecurityBundle\Security;
@@ -71,7 +71,7 @@ class AbsencePreviewController extends AbstractController
);
$user = $this->security->getUser();
assert($user instanceof User);
assert($user instanceof UserInterface);
$available = null;
$projectedAvailable = null;
@@ -9,7 +9,7 @@ use App\Entity\Task;
use App\Entity\TaskGroup;
use App\Entity\TaskMailLink;
use App\Entity\TaskStatus;
use App\Entity\User;
use App\Module\Core\Domain\Entity\User;
use App\Repository\MailMessageRepository;
use App\Repository\TaskRepository;
use App\Security\MailAccessChecker;
+4 -4
View File
@@ -4,8 +4,8 @@ declare(strict_types=1);
namespace App\Controller;
use App\Entity\User;
use App\Repository\NotificationRepository;
use App\Module\Core\Infrastructure\Doctrine\DoctrineNotificationRepository;
use App\Shared\Domain\Contract\UserInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
@@ -14,14 +14,14 @@ use Symfony\Component\Security\Http\Attribute\IsGranted;
class MarkAllReadController extends AbstractController
{
public function __construct(
private readonly NotificationRepository $notificationRepository,
private readonly DoctrineNotificationRepository $notificationRepository,
) {}
#[Route('/api/notifications/mark-all-read', name: 'notification_mark_all_read', methods: ['POST'], priority: 1)]
#[IsGranted('IS_AUTHENTICATED_FULLY')]
public function __invoke(): Response
{
/** @var User $user */
/** @var UserInterface $user */
$user = $this->getUser();
$this->notificationRepository->markAllReadByUser($user);
@@ -4,8 +4,8 @@ declare(strict_types=1);
namespace App\Controller;
use App\Entity\User;
use App\Repository\NotificationRepository;
use App\Module\Core\Infrastructure\Doctrine\DoctrineNotificationRepository;
use App\Shared\Domain\Contract\UserInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\Routing\Attribute\Route;
@@ -14,14 +14,14 @@ use Symfony\Component\Security\Http\Attribute\IsGranted;
class NotificationUnreadCountController extends AbstractController
{
public function __construct(
private readonly NotificationRepository $notificationRepository,
private readonly DoctrineNotificationRepository $notificationRepository,
) {}
#[Route('/api/notifications/unread-count', name: 'notification_unread_count', methods: ['GET'], priority: 1)]
#[IsGranted('IS_AUTHENTICATED_FULLY')]
public function __invoke(): JsonResponse
{
/** @var User $user */
/** @var UserInterface $user */
$user = $this->getUser();
$count = $this->notificationRepository->countUnreadByUser($user);
@@ -4,7 +4,7 @@ declare(strict_types=1);
namespace App\Controller;
use App\Entity\User;
use App\Module\Core\Domain\Entity\User;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
+1 -1
View File
@@ -5,7 +5,7 @@ declare(strict_types=1);
namespace App\Controller;
use App\Entity\Project;
use App\Entity\User;
use App\Module\Core\Domain\Entity\User;
use App\Repository\TimeEntryRepository;
use App\Service\TimeEntryExportService;
use DateTimeImmutable;
+1 -1
View File
@@ -4,7 +4,7 @@ declare(strict_types=1);
namespace App\Controller;
use App\Entity\User;
use App\Module\Core\Domain\Entity\User;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
+7 -1
View File
@@ -18,7 +18,6 @@ use App\Entity\TaskRecurrence;
use App\Entity\TaskStatus;
use App\Entity\TaskTag;
use App\Entity\TimeEntry;
use App\Entity\User;
use App\Entity\Workflow;
use App\Entity\ZimbraConfiguration;
use App\Enum\AbsenceStatus;
@@ -26,6 +25,8 @@ use App\Enum\AbsenceType;
use App\Enum\ContractType;
use App\Enum\RecurrenceType;
use App\Enum\StatusCategory;
use App\Module\Core\Application\Rbac\RbacSeeder;
use App\Module\Core\Domain\Entity\User;
use DateTimeImmutable;
use DateTimeZone;
use Doctrine\Bundle\FixturesBundle\Fixture;
@@ -36,6 +37,7 @@ class AppFixtures extends Fixture
{
public function __construct(
private readonly UserPasswordHasherInterface $passwordHasher,
private readonly RbacSeeder $rbacSeeder,
) {}
public function load(ObjectManager $manager): void
@@ -751,5 +753,9 @@ class AppFixtures extends Fixture
$manager->persist($pendingMarriage);
$manager->flush();
// Seed des rôles système RBAC (admin, user). Idempotent ; aucune matrice
// métier attachée (cf. Décision 4 : les modules métier arrivent en 2.x).
$this->rbacSeeder->ensureSystemRoles();
}
}
+5 -4
View File
@@ -10,6 +10,7 @@ use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use App\Enum\AbsenceType;
use App\Repository\AbsenceBalanceRepository;
use App\Shared\Domain\Contract\UserInterface;
use App\State\AbsenceBalanceProvider;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
@@ -45,10 +46,10 @@ class AbsenceBalance
#[Groups(['absence_balance:read'])]
private ?int $id = null;
#[ORM\ManyToOne(targetEntity: User::class)]
#[ORM\ManyToOne(targetEntity: UserInterface::class)]
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
#[Groups(['absence_balance:read'])]
private ?User $user = null;
private ?UserInterface $user = null;
#[ORM\Column(type: Types::STRING, length: 32, enumType: AbsenceType::class)]
#[Groups(['absence_balance:read'])]
@@ -110,12 +111,12 @@ class AbsenceBalance
return $this->id;
}
public function getUser(): ?User
public function getUser(): ?UserInterface
{
return $this->user;
}
public function setUser(?User $user): static
public function setUser(?UserInterface $user): static
{
$this->user = $user;
+9 -8
View File
@@ -14,6 +14,7 @@ use App\Enum\AbsenceStatus;
use App\Enum\AbsenceType;
use App\Enum\HalfDay;
use App\Repository\AbsenceRequestRepository;
use App\Shared\Domain\Contract\UserInterface;
use App\State\AbsenceCancelProcessor;
use App\State\AbsenceRequestProcessor;
use App\State\AbsenceRequestProvider;
@@ -73,10 +74,10 @@ class AbsenceRequest
#[Groups(['absence_request:read'])]
private ?int $id = null;
#[ORM\ManyToOne(targetEntity: User::class)]
#[ORM\ManyToOne(targetEntity: UserInterface::class)]
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
#[Groups(['absence_request:read'])]
private ?User $user = null;
private ?UserInterface $user = null;
#[ORM\Column(type: Types::STRING, length: 32, enumType: AbsenceType::class)]
#[Groups(['absence_request:read', 'absence_request:write'])]
@@ -130,10 +131,10 @@ class AbsenceRequest
#[Groups(['absence_request:read'])]
private ?DateTimeImmutable $reviewedAt = null;
#[ORM\ManyToOne(targetEntity: User::class)]
#[ORM\ManyToOne(targetEntity: UserInterface::class)]
#[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')]
#[Groups(['absence_request:read'])]
private ?User $reviewedBy = null;
private ?UserInterface $reviewedBy = null;
#[Groups(['absence_request:read'])]
public function getLabel(): ?string
@@ -156,12 +157,12 @@ class AbsenceRequest
return $this->id;
}
public function getUser(): ?User
public function getUser(): ?UserInterface
{
return $this->user;
}
public function setUser(?User $user): static
public function setUser(?UserInterface $user): static
{
$this->user = $user;
@@ -312,12 +313,12 @@ class AbsenceRequest
return $this;
}
public function getReviewedBy(): ?User
public function getReviewedBy(): ?UserInterface
{
return $this->reviewedBy;
}
public function setReviewedBy(?User $reviewedBy): static
public function setReviewedBy(?UserInterface $reviewedBy): static
{
$this->reviewedBy = $reviewedBy;
+10 -9
View File
@@ -16,6 +16,7 @@ use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use App\Repository\TaskRepository;
use App\Shared\Domain\Contract\UserInterface;
use App\State\TaskCalendarProcessor;
use App\State\TaskNumberProcessor;
use DateTimeImmutable;
@@ -80,13 +81,13 @@ class Task
#[Groups(['task:read', 'task:write'])]
private ?TaskPriority $priority = null;
#[ORM\ManyToOne(targetEntity: User::class)]
#[ORM\ManyToOne(targetEntity: UserInterface::class)]
#[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')]
#[Groups(['task:read', 'task:write'])]
private ?User $assignee = null;
private ?UserInterface $assignee = null;
/** @var Collection<int, User> */
#[ORM\ManyToMany(targetEntity: User::class)]
/** @var Collection<int, UserInterface> */
#[ORM\ManyToMany(targetEntity: UserInterface::class)]
#[ORM\JoinTable(
name: 'task_collaborator',
joinColumns: [new ORM\JoinColumn(name: 'task_id', referencedColumnName: 'id', onDelete: 'CASCADE')],
@@ -239,25 +240,25 @@ class Task
return $this;
}
public function getAssignee(): ?User
public function getAssignee(): ?UserInterface
{
return $this->assignee;
}
public function setAssignee(?User $assignee): static
public function setAssignee(?UserInterface $assignee): static
{
$this->assignee = $assignee;
return $this;
}
/** @return Collection<int, User> */
/** @return Collection<int, UserInterface> */
public function getCollaborators(): Collection
{
return $this->collaborators;
}
public function addCollaborator(User $user): static
public function addCollaborator(UserInterface $user): static
{
if (!$this->collaborators->contains($user)) {
$this->collaborators->add($user);
@@ -266,7 +267,7 @@ class Task
return $this;
}
public function removeCollaborator(User $user): static
public function removeCollaborator(UserInterface $user): static
{
$this->collaborators->removeElement($user);
+5 -4
View File
@@ -12,6 +12,7 @@ use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Post;
use App\EventListener\TaskDocumentListener;
use App\Shared\Domain\Contract\UserInterface;
use App\State\TaskDocumentProcessor;
use App\State\TaskDocumentProvider;
use DateTimeImmutable;
@@ -77,10 +78,10 @@ class TaskDocument
#[Groups(['task_document:read', 'task:read'])]
private ?DateTimeImmutable $createdAt = null;
#[ORM\ManyToOne(targetEntity: User::class)]
#[ORM\ManyToOne(targetEntity: UserInterface::class)]
#[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')]
#[Groups(['task_document:read', 'task:read'])]
private ?User $uploadedBy = null;
private ?UserInterface $uploadedBy = null;
public function getId(): ?int
{
@@ -176,12 +177,12 @@ class TaskDocument
return $this;
}
public function getUploadedBy(): ?User
public function getUploadedBy(): ?UserInterface
{
return $this->uploadedBy;
}
public function setUploadedBy(?User $uploadedBy): static
public function setUploadedBy(?UserInterface $uploadedBy): static
{
$this->uploadedBy = $uploadedBy;
+5 -4
View File
@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Entity;
use App\Repository\TaskMailLinkRepository;
use App\Shared\Domain\Contract\UserInterface;
use DateTimeImmutable;
use Doctrine\ORM\Mapping as ORM;
@@ -29,9 +30,9 @@ class TaskMailLink
#[ORM\Column(type: 'datetimetz_immutable')]
private DateTimeImmutable $linkedAt;
#[ORM\ManyToOne(targetEntity: User::class)]
#[ORM\ManyToOne(targetEntity: UserInterface::class)]
#[ORM\JoinColumn(name: 'linked_by_id', referencedColumnName: 'id', nullable: true, onDelete: 'SET NULL')]
private ?User $linkedBy = null;
private ?UserInterface $linkedBy = null;
public function getId(): ?int
{
@@ -74,12 +75,12 @@ class TaskMailLink
return $this;
}
public function getLinkedBy(): ?User
public function getLinkedBy(): ?UserInterface
{
return $this->linkedBy;
}
public function setLinkedBy(?User $linkedBy): static
public function setLinkedBy(?UserInterface $linkedBy): static
{
$this->linkedBy = $linkedBy;
+5 -4
View File
@@ -14,6 +14,7 @@ use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use App\Repository\TimeEntryRepository;
use App\Shared\Domain\Contract\UserInterface;
use App\State\ActiveTimeEntryProvider;
use DateTimeImmutable;
use Doctrine\Common\Collections\ArrayCollection;
@@ -77,10 +78,10 @@ class TimeEntry
#[Groups(['time_entry:read', 'time_entry:write'])]
private ?DateTimeImmutable $stoppedAt = null;
#[ORM\ManyToOne(targetEntity: User::class)]
#[ORM\ManyToOne(targetEntity: UserInterface::class)]
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
#[Groups(['time_entry:read', 'time_entry:write'])]
private ?User $user = null;
private ?UserInterface $user = null;
#[ORM\ManyToOne(targetEntity: Project::class)]
#[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')]
@@ -160,12 +161,12 @@ class TimeEntry
return $this;
}
public function getUser(): ?User
public function getUser(): ?UserInterface
{
return $this->user;
}
public function setUser(?User $user): static
public function setUser(?UserInterface $user): static
{
$this->user = $user;
+13 -26
View File
@@ -4,10 +4,9 @@ declare(strict_types=1);
namespace App\EventListener;
use App\Entity\Notification;
use App\Entity\Task;
use App\Entity\User;
use DateTimeImmutable;
use App\Shared\Domain\Contract\NotifierInterface;
use App\Shared\Domain\Contract\UserInterface;
use Doctrine\Bundle\DoctrineBundle\Attribute\AsDoctrineListener;
use Doctrine\ORM\Event\OnFlushEventArgs;
use Doctrine\ORM\Event\PostFlushEventArgs;
@@ -18,15 +17,18 @@ use Symfony\Bundle\SecurityBundle\Security;
#[AsDoctrineListener(event: Events::postFlush)]
final class TaskNotificationListener
{
/** @var list<array{user: User, type: string, task: Task}> */
/** @var list<array{user: UserInterface, type: string, task: Task}> */
private array $pending = [];
public function __construct(private readonly Security $security) {}
public function __construct(
private readonly Security $security,
private readonly NotifierInterface $notifier,
) {}
public function onFlush(OnFlushEventArgs $args): void
{
$actor = $this->security->getUser();
if (!$actor instanceof User) {
if (!$actor instanceof UserInterface) {
return;
}
@@ -38,7 +40,7 @@ final class TaskNotificationListener
continue;
}
$assignee = $entity->getAssignee();
if ($assignee instanceof User && $assignee !== $actor) {
if ($assignee instanceof UserInterface && $assignee !== $actor) {
$this->pending[] = ['user' => $assignee, 'type' => 'task_assigned', 'task' => $entity];
}
}
@@ -53,7 +55,7 @@ final class TaskNotificationListener
continue;
}
$new = $changeSet['assignee'][1];
if ($new instanceof User && $new !== $actor) {
if ($new instanceof UserInterface && $new !== $actor) {
$this->pending[] = ['user' => $new, 'type' => 'task_assigned', 'task' => $entity];
}
}
@@ -68,7 +70,7 @@ final class TaskNotificationListener
continue;
}
foreach ($collection->getInsertDiff() as $user) {
if ($user instanceof User && $user !== $actor) {
if ($user instanceof UserInterface && $user !== $actor) {
$this->pending[] = ['user' => $user, 'type' => 'task_collaborator_added', 'task' => $owner];
}
}
@@ -84,25 +86,10 @@ final class TaskNotificationListener
$pending = $this->pending;
$this->pending = [];
$em = $args->getObjectManager();
foreach ($pending as $item) {
$em->persist($this->buildNotification($item['user'], $item['type'], $item['task']));
[$title, $message] = $this->render($item['type'], $item['task']);
$this->notifier->notify($item['user'], $item['type'], $title, $message);
}
$em->flush();
}
private function buildNotification(User $user, string $type, Task $task): Notification
{
[$title, $message] = $this->render($type, $task);
$notification = new Notification();
$notification->setUser($user);
$notification->setType($type);
$notification->setTitle($title);
$notification->setMessage($message);
$notification->setCreatedAt(new DateTimeImmutable());
return $notification;
}
/**
@@ -9,9 +9,9 @@ use App\Enum\AbsenceStatus;
use App\Enum\AbsenceType;
use App\Enum\HalfDay;
use App\Mcp\Tool\Serializer;
use App\Module\Core\Infrastructure\Doctrine\DoctrineUserRepository;
use App\Repository\AbsencePolicyRepository;
use App\Repository\AbsenceRequestRepository;
use App\Repository\UserRepository;
use App\Service\AbsenceBalanceService;
use App\Service\AbsenceDayCalculator;
use DateTimeImmutable;
@@ -28,7 +28,7 @@ class CreateAbsenceRequestTool
{
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly UserRepository $userRepository,
private readonly DoctrineUserRepository $userRepository,
private readonly AbsencePolicyRepository $policyRepository,
private readonly AbsenceRequestRepository $requestRepository,
private readonly AbsenceDayCalculator $calculator,
@@ -6,8 +6,8 @@ namespace App\Mcp\Tool\Absence;
use App\Enum\AbsenceType;
use App\Mcp\Tool\Serializer;
use App\Module\Core\Infrastructure\Doctrine\DoctrineUserRepository;
use App\Repository\AbsenceBalanceRepository;
use App\Repository\UserRepository;
use InvalidArgumentException;
use Mcp\Capability\Attribute\McpTool;
use Symfony\Bundle\SecurityBundle\Security;
@@ -20,7 +20,7 @@ class ListAbsenceBalancesTool
{
public function __construct(
private readonly AbsenceBalanceRepository $balanceRepository,
private readonly UserRepository $userRepository,
private readonly DoctrineUserRepository $userRepository,
private readonly Security $security,
) {}
@@ -7,8 +7,8 @@ namespace App\Mcp\Tool\Absence;
use App\Enum\AbsenceStatus;
use App\Enum\AbsenceType;
use App\Mcp\Tool\Serializer;
use App\Module\Core\Infrastructure\Doctrine\DoctrineUserRepository;
use App\Repository\AbsenceRequestRepository;
use App\Repository\UserRepository;
use DateTimeImmutable;
use InvalidArgumentException;
use Mcp\Capability\Attribute\McpTool;
@@ -22,7 +22,7 @@ class ListAbsenceRequestsTool
{
public function __construct(
private readonly AbsenceRequestRepository $requestRepository,
private readonly UserRepository $userRepository,
private readonly DoctrineUserRepository $userRepository,
private readonly Security $security,
) {}
@@ -4,11 +4,11 @@ declare(strict_types=1);
namespace App\Mcp\Tool\Absence;
use App\Entity\User;
use App\Enum\AbsenceStatus;
use App\Mcp\Tool\Serializer;
use App\Repository\AbsenceRequestRepository;
use App\Service\AbsenceBalanceService;
use App\Shared\Domain\Contract\UserInterface;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
use InvalidArgumentException;
@@ -49,7 +49,7 @@ class ReviewAbsenceRequestTool
}
$admin = $this->security->getUser();
assert($admin instanceof User);
assert($admin instanceof UserInterface);
if ('approve' === $decision) {
// Never let an approval push the balance below zero (CP only).
+2 -2
View File
@@ -5,7 +5,7 @@ declare(strict_types=1);
namespace App\Mcp\Tool\Reference;
use App\Mcp\Tool\Serializer;
use App\Repository\UserRepository;
use App\Module\Core\Infrastructure\Doctrine\DoctrineUserRepository;
use InvalidArgumentException;
use Mcp\Capability\Attribute\McpTool;
use Symfony\Bundle\SecurityBundle\Security;
@@ -17,7 +17,7 @@ use function sprintf;
class GetUserTool
{
public function __construct(
private readonly UserRepository $userRepository,
private readonly DoctrineUserRepository $userRepository,
private readonly Security $security,
) {}
+2 -2
View File
@@ -4,7 +4,7 @@ declare(strict_types=1);
namespace App\Mcp\Tool\Reference;
use App\Repository\UserRepository;
use App\Module\Core\Infrastructure\Doctrine\DoctrineUserRepository;
use Mcp\Capability\Attribute\McpTool;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
@@ -13,7 +13,7 @@ use Symfony\Component\Security\Core\Exception\AccessDeniedException;
class ListUsersTool
{
public function __construct(
private readonly UserRepository $userRepository,
private readonly DoctrineUserRepository $userRepository,
private readonly Security $security,
) {}
+2 -2
View File
@@ -6,7 +6,7 @@ namespace App\Mcp\Tool\Reference;
use App\Enum\ContractType;
use App\Mcp\Tool\Serializer;
use App\Repository\UserRepository;
use App\Module\Core\Infrastructure\Doctrine\DoctrineUserRepository;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
use InvalidArgumentException;
@@ -20,7 +20,7 @@ use function sprintf;
class UpdateUserTool
{
public function __construct(
private readonly UserRepository $userRepository,
private readonly DoctrineUserRepository $userRepository,
private readonly EntityManagerInterface $entityManager,
private readonly Security $security,
) {}
+1 -1
View File
@@ -17,7 +17,7 @@ use App\Entity\TaskPriority;
use App\Entity\TaskStatus;
use App\Entity\TaskTag;
use App\Entity\TimeEntry;
use App\Entity\User;
use App\Module\Core\Domain\Entity\User;
use Doctrine\Common\Collections\Collection;
/**
+2 -2
View File
@@ -6,6 +6,7 @@ namespace App\Mcp\Tool\Task;
use App\Entity\Task;
use App\Mcp\Tool\Serializer;
use App\Module\Core\Infrastructure\Doctrine\DoctrineUserRepository;
use App\Repository\ProjectRepository;
use App\Repository\TaskEffortRepository;
use App\Repository\TaskGroupRepository;
@@ -13,7 +14,6 @@ use App\Repository\TaskPriorityRepository;
use App\Repository\TaskRepository;
use App\Repository\TaskStatusRepository;
use App\Repository\TaskTagRepository;
use App\Repository\UserRepository;
use App\Service\CalDavService;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
@@ -36,7 +36,7 @@ class CreateTaskTool
private readonly TaskEffortRepository $taskEffortRepository,
private readonly TaskGroupRepository $taskGroupRepository,
private readonly TaskTagRepository $taskTagRepository,
private readonly UserRepository $userRepository,
private readonly DoctrineUserRepository $userRepository,
private readonly Security $security,
private readonly CalDavService $calDavService,
) {}
+2 -2
View File
@@ -5,13 +5,13 @@ declare(strict_types=1);
namespace App\Mcp\Tool\Task;
use App\Mcp\Tool\Serializer;
use App\Module\Core\Infrastructure\Doctrine\DoctrineUserRepository;
use App\Repository\TaskEffortRepository;
use App\Repository\TaskGroupRepository;
use App\Repository\TaskPriorityRepository;
use App\Repository\TaskRepository;
use App\Repository\TaskStatusRepository;
use App\Repository\TaskTagRepository;
use App\Repository\UserRepository;
use App\Service\CalDavService;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
@@ -33,7 +33,7 @@ class UpdateTaskTool
private readonly TaskEffortRepository $taskEffortRepository,
private readonly TaskGroupRepository $taskGroupRepository,
private readonly TaskTagRepository $taskTagRepository,
private readonly UserRepository $userRepository,
private readonly DoctrineUserRepository $userRepository,
private readonly Security $security,
private readonly CalDavService $calDavService,
) {}
@@ -6,11 +6,11 @@ namespace App\Mcp\Tool\TimeEntry;
use App\Entity\TimeEntry;
use App\Mcp\Tool\Serializer;
use App\Module\Core\Infrastructure\Doctrine\DoctrineUserRepository;
use App\Repository\ProjectRepository;
use App\Repository\TaskRepository;
use App\Repository\TaskTagRepository;
use App\Repository\TimeEntryRepository;
use App\Repository\UserRepository;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
use InvalidArgumentException;
@@ -25,7 +25,7 @@ class CreateTimeEntryTool
{
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly UserRepository $userRepository,
private readonly DoctrineUserRepository $userRepository,
private readonly ProjectRepository $projectRepository,
private readonly TaskRepository $taskRepository,
private readonly TaskTagRepository $taskTagRepository,
@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace App\Module\Core\Application\DTO;
use DateTimeImmutable;
/**
* DTO de sortie pour une ligne d'audit.
*
* Readonly : aucune mutation possible apres hydration. La resource API
* Platform expose directement ce DTO (pas d'entite sous-jacente car la
* table audit_log n'est pas geree par l'ORM).
*/
final readonly class AuditLogOutput
{
public function __construct(
public string $id,
public string $entityType,
public string $entityId,
public string $action,
/** @var array<string, mixed> */
public array $changes,
public string $performedBy,
public DateTimeImmutable $performedAt,
public ?string $ipAddress,
public ?string $requestId,
) {}
}
@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace App\Module\Core\Application\Rbac;
use App\Module\Core\Domain\Entity\Role;
use App\Module\Core\Domain\Repository\RoleRepositoryInterface;
use App\Module\Core\Domain\Security\SystemRoles;
use Doctrine\ORM\EntityManagerInterface;
final readonly class RbacSeeder
{
public function __construct(
private EntityManagerInterface $em,
private RoleRepositoryInterface $roles,
) {}
/**
* Crée les rôles système s'ils sont absents. Idempotent.
*/
public function ensureSystemRoles(): void
{
$this->ensureRole(SystemRoles::ADMIN_CODE, 'Administrateur', 'Accès complet (bypass RBAC).');
$this->ensureRole(SystemRoles::USER_CODE, 'Utilisateur', 'Rôle de base sans permission spécifique.');
$this->em->flush();
}
private function ensureRole(string $code, string $label, string $description): void
{
if (null !== $this->roles->findByCode($code)) {
return;
}
$this->roles->save(new Role($code, $label, $description, true));
}
}
+42
View File
@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace App\Module\Core;
use App\Shared\Domain\Module\ModuleInterface;
final class CoreModule implements ModuleInterface
{
public static function id(): string
{
return 'core';
}
public static function label(): string
{
return 'Core';
}
public static function isRequired(): bool
{
return true;
}
/**
* Permissions RBAC fin du Module Core (1.2).
*
* @return list<array{code: string, label: string}>
*/
public static function permissions(): array
{
return [
['code' => 'core.users.view', 'label' => 'Voir les utilisateurs'],
['code' => 'core.users.manage', 'label' => 'Gérer les utilisateurs (créer, éditer, supprimer)'],
['code' => 'core.roles.view', 'label' => 'Voir les rôles RBAC'],
['code' => 'core.roles.manage', 'label' => 'Gérer les rôles et permissions'],
['code' => 'core.permissions.view', 'label' => 'Consulter le catalogue des permissions RBAC'],
['code' => 'core.audit_log.view', 'label' => 'Consulter le journal d\'audit'],
];
}
}
@@ -2,13 +2,14 @@
declare(strict_types=1);
namespace App\Entity;
namespace App\Module\Core\Domain\Entity;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use App\Repository\NotificationRepository;
use App\State\NotificationProvider;
use App\Module\Core\Infrastructure\ApiPlatform\State\NotificationProvider;
use App\Module\Core\Infrastructure\Doctrine\DoctrineNotificationRepository;
use App\Shared\Domain\Contract\UserInterface;
use DateTimeImmutable;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
@@ -28,7 +29,7 @@ use Symfony\Component\Serializer\Attribute\Groups;
denormalizationContext: ['groups' => ['notification:write']],
order: ['createdAt' => 'DESC'],
)]
#[ORM\Entity(repositoryClass: NotificationRepository::class)]
#[ORM\Entity(repositoryClass: DoctrineNotificationRepository::class)]
#[ORM\Index(columns: ['user_id'], name: 'idx_notification_user')]
#[ORM\Index(columns: ['user_id', 'is_read'], name: 'idx_notification_user_read')]
class Notification
@@ -39,10 +40,10 @@ class Notification
#[Groups(['notification:read'])]
private ?int $id = null;
#[ORM\ManyToOne(targetEntity: User::class)]
#[ORM\ManyToOne(targetEntity: UserInterface::class)]
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
#[Groups(['notification:read'])]
private ?User $user = null;
private ?UserInterface $user = null;
#[ORM\Column(length: 50)]
#[Groups(['notification:read'])]
@@ -69,12 +70,12 @@ class Notification
return $this->id;
}
public function getUser(): ?User
public function getUser(): ?UserInterface
{
return $this->user;
}
public function setUser(?User $user): static
public function setUser(?UserInterface $user): static
{
$this->user = $user;
@@ -0,0 +1,115 @@
<?php
declare(strict_types=1);
namespace App\Module\Core\Domain\Entity;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use App\Module\Core\Infrastructure\Doctrine\DoctrinePermissionRepository;
use App\Shared\Domain\Attribute\Auditable;
use Doctrine\ORM\Mapping as ORM;
use InvalidArgumentException;
use Symfony\Component\Serializer\Attribute\Groups;
#[Auditable]
#[ORM\Entity(repositoryClass: DoctrinePermissionRepository::class)]
#[ORM\Table(name: 'permission')]
#[ORM\Index(name: 'idx_permission_module', columns: ['module'])]
#[ORM\Index(name: 'idx_permission_orphan', columns: ['orphan'])]
#[ApiResource(
operations: [
new GetCollection(paginationEnabled: false),
new Get(),
],
normalizationContext: ['groups' => ['permission:read']],
security: "is_granted('core.permissions.view') or is_granted('core.users.manage') or is_granted('core.roles.manage')",
)]
class Permission
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
#[Groups(['permission:read', 'role:read'])]
private ?int $id = null;
#[ORM\Column(length: 255, unique: true, options: ['comment' => 'Permission code (module.resource[.sub].action)'])]
#[Groups(['permission:read', 'role:read'])]
private string $code;
#[ORM\Column(length: 255, options: ['comment' => 'Human-readable permission label'])]
#[Groups(['permission:read', 'role:read'])]
private string $label;
#[ORM\Column(length: 100, options: ['comment' => 'Owning module id (e.g. core)'])]
#[Groups(['permission:read', 'role:read'])]
private string $module;
#[ORM\Column(options: ['comment' => 'True when the permission is no longer declared by any active module'])]
#[Groups(['permission:read'])]
private bool $orphan = false;
public function __construct(string $code, string $label, string $module)
{
$code = trim($code);
$label = trim($label);
$module = trim($module);
if ('' === $code || !str_contains($code, '.')) {
throw new InvalidArgumentException(sprintf('Code de permission invalide : "%s" (attendu module.resource.action).', $code));
}
if ('' === $label) {
throw new InvalidArgumentException('Le libellé de permission ne peut pas être vide.');
}
if ('' === $module) {
throw new InvalidArgumentException('Le module de permission ne peut pas être 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;
}
public function markOrphan(): void
{
$this->orphan = true;
}
public function revive(string $label, string $module): void
{
$this->orphan = false;
$this->updateMetadata($label, $module);
}
public function updateMetadata(string $label, string $module): void
{
$this->label = $label;
$this->module = $module;
}
}
+151
View File
@@ -0,0 +1,151 @@
<?php
declare(strict_types=1);
namespace App\Module\Core\Domain\Entity;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use App\Module\Core\Domain\Exception\SystemRoleDeletionException;
use App\Module\Core\Infrastructure\ApiPlatform\State\Processor\RoleProcessor;
use App\Module\Core\Infrastructure\Doctrine\DoctrineRoleRepository;
use App\Shared\Domain\Attribute\Auditable;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use InvalidArgumentException;
use Symfony\Component\Serializer\Attribute\Groups;
use Symfony\Component\Serializer\Attribute\SerializedName;
#[Auditable]
#[ORM\Entity(repositoryClass: DoctrineRoleRepository::class)]
#[ORM\Table(name: '`role`')]
#[ORM\Index(name: 'idx_role_is_system', columns: ['is_system'])]
#[ApiResource(
operations: [
new GetCollection(security: "is_granted('core.roles.view')", paginationEnabled: false),
new Get(security: "is_granted('core.roles.view')"),
new Post(security: "is_granted('core.roles.manage')", processor: RoleProcessor::class),
new Patch(security: "is_granted('core.roles.manage')", processor: RoleProcessor::class),
new Delete(security: "is_granted('core.roles.manage')", processor: RoleProcessor::class),
],
normalizationContext: ['groups' => ['role:read']],
denormalizationContext: ['groups' => ['role:write']],
)]
class Role
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
#[Groups(['role:read'])]
private ?int $id = null;
#[ORM\Column(length: 100, unique: true, options: ['comment' => 'Immutable role code (snake_case)'])]
#[Groups(['role:read', 'role:write'])]
private string $code;
#[ORM\Column(length: 255, options: ['comment' => 'Human-readable role label'])]
#[Groups(['role:read', 'role:write'])]
private string $label;
#[ORM\Column(type: 'text', nullable: true, options: ['comment' => 'Optional role description'])]
#[Groups(['role:read', 'role:write'])]
private ?string $description;
#[ORM\Column(name: 'is_system', options: ['comment' => 'True for built-in roles that cannot be deleted'])]
private bool $isSystem;
/**
* @var Collection<int, Permission>
*/
#[ORM\ManyToMany(targetEntity: Permission::class, fetch: 'EAGER')]
#[ORM\JoinTable(name: 'role_permission')]
#[Groups(['role:read', 'role:write'])]
private Collection $permissions;
public function __construct(string $code, string $label, ?string $description = null, bool $isSystem = false)
{
if (1 !== preg_match('/^[a-z][a-z0-9_]*$/', $code)) {
throw new InvalidArgumentException(sprintf('Code de rôle invalide : "%s" (attendu snake_case).', $code));
}
if ('' === trim($label)) {
throw new InvalidArgumentException('Le libellé de rôle ne peut pas être vide.');
}
$this->code = $code;
$this->label = $label;
$this->description = $description;
$this->isSystem = $isSystem;
$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 setLabel(string $label): void
{
$this->label = $label;
}
public function getDescription(): ?string
{
return $this->description;
}
public function setDescription(?string $description): void
{
$this->description = $description;
}
// PropertyInfo strips the `is` prefix and would expose this field as `system`.
// An explicit SerializedName guarantees the `isSystem` key expected by API clients.
#[Groups(['role:read'])]
#[SerializedName('isSystem')]
public function isSystem(): bool
{
return $this->isSystem;
}
/**
* @return Collection<int, Permission>
*/
public function getPermissions(): Collection
{
return $this->permissions;
}
public function addPermission(Permission $permission): void
{
if (!$this->permissions->contains($permission)) {
$this->permissions->add($permission);
}
}
public function removePermission(Permission $permission): void
{
$this->permissions->removeElement($permission);
}
public function ensureDeletable(): void
{
if ($this->isSystem) {
throw new SystemRoleDeletionException($this->code);
}
}
}
@@ -2,7 +2,7 @@
declare(strict_types=1);
namespace App\Entity;
namespace App\Module\Core\Domain\Entity;
use ApiPlatform\Metadata\ApiProperty;
use ApiPlatform\Metadata\ApiResource;
@@ -12,11 +12,16 @@ use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use App\Enum\ContractType;
use App\Repository\UserRepository;
use App\Module\Core\Infrastructure\ApiPlatform\State\MeProvider;
use App\Module\Core\Infrastructure\ApiPlatform\State\Processor\UserRbacProcessor;
use App\Module\Core\Infrastructure\ApiPlatform\State\UserPasswordHasherProcessor;
use App\Module\Core\Infrastructure\Doctrine\DoctrineUserRepository;
use App\Shared\Domain\Attribute\Auditable;
use App\Shared\Domain\Attribute\AuditIgnore;
use App\Shared\Domain\Contract\UserInterface as SharedUserInterface;
use App\State\MeProvider;
use App\State\UserPasswordHasherProcessor;
use DateTimeImmutable;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
@@ -40,10 +45,23 @@ use Symfony\Component\Serializer\Attribute\Groups;
new Post(security: "is_granted('ROLE_ADMIN')", processor: UserPasswordHasherProcessor::class),
new Patch(security: "is_granted('ROLE_ADMIN')", processor: UserPasswordHasherProcessor::class),
new Delete(security: "is_granted('ROLE_ADMIN')"),
new Get(
uriTemplate: '/users/{id}/rbac',
security: "is_granted('core.users.manage')",
normalizationContext: ['groups' => ['user:rbac:read']],
),
new Patch(
uriTemplate: '/users/{id}/rbac',
security: "is_granted('core.users.manage')",
normalizationContext: ['groups' => ['user:rbac:read']],
denormalizationContext: ['groups' => ['user:rbac:write']],
processor: UserRbacProcessor::class,
),
],
denormalizationContext: ['groups' => ['user:write']],
)]
#[ORM\Entity(repositoryClass: UserRepository::class)]
#[Auditable]
#[ORM\Entity(repositoryClass: DoctrineUserRepository::class)]
#[ORM\Table(name: '`user`')]
class User implements UserInterface, PasswordAuthenticatedUserInterface, SharedUserInterface
{
@@ -72,9 +90,11 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface, SharedU
private array $roles = [];
#[ORM\Column]
#[AuditIgnore]
private ?string $password = null;
#[Groups(['user:write'])]
#[AuditIgnore]
private ?string $plainPassword = null;
#[ORM\Column(type: Types::DATETIME_IMMUTABLE)]
@@ -82,6 +102,7 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface, SharedU
#[ORM\Column(length: 64, unique: true, nullable: true)]
#[Groups(['me:read'])]
#[AuditIgnore]
private ?string $apiToken = null;
#[ORM\Column(length: 255, nullable: true)]
@@ -135,9 +156,27 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface, SharedU
#[Groups(['me:read', 'user:list', 'user:write'])]
private float $initialLeaveBalance = 0.0;
/**
* @var Collection<int, Role>
*/
#[ORM\ManyToMany(targetEntity: Role::class, fetch: 'EAGER')]
#[ORM\JoinTable(name: 'user_role')]
#[Groups(['user:rbac:read', 'user:rbac:write'])]
private Collection $rbacRoles;
/**
* @var Collection<int, Permission>
*/
#[ORM\ManyToMany(targetEntity: Permission::class, fetch: 'EAGER')]
#[ORM\JoinTable(name: 'user_permission')]
#[Groups(['user:rbac:read', 'user:rbac:write'])]
private Collection $directPermissions;
public function __construct()
{
$this->createdAt = new DateTimeImmutable();
$this->createdAt = new DateTimeImmutable();
$this->rbacRoles = new ArrayCollection();
$this->directPermissions = new ArrayCollection();
}
public function getId(): ?int
@@ -373,4 +412,67 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface, SharedU
return $this;
}
/**
* @return Collection<int, Role>
*/
public function getRbacRoles(): Collection
{
return $this->rbacRoles;
}
public function addRbacRole(Role $role): void
{
if (!$this->rbacRoles->contains($role)) {
$this->rbacRoles->add($role);
}
}
public function removeRbacRole(Role $role): void
{
$this->rbacRoles->removeElement($role);
}
/**
* @return Collection<int, Permission>
*/
public function getDirectPermissions(): Collection
{
return $this->directPermissions;
}
public function addDirectPermission(Permission $permission): void
{
if (!$this->directPermissions->contains($permission)) {
$this->directPermissions->add($permission);
}
}
public function removeDirectPermission(Permission $permission): void
{
$this->directPermissions->removeElement($permission);
}
/**
* Permissions effectives = union (rôles RBAC permissions) (permissions directes), triée, dédupliquée.
*
* @return list<string>
*/
#[Groups(['me:read', 'user:rbac:read'])]
public function getEffectivePermissions(): array
{
$codes = [];
foreach ($this->rbacRoles 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;
}
}
@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace App\Module\Core\Domain\Exception;
use DomainException;
final class SystemRoleDeletionException extends DomainException
{
public function __construct(string $code)
{
parent::__construct(sprintf('Le rôle système "%s" ne peut pas être supprimé.', $code));
}
}
@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace App\Module\Core\Domain\Repository;
use App\Module\Core\Domain\Entity\Permission;
interface PermissionRepositoryInterface
{
public function findById(int $id): ?Permission;
public function findByCode(string $code): ?Permission;
/** @return list<Permission> */
public function findAll(): array;
/** @return list<string> */
public function findAllCodes(): array;
public function save(Permission $permission): void;
}
@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace App\Module\Core\Domain\Repository;
use App\Module\Core\Domain\Entity\Role;
interface RoleRepositoryInterface
{
public function findById(int $id): ?Role;
public function findByCode(string $code): ?Role;
/** @return list<Role> */
public function findAll(): array;
public function save(Role $role): void;
}
@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace App\Module\Core\Domain\Repository;
use App\Shared\Domain\Contract\UserInterface;
use DateTimeInterface;
interface UserRepositoryInterface
{
/**
* @return list<UserInterface>
*/
public function findByRole(string $role): array;
/**
* @return list<UserInterface>
*/
public function findActiveEmployees(DateTimeInterface $date): array;
public function findOneByUsername(string $username): ?UserInterface;
}
@@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
namespace App\Module\Core\Domain\Security;
final class SystemRoles
{
public const string ADMIN_CODE = 'admin';
public const string USER_CODE = 'user';
}
@@ -0,0 +1,74 @@
<?php
declare(strict_types=1);
namespace App\Module\Core\Infrastructure\ApiPlatform\Pagination;
use ApiPlatform\State\Pagination\PaginatorInterface;
use ArrayIterator;
use IteratorAggregate;
use Traversable;
/**
* Paginator pour resources alimentees par DBAL (pas par Doctrine ORM).
*
* Implemente PaginatorInterface : API Platform l'introspecte pour generer
* automatiquement la section `hydra:view` (first / next / previous / last)
* dans la reponse JSON-LD. Aucun calcul manuel de liens.
*
* @template T of object
*
* @implements PaginatorInterface<T>
*/
final readonly class DbalPaginator implements PaginatorInterface, IteratorAggregate
{
/**
* @param list<T> $items Items deja decoupes sur la page courante
* @param int $currentPage Page courante (1-indexee)
* @param int $itemsPerPage Limite appliquee a la requete SQL
* @param int $totalItems Resultat du COUNT(*) sans limite
*/
public function __construct(
private array $items,
private int $currentPage,
private int $itemsPerPage,
private int $totalItems,
) {}
public function getCurrentPage(): float
{
return (float) $this->currentPage;
}
public function getLastPage(): float
{
if ($this->itemsPerPage <= 0) {
return 1.0;
}
return (float) max(1, (int) ceil($this->totalItems / $this->itemsPerPage));
}
public function getItemsPerPage(): float
{
return (float) $this->itemsPerPage;
}
public function getTotalItems(): float
{
return (float) $this->totalItems;
}
public function count(): int
{
return count($this->items);
}
/**
* @return Traversable<int, T>
*/
public function getIterator(): Traversable
{
return new ArrayIterator($this->items);
}
}
@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace App\Module\Core\Infrastructure\ApiPlatform\Resource;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use App\Module\Core\Infrastructure\ApiPlatform\State\Provider\AuditLogEntityTypesProvider;
/**
* Retourne la liste des valeurs distinctes de `entity_type` presentes dans
* `audit_log`, pour alimenter le filtre multi-selection cote front (journal
* d'audit). La liste evolue automatiquement avec les nouvelles entites
* `#[Auditable]` au fil des ecritures.
*/
#[ApiResource(
shortName: 'AuditLogEntityTypes',
operations: [
new Get(
uriTemplate: '/audit-log-entity-types',
security: "is_granted('core.audit_log.view')",
provider: AuditLogEntityTypesProvider::class,
),
],
)]
final class AuditLogEntityTypesResource
{
/** @param list<string> $entityTypes */
public function __construct(
public readonly string $id = 'entity-types',
public readonly array $entityTypes = [],
) {}
}
@@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace App\Module\Core\Infrastructure\ApiPlatform\Resource;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use App\Module\Core\Application\DTO\AuditLogOutput;
use App\Module\Core\Infrastructure\ApiPlatform\State\Provider\AuditLogProvider;
/**
* Resource API Platform en lecture seule sur le journal d'audit.
*
* Aucune operation d'ecriture exposee (POST/PUT/PATCH/DELETE -> 405)
* conformement au caractere append-only de la table `audit_log`.
*
* La resource est un simple porteur de metadonnees #[ApiResource] ; le
* provider lit via DBAL et retourne directement des instances du DTO
* `AuditLogOutput` (declare via `output:`). La table n'est pas geree par
* l'ORM : aucune entite Doctrine n'est necessaire ici.
*
* Filtres query-param supportes par le provider :
* ?entity_type=core.User
* ?entity_id=42
* ?action=update
* ?performed_by=admin
* ?performed_at[after]=2026-04-01T00:00:00Z
* ?performed_at[before]=2026-04-30T23:59:59Z
*
* La pagination herite du standard global (10 items / page, max 50, cf.
* `config/packages/api_platform.yaml`). Elle est materialisee par le
* DbalPaginator du provider qui implemente PaginatorInterface API Platform
* genere automatiquement hydra:view sans construction manuelle.
*/
#[ApiResource(
shortName: 'AuditLog',
operations: [
new GetCollection(
uriTemplate: '/audit-logs',
security: "is_granted('core.audit_log.view')",
provider: AuditLogProvider::class,
),
new Get(
uriTemplate: '/audit-logs/{id}',
requirements: ['id' => '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}'],
security: "is_granted('core.audit_log.view')",
provider: AuditLogProvider::class,
),
],
output: AuditLogOutput::class,
)]
final class AuditLogResource {}
@@ -2,12 +2,13 @@
declare(strict_types=1);
namespace App\State;
namespace App\Module\Core\Infrastructure\ApiPlatform\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\Entity\User;
use App\Module\Core\Domain\Entity\User;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException;
/**
* @implements ProviderInterface<User>
@@ -20,7 +21,11 @@ final readonly class MeProvider implements ProviderInterface
public function provide(Operation $operation, array $uriVariables = [], array $context = []): User
{
// @var User $user
return $this->security->getUser();
$user = $this->security->getUser();
if (!$user instanceof User) {
throw new UnauthorizedHttpException('Bearer', 'Not authenticated.');
}
return $user;
}
}
@@ -2,14 +2,14 @@
declare(strict_types=1);
namespace App\State;
namespace App\Module\Core\Infrastructure\ApiPlatform\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\Pagination\Pagination;
use ApiPlatform\State\Pagination\TraversablePaginator;
use ApiPlatform\State\ProviderInterface;
use App\Entity\Notification;
use App\Repository\NotificationRepository;
use App\Module\Core\Domain\Entity\Notification;
use App\Module\Core\Infrastructure\Doctrine\DoctrineNotificationRepository;
use ArrayIterator;
use Doctrine\ORM\Tools\Pagination\Paginator as DoctrinePaginator;
use Symfony\Bundle\SecurityBundle\Security;
@@ -23,7 +23,7 @@ final readonly class NotificationProvider implements ProviderInterface
{
public function __construct(
private Security $security,
private NotificationRepository $notificationRepository,
private DoctrineNotificationRepository $notificationRepository,
private Pagination $pagination,
) {}
@@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace App\Module\Core\Infrastructure\ApiPlatform\State\Processor;
use ApiPlatform\Metadata\DeleteOperationInterface;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Module\Core\Domain\Entity\Role;
use Doctrine\ORM\EntityManagerInterface;
use DomainException;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use function assert;
/**
* @implements ProcessorInterface<Role, null|Role>
*/
final readonly class RoleProcessor implements ProcessorInterface
{
public function __construct(private EntityManagerInterface $em) {}
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): ?Role
{
assert($data instanceof Role);
if ($operation instanceof DeleteOperationInterface) {
try {
$data->ensureDeletable();
} catch (DomainException $e) {
throw new AccessDeniedHttpException($e->getMessage(), $e);
}
$this->em->remove($data);
$this->em->flush();
return null;
}
$this->em->persist($data);
$this->em->flush();
return $data;
}
}
@@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
namespace App\Module\Core\Infrastructure\ApiPlatform\State\Processor;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Module\Core\Domain\Entity\User;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use function assert;
/**
* @implements ProcessorInterface<User, User>
*/
final readonly class UserRbacProcessor implements ProcessorInterface
{
public function __construct(
private EntityManagerInterface $em,
private Security $security,
) {}
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): User
{
assert($data instanceof User);
// Defense-in-depth: a user may never edit their OWN RBAC assignment
// through this endpoint, even with core.users.manage — this prevents
// self-escalation if the permission is ever delegated to a non-admin.
$current = $this->security->getUser();
if ($current instanceof User && $current->getId() === $data->getId()) {
throw new AccessDeniedHttpException('You cannot edit your own RBAC assignment.');
}
$this->em->persist($data);
$this->em->flush();
return $data;
}
}
@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace App\Module\Core\Infrastructure\ApiPlatform\State\Provider;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\Module\Core\Infrastructure\ApiPlatform\Resource\AuditLogEntityTypesResource;
use Doctrine\DBAL\Connection;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
/**
* Provider DBAL : SELECT DISTINCT entity_type FROM audit_log.
*
* @implements ProviderInterface<AuditLogEntityTypesResource>
*/
final readonly class AuditLogEntityTypesProvider implements ProviderInterface
{
public function __construct(
#[Autowire(service: 'doctrine.dbal.default_connection')]
private Connection $connection,
) {}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): AuditLogEntityTypesResource
{
/** @var list<string> $types */
$types = $this->connection
->executeQuery('SELECT DISTINCT entity_type FROM audit_log ORDER BY entity_type ASC')
->fetchFirstColumn()
;
return new AuditLogEntityTypesResource(entityTypes: $types);
}
}
@@ -0,0 +1,247 @@
<?php
declare(strict_types=1);
namespace App\Module\Core\Infrastructure\ApiPlatform\State\Provider;
use ApiPlatform\Metadata\CollectionOperationInterface;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\Pagination\Pagination;
use ApiPlatform\State\ProviderInterface;
use App\Module\Core\Application\DTO\AuditLogOutput;
use App\Module\Core\Infrastructure\ApiPlatform\Pagination\DbalPaginator;
use DateTimeImmutable;
use Doctrine\DBAL\ArrayParameterType;
use Doctrine\DBAL\Connection;
use Doctrine\DBAL\Query\QueryBuilder;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
/**
* Provider API Platform pour la resource AuditLog.
*
* Lit la table `audit_log` via DBAL (pas d'entite ORM). Retourne soit :
* - une instance unique d'AuditLogOutput (operation Get) ;
* - un DbalPaginator de AuditLogOutput (operation GetCollection).
*
* Le paginator implementant PaginatorInterface laisse API Platform generer
* automatiquement la section `hydra:view` : aucune manipulation manuelle.
*
* Connexion DBAL : `default` (lecture aucun besoin de la connexion `audit`
* reservee a l'ecriture hors transaction ORM).
*/
final readonly class AuditLogProvider implements ProviderInterface
{
public function __construct(
#[Autowire(service: 'doctrine.dbal.default_connection')]
private Connection $connection,
private Pagination $pagination,
) {}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): AuditLogOutput|DbalPaginator|null
{
if (!$operation instanceof CollectionOperationInterface) {
return $this->provideItem((string) $uriVariables['id']);
}
return $this->provideCollection($operation, $context);
}
private function provideItem(string $id): ?AuditLogOutput
{
/** @var array<string, mixed>|false $row */
$row = $this->connection->fetchAssociative(
'SELECT id, entity_type, entity_id, action, changes, performed_by, performed_at, ip_address, request_id
FROM audit_log WHERE id = :id',
['id' => $id],
);
if (false === $row) {
return null;
}
return $this->hydrate($row);
}
/**
* @param array<string, mixed> $context
*/
private function provideCollection(Operation $operation, array $context): DbalPaginator
{
// Contrairement aux ressources ORM (cf. CategoryProvider), ce provider
// ne gere PAS l'echappatoire `?pagination=false` : la pagination y est
// toujours forcee. `audit_log` est une table append-only a croissance
// infinie — la dumper entierement saturerait memoire/reseau et n'a aucun
// usage front (pas de <select> alimente par l'audit). Le flag global
// `pagination_client_enabled: true` reste donc volontairement inerte ici.
//
// `page` brut peut etre <= 0 (parametre client) → OFFSET negatif → 500 PG
// (`SQLSTATE[22023] OFFSET must not be negative`). API Platform clampe
// `itemsPerPage` au max de la resource mais pas `page` ; on impose un
// minimum a 1 cote provider.
$page = max(1, $this->pagination->getPage($context));
$itemsPerPage = $this->pagination->getLimit($operation, $context);
$offset = ($page - 1) * $itemsPerPage;
$filters = $this->extractFilters($context['filters'] ?? []);
$dataQuery = $this->buildBaseQuery()
->select('id', 'entity_type', 'entity_id', 'action', 'changes', 'performed_by', 'performed_at', 'ip_address', 'request_id')
->orderBy('performed_at', 'DESC')
// Tie-breaker sur `id` (UUID v7 monotone) : garantit un tri
// totalement deterministe quand plusieurs lignes partagent la
// meme timestamp (ex: batch fixture, bulk flush < 1µs).
->addOrderBy('id', 'DESC')
->setFirstResult($offset)
->setMaxResults($itemsPerPage)
;
$countQuery = $this->buildBaseQuery()->select('COUNT(*)');
$this->applyFilters($dataQuery, $filters);
$this->applyFilters($countQuery, $filters);
/** @var list<array<string, mixed>> $rows */
$rows = $dataQuery->executeQuery()->fetchAllAssociative();
$totalItems = (int) $countQuery->executeQuery()->fetchOne();
$items = array_map(fn (array $row) => $this->hydrate($row), $rows);
return new DbalPaginator($items, $page, $itemsPerPage, $totalItems);
}
private function buildBaseQuery(): QueryBuilder
{
return $this->connection->createQueryBuilder()->from('audit_log');
}
/**
* @param array<string, mixed> $raw
*
* @return array{entity_type?: list<string>|string, entity_id?: string, action?: string, performed_by?: string, performed_at_after?: string, performed_at_before?: string}
*/
private function extractFilters(array $raw): array
{
$filters = [];
// `entity_type` accepte soit une chaine, soit une liste (query syntax
// `entity_type[]=core.User&entity_type[]=core.Role`) pour le filtre
// multi-selection cote front. On normalise en list<string> non-vide.
if (isset($raw['entity_type'])) {
if (is_string($raw['entity_type']) && '' !== $raw['entity_type']) {
$filters['entity_type'] = $raw['entity_type'];
} elseif (is_array($raw['entity_type'])) {
$cleaned = array_values(array_filter(
$raw['entity_type'],
static fn ($v): bool => is_string($v) && '' !== $v,
));
if ([] !== $cleaned) {
$filters['entity_type'] = $cleaned;
}
}
}
foreach (['entity_id', 'performed_by'] as $key) {
if (isset($raw[$key]) && is_string($raw[$key]) && '' !== $raw[$key]) {
$filters[$key] = $raw[$key];
}
}
// `action` : whitelist stricte. Un input hors-liste provoquait avant
// un simple match vide (resultat 0 ligne) mais permettait d'incrementer
// le log applicatif a chaque variation ; on rejette en 400 explicite.
if (isset($raw['action']) && is_string($raw['action']) && '' !== $raw['action']) {
if (!in_array($raw['action'], ['create', 'update', 'delete'], true)) {
throw new BadRequestHttpException(
'Filtre "action" invalide : valeurs autorisees create|update|delete.',
);
}
$filters['action'] = $raw['action'];
}
// Filtres de plage `performed_at[after]` / `performed_at[before]`.
// Sans validation, un input malforme remonte jusqu'a Postgres qui
// leve `SQLSTATE[22007]: invalid input syntax for type timestamp` →
// 500 Internal Server Error, log Monolog pollue, mauvaise UX API.
// On valide en amont et on rejette en 400 explicite.
if (isset($raw['performed_at']) && is_array($raw['performed_at'])) {
$range = $raw['performed_at'];
foreach (['after', 'before'] as $bound) {
if (!isset($range[$bound]) || !is_string($range[$bound]) || '' === $range[$bound]) {
continue;
}
if (false === strtotime($range[$bound])) {
throw new BadRequestHttpException(sprintf(
'Filtre "performed_at[%s]" invalide : date ISO 8601 attendue (ex: 2026-04-22T00:00:00Z).',
$bound,
));
}
$filters['performed_at_'.$bound] = $range[$bound];
}
}
return $filters;
}
/**
* @param array<string, list<string>|string> $filters
*/
private function applyFilters(QueryBuilder $qb, array $filters): void
{
if (isset($filters['entity_type'])) {
if (is_array($filters['entity_type'])) {
$qb->andWhere('entity_type IN (:entity_types)')
->setParameter('entity_types', $filters['entity_type'], ArrayParameterType::STRING)
;
} else {
$qb->andWhere('entity_type = :entity_type')->setParameter('entity_type', $filters['entity_type']);
}
}
if (isset($filters['entity_id'])) {
$qb->andWhere('entity_id = :entity_id')->setParameter('entity_id', $filters['entity_id']);
}
if (isset($filters['action'])) {
$qb->andWhere('action = :action')->setParameter('action', $filters['action']);
}
if (isset($filters['performed_by'])) {
// Recherche contains insensible a la casse pour matcher "adm" → "admin".
// On echappe `%`, `_` et `\` saisis par l'utilisateur pour qu'ils soient
// interpretes comme caracteres litteraux (sinon `%` matche tout, `_`
// matche n'importe quel caractere). Pas de clause ESCAPE : `\` est
// deja le caractere d'echappement LIKE par defaut en PostgreSQL.
$escaped = str_replace(['\\', '%', '_'], ['\\\\', '\%', '\_'], $filters['performed_by']);
$qb->andWhere('performed_by ILIKE :performed_by')
->setParameter('performed_by', '%'.$escaped.'%')
;
}
if (isset($filters['performed_at_after'])) {
$qb->andWhere('performed_at >= :performed_at_after')->setParameter('performed_at_after', $filters['performed_at_after']);
}
if (isset($filters['performed_at_before'])) {
$qb->andWhere('performed_at <= :performed_at_before')->setParameter('performed_at_before', $filters['performed_at_before']);
}
}
/**
* @param array<string, mixed> $row
*/
private function hydrate(array $row): AuditLogOutput
{
/** @var string $rawChanges */
$rawChanges = $row['changes'] ?? '{}';
/** @var array<string, mixed> $changes */
$changes = is_array($rawChanges) ? $rawChanges : json_decode((string) $rawChanges, true, 512, JSON_THROW_ON_ERROR);
return new AuditLogOutput(
id: (string) $row['id'],
entityType: (string) $row['entity_type'],
entityId: (string) $row['entity_id'],
action: (string) $row['action'],
changes: $changes,
performedBy: (string) $row['performed_by'],
performedAt: new DateTimeImmutable((string) $row['performed_at']),
ipAddress: null !== $row['ip_address'] ? (string) $row['ip_address'] : null,
requestId: null !== $row['request_id'] ? (string) $row['request_id'] : null,
);
}
}
@@ -2,11 +2,11 @@
declare(strict_types=1);
namespace App\State;
namespace App\Module\Core\Infrastructure\ApiPlatform\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Entity\User;
use App\Module\Core\Domain\Entity\User;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
@@ -0,0 +1,94 @@
<?php
declare(strict_types=1);
namespace App\Module\Core\Infrastructure\Audit;
use DateTimeImmutable;
use DateTimeZone;
use Doctrine\DBAL\Connection;
use Doctrine\DBAL\Types\Types;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\Uid\Uuid;
/**
* Low-level service responsible for writing into the `audit_log` table.
*
* Uses a dedicated `audit` DBAL connection (same DSN as `default`) to write
* outside the ORM transaction: audit rows survive an application-side
* rollback and avoid transactional entanglement in batch (fixtures).
*
* Sensitive keys are stripped in defense-in-depth even when entities already
* declare those properties #[AuditIgnore]. SQL failures are swallowed by the
* caller (AuditListener wraps log() in try/catch) audit must never crash a
* business flow.
*/
final class AuditLogWriter
{
/** @var list<string> keys always stripped from the `changes` payload */
private const array SENSITIVE_KEYS = ['password', 'plainPassword', 'apiToken', 'token', 'secret'];
public function __construct(
#[Autowire(service: 'doctrine.dbal.audit_connection')]
private readonly Connection $connection,
private readonly Security $security,
private readonly RequestStack $requestStack,
private readonly RequestIdProvider $requestIdProvider,
) {}
/**
* @param string $entityType Format "module.Entity" (e.g. "core.User")
* @param string $entityId Entity id (int or serialized UUID)
* @param string $action create|update|delete
* @param array<string, mixed> $changes JSON payload (sensitive keys stripped)
*/
public function log(
string $entityType,
string $entityId,
string $action,
array $changes,
): void {
$filteredChanges = $this->stripSensitive($changes);
$this->connection->insert('audit_log', [
'id' => Uuid::v7()->toRfc4122(),
'entity_type' => $entityType,
'entity_id' => $entityId,
'action' => $action,
'changes' => $filteredChanges,
'performed_by' => $this->security->getUser()?->getUserIdentifier() ?? 'system',
'performed_at' => new DateTimeImmutable('now', new DateTimeZone('UTC')),
'ip_address' => $this->requestStack->getCurrentRequest()?->getClientIp(),
'request_id' => $this->requestIdProvider->getRequestId(),
], [
'id' => Types::GUID,
'changes' => Types::JSON,
'performed_at' => Types::DATETIMETZ_IMMUTABLE,
]);
}
/**
* Recursively removes sensitive keys from the payload.
*
* @param array<string, mixed> $data
*
* @return array<string, mixed>
*/
private function stripSensitive(array $data): array
{
foreach ($data as $key => $value) {
if (in_array($key, self::SENSITIVE_KEYS, true)) {
unset($data[$key]);
continue;
}
if (is_array($value)) {
$data[$key] = $this->stripSensitive($value);
}
}
return $data;
}
}
@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace App\Module\Core\Infrastructure\Audit;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\Uid\Uuid;
/**
* Provides an HTTP request identifier (UUID v4) shared by every audit row
* produced during a single main request. Null in CLI (fixtures, batch).
*/
final class RequestIdProvider
{
private ?string $requestId = null;
#[AsEventListener(event: 'kernel.request')]
public function onKernelRequest(RequestEvent $event): void
{
if (!$event->isMainRequest()) {
return;
}
$this->requestId = Uuid::v4()->toRfc4122();
}
public function getRequestId(): ?string
{
return $this->requestId;
}
}
@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace App\Module\Core\Infrastructure\Console;
use App\Module\Core\Application\Rbac\RbacSeeder;
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;
#[AsCommand(name: 'app:seed-rbac', description: 'Seed les rôles système RBAC (admin, user).')]
final class SeedRbacCommand extends Command
{
public function __construct(private readonly RbacSeeder $seeder)
{
parent::__construct();
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$this->seeder->ensureSystemRoles();
$io->success('Rôles système RBAC seedés (admin, user).');
return Command::SUCCESS;
}
}
@@ -0,0 +1,84 @@
<?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 App\Shared\Domain\Module\ModuleRegistry;
use Doctrine\ORM\EntityManagerInterface;
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 function count;
#[AsCommand(name: 'app:sync-permissions', description: 'Synchronise le catalogue des permissions depuis les modules actifs.')]
final class SyncPermissionsCommand extends Command
{
public function __construct(
private readonly EntityManagerInterface $em,
private readonly PermissionRepositoryInterface $permissions,
#[Autowire('%kernel.project_dir%')]
private readonly string $projectDir,
) {
parent::__construct();
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
/** @var list<class-string> $moduleClasses */
$moduleClasses = require $this->projectDir.'/config/modules.php';
// Phase 1 : permissions désirées (code => {code,label,module}).
$desired = [];
foreach (ModuleRegistry::permissions($moduleClasses) as $perm) {
$desired[$perm['code']] = $perm;
}
// Phase 2 : upsert.
$existing = [];
foreach ($this->permissions->findAll() as $permission) {
$existing[$permission->getCode()] = $permission;
}
$added = $updated = $revived = 0;
foreach ($desired as $code => $perm) {
$entity = $existing[$code] ?? null;
if (null === $entity) {
$this->permissions->save(new Permission($perm['code'], $perm['label'], $perm['module']));
++$added;
continue;
}
if ($entity->isOrphan()) {
$entity->revive($perm['label'], $perm['module']);
++$revived;
} elseif ($entity->getLabel() !== $perm['label'] || $entity->getModule() !== $perm['module']) {
$entity->updateMetadata($perm['label'], $perm['module']);
++$updated;
}
}
// Phase 3 : orphelines (existantes absentes des désirées).
$orphaned = 0;
foreach ($existing as $code => $entity) {
if (!isset($desired[$code]) && !$entity->isOrphan()) {
$entity->markOrphan();
++$orphaned;
}
}
$this->em->flush();
$io->success(sprintf('Permissions synchronisées : %d ajoutées, %d mises à jour, %d réactivées, %d orphelines. Total désirées : %d.', $added, $updated, $revived, $orphaned, count($desired)));
return Command::SUCCESS;
}
}
@@ -0,0 +1,513 @@
<?php
declare(strict_types=1);
namespace App\Module\Core\Infrastructure\Doctrine;
use App\Module\Core\Infrastructure\Audit\AuditLogWriter;
use App\Shared\Domain\Attribute\Auditable;
use App\Shared\Domain\Attribute\AuditIgnore;
use DateTimeInterface;
use Doctrine\Bundle\DoctrineBundle\Attribute\AsDoctrineListener;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\Event\OnFlushEventArgs;
use Doctrine\ORM\Event\PostFlushEventArgs;
use Doctrine\ORM\Events;
use Doctrine\ORM\Mapping\ClassMetadata;
use Doctrine\ORM\PersistentCollection;
use Doctrine\ORM\UnitOfWork;
use Psr\Log\LoggerInterface;
use ReflectionClass;
use ReflectionProperty;
use Throwable;
/**
* Listener Doctrine qui produit les lignes d'audit pour les entites portant
* l'attribut #[Auditable].
*
* Pipeline en deux temps :
* 1. onFlush : on traverse UnitOfWork (insertions / updates / deletions) et
* on capture les changements en memoire. Aucune ecriture SQL cote audit
* a ce stade pour ne pas interferer avec la transaction ORM en cours.
* 2. postFlush : on ecrit via AuditLogWriter (connexion DBAL dediee).
*
* Pattern swap-and-clear dans postFlush :
* - on copie localement la liste des evenements ;
* - on vide la propriete pendingLogs immediatement ;
* - on itere la copie.
* Pourquoi : si une ecriture audit declenchait un flush re-entrant (cas rare,
* ex: callback listener externe), l'etat de pendingLogs serait deja nettoye
* pas de double insertion, pas de boucle infinie.
*
* Erreurs silencieuses : un INSERT audit qui echoue est logue en error mais
* jamais propage. Acceptable pour un CRM interne ; a reconsiderer si besoin
* de garantie forte (dead-letter queue, retry).
*
* Collections (OneToMany / ManyToMany) :
* - Les modifications de collections sont tracees via
* `getScheduledCollectionUpdates()` et reportees comme un changement
* `{fieldName: {added: [ids], removed: [ids]}}` dans le changeset de
* l'entite proprietaire.
* - Si l'entite proprietaire est deja scheduled pour insertion, la diff
* est merge dans le snapshot create (en tant que liste d'IDs initiaux).
* - Si l'entite proprietaire est scheduled pour deletion, les collections
* associees sont ignorees (deja couvertes par le snapshot delete).
*
* Limitations connues :
* - Les ManyToOne sont tracees par ID (null-safe via `?->getId()`).
* - Les DELETE / UPDATE bulk DQL et les `Connection::executeStatement()`
* bruts BYPASSENT le listener : onFlush n'est jamais appele. Toute
* operation de purge/nettoyage qui doit etre auditee doit passer par
* `EntityManager::remove()` + `flush()`. Si un futur batch (ex: commande
* "purger users inactifs") utilise du DQL bulk, les suppressions ne
* seront pas dans `audit_log` choix d'architecture explicite a faire.
*/
#[AsDoctrineListener(event: Events::onFlush)]
#[AsDoctrineListener(event: Events::postFlush)]
final class AuditListener
{
/**
* Cache par FQCN : true si la classe porte #[Auditable], false sinon.
* Evite une ReflectionClass par entite a chaque flush.
*
* @var array<class-string, bool>
*/
private array $auditableCache = [];
/**
* Cache par FQCN : liste des noms de proprietes ignorees (#[AuditIgnore]).
*
* @var array<class-string, list<string>>
*/
private array $ignoredPropertiesCache = [];
/**
* Logs en attente d'ecriture (remplis en onFlush, consommes en postFlush).
*
* Pour les inserts, l'ID est assignee DURANT le flush : on capture la
* reference de l'entite et on resout l'ID au moment du postFlush.
*
* @var list<array{entity: object, metadata: ClassMetadata, entityType: string, action: string, changes: array<string, mixed>, capturedId: ?string}>
*/
private array $pendingLogs = [];
public function __construct(
private readonly AuditLogWriter $writer,
private readonly LoggerInterface $logger,
) {}
public function onFlush(OnFlushEventArgs $args): void
{
/** @var EntityManagerInterface $em */
$em = $args->getObjectManager();
$uow = $em->getUnitOfWork();
// Reset defensif en debut de cycle : si un flush precedent a leve une
// exception, Doctrine n'appelle PAS postFlush et pendingLogs reste
// rempli avec des changements jamais committes. Sans ce reset, un
// flush ulterieur reussi ecrirait les fausses entrees dans audit_log.
// Le swap-and-clear dans postFlush couvre deja les flushes re-entrants,
// ce reset ne le fragilise donc pas.
$this->pendingLogs = [];
foreach ($uow->getScheduledEntityInsertions() as $entity) {
$this->capturePendingLog($entity, $em, $uow, 'create');
}
foreach ($uow->getScheduledEntityUpdates() as $entity) {
$this->capturePendingLog($entity, $em, $uow, 'update');
}
foreach ($uow->getScheduledEntityDeletions() as $entity) {
$this->capturePendingLog($entity, $em, $uow, 'delete');
}
// Collections to-many (OneToMany / ManyToMany) : `getEntityChangeSet()`
// ne les expose pas, il faut interroger `UnitOfWork` separement. On
// merge la diff dans le log de l'entite proprietaire si elle est deja
// scheduled, sinon on cree une entree "update" dediee.
foreach ($uow->getScheduledCollectionUpdates() as $collection) {
$this->captureCollectionChange($collection, $em, cleared: false);
}
foreach ($uow->getScheduledCollectionDeletions() as $collection) {
$this->captureCollectionChange($collection, $em, cleared: true);
}
}
public function postFlush(PostFlushEventArgs $args): void
{
// Swap-and-clear : protege d'un flush re-entrant (aucune double
// insertion meme si un callback utilisateur re-declenche un flush).
$logs = $this->pendingLogs;
$this->pendingLogs = [];
foreach ($logs as $log) {
// Pour les inserts, l'ID n'etait pas encore disponible en onFlush :
// on la resout maintenant (Doctrine l'a hydratee pendant le flush).
$entityId = $log['capturedId'] ?? $this->resolveEntityId($log['entity'], $log['metadata']);
if (null === $entityId) {
$this->logger->warning(
'AuditListener : impossible de resoudre l\'ID de l\'entite apres flush, entree ignoree',
['entityType' => $log['entityType'], 'action' => $log['action']]
);
continue;
}
try {
$this->writer->log(
$log['entityType'],
$entityId,
$log['action'],
$log['changes'],
);
} catch (Throwable $e) {
// Erreur audit : logue mais ne crashe jamais le flux metier.
$this->logger->error(
'Echec d\'ecriture audit_log',
[
'exception' => $e,
'entityType' => $log['entityType'],
'entityId' => $entityId,
'action' => $log['action'],
]
);
}
}
}
private function capturePendingLog(object $entity, EntityManagerInterface $em, UnitOfWork $uow, string $action): void
{
// Resolution via ClassMetadata : `$entity::class` renvoie le FQCN du
// proxy Doctrine pour une entite chargee en lazy (ex:
// `Proxies\__CG__\App\Module\Core\Domain\Entity\User`) — `isAuditable()`
// le verrait comme non-auditable car `#[Auditable]` n'est declare que
// sur la classe parente.
$metadata = $em->getClassMetadata($entity::class);
$class = $metadata->getName();
if (!$this->isAuditable($class)) {
return;
}
// Sur `delete`, on inclut aussi les collections to-many dans le
// snapshot : c'est la derniere occasion de capturer l'etat complet
// (ex: quelles permissions etaient rattachees au role supprime).
// Sur `create`, les collections initiales sont rapportees via
// captureCollectionChange quand l'entite est scheduled avec un
// collection update dans le meme flush.
$changes = match ($action) {
'update' => $this->buildUpdateChanges($entity, $uow, $class),
'create' => $this->buildSnapshot($entity, $metadata, $class, includeCollections: false),
'delete' => $this->buildSnapshot($entity, $metadata, $class, includeCollections: true),
default => [],
};
if ('update' === $action && [] === $changes) {
// Flush sans changement reel sur une entite auditable : on n'emet pas.
return;
}
// Pour delete/update, l'ID est deja set en onFlush — on la capture
// maintenant (apres postFlush, l'entite detachee peut perdre sa ref
// dans l'identity map). Pour create (IDENTITY), l'ID est generee par
// le flush — on differe a postFlush.
$capturedId = 'create' === $action ? null : $this->resolveEntityId($entity, $metadata);
$this->pendingLogs[] = [
'entity' => $entity,
'metadata' => $metadata,
'entityType' => $this->formatEntityType($class),
'action' => $action,
'changes' => $changes,
'capturedId' => $capturedId,
];
}
/**
* Capture la modification d'une collection to-many.
*
* Strategie de merge :
* - Si l'entite proprietaire est deja scheduled pour `delete` ignore
* (redondant avec le snapshot delete deja produit).
* - Si l'entite est deja scheduled pour `create` on ajoute le champ
* collection au snapshot initial, sous forme de liste d'IDs ajoutes.
* - Si l'entite est deja scheduled pour `update` on merge la diff
* {added, removed} dans le changeset existant.
* - Sinon on cree une nouvelle entree `update` dediee pour l'entite
* proprietaire (cas d'une collection modifiee sans autre changement
* sur l'entite elle-meme, ex : ajout d'une permission a un role).
*
* @param bool $cleared true si la collection entiere est supprimee
* (getScheduledCollectionDeletions) tous les
* items du snapshot sont consideres comme retires
*/
private function captureCollectionChange(PersistentCollection $collection, EntityManagerInterface $em, bool $cleared): void
{
$owner = $collection->getOwner();
if (null === $owner) {
return;
}
// Voir capturePendingLog : meme contournement proxy Doctrine.
$class = $em->getClassMetadata($owner::class)->getName();
if (!$this->isAuditable($class)) {
return;
}
$fieldName = $collection->getMapping()->fieldName;
if (in_array($fieldName, $this->getIgnoredProperties($class), true)) {
return;
}
if ($cleared) {
$added = [];
$removed = array_map(
fn ($item): mixed => $this->normalizeValue($item),
$collection->getSnapshot(),
);
} else {
$added = array_map(
fn ($item): mixed => $this->normalizeValue($item),
$collection->getInsertDiff(),
);
$removed = array_map(
fn ($item): mixed => $this->normalizeValue($item),
$collection->getDeleteDiff(),
);
}
if ([] === $added && [] === $removed) {
return;
}
// Chercher un log deja en attente pour cette entite, pour merger la
// diff au lieu de creer une entree d'audit redondante.
foreach ($this->pendingLogs as $idx => $log) {
if ($log['entity'] !== $owner) {
continue;
}
if ('delete' === $log['action']) {
// Deletion de l'entite : la collection suit mecaniquement,
// pas d'entree dediee (le snapshot delete contient deja
// l'etat a supprimer).
return;
}
if ('create' === $log['action']) {
// Insertion : le snapshot create ne contient pas les
// collections (buildSnapshot ignore les to-many). On ajoute
// donc la liste des items initiaux comme IDs, pour avoir
// une trace complete de l'etat a la creation. array_values
// garantit un array JSON (pas un objet) si les cles du diff
// ne sont pas sequentielles.
$this->pendingLogs[$idx]['changes'][$fieldName] = array_values($added);
return;
}
// Update : on merge dans le changeset existant.
$this->pendingLogs[$idx]['changes'][$fieldName] = [
'added' => array_values($added),
'removed' => array_values($removed),
];
return;
}
// Aucun log existant : l'entite n'a eu QUE des changements de
// collection. On cree une entree update minimale.
$metadata = $em->getClassMetadata($class);
$this->pendingLogs[] = [
'entity' => $owner,
'metadata' => $metadata,
'entityType' => $this->formatEntityType($class),
'action' => 'update',
'changes' => [$fieldName => [
'added' => array_values($added),
'removed' => array_values($removed),
]],
'capturedId' => $this->resolveEntityId($owner, $metadata),
];
}
/**
* Build du changeset "update" : {champ: {old, new}} a partir de
* `UnitOfWork::getEntityChangeSet()`. ManyToOne : on log l'ID,
* null-safe via `?->getId()`.
*
* @return array<string, array{old: mixed, new: mixed}>
*/
private function buildUpdateChanges(object $entity, UnitOfWork $uow, string $class): array
{
$changeSet = $uow->getEntityChangeSet($entity);
$ignored = $this->getIgnoredProperties($class);
$filteredChanges = [];
foreach ($changeSet as $field => [$oldValue, $newValue]) {
if (in_array($field, $ignored, true)) {
continue;
}
$filteredChanges[$field] = [
'old' => $this->normalizeValue($oldValue),
'new' => $this->normalizeValue($newValue),
];
}
return $filteredChanges;
}
/**
* Build d'un snapshot complet (create / delete) : lit toutes les
* proprietes non-ignorees via Reflection.
*
* @param bool $includeCollections si true, les associations to-many sont
* aussi snapshotees (liste d'IDs). Utilise
* uniquement sur `delete` pour preserver
* l'etat des relations au moment de la
* suppression. En create, on laisse
* captureCollectionChange enrichir le
* snapshot si une collection est modifiee
* dans le meme flush.
*
* @return array<string, mixed>
*/
private function buildSnapshot(object $entity, ClassMetadata $metadata, string $class, bool $includeCollections): array
{
$ignored = $this->getIgnoredProperties($class);
$snapshot = [];
foreach ($metadata->getFieldNames() as $field) {
if (in_array($field, $ignored, true)) {
continue;
}
$snapshot[$field] = $this->normalizeValue($metadata->getFieldValue($entity, $field));
}
foreach ($metadata->getAssociationNames() as $assoc) {
if (in_array($assoc, $ignored, true)) {
continue;
}
if ($metadata->isSingleValuedAssociation($assoc)) {
$related = $metadata->getFieldValue($entity, $assoc);
$snapshot[$assoc] = null !== $related && method_exists($related, 'getId')
? $related->getId()
: null;
continue;
}
if (!$includeCollections) {
continue;
}
// Collection to-many : snapshot = liste d'IDs. On itere la
// Collection (PersistentCollection ou ArrayCollection) pour
// obtenir les elements. Pour un delete, la collection est deja
// chargee (Doctrine en a besoin pour les cascades).
$collection = $metadata->getFieldValue($entity, $assoc);
if (!is_iterable($collection)) {
continue;
}
$ids = [];
foreach ($collection as $item) {
$ids[] = $this->normalizeValue($item);
}
$snapshot[$assoc] = $ids;
}
return $snapshot;
}
private function isAuditable(string $class): bool
{
if (array_key_exists($class, $this->auditableCache)) {
return $this->auditableCache[$class];
}
$reflection = new ReflectionClass($class);
$isAuditable = [] !== $reflection->getAttributes(Auditable::class);
$this->auditableCache[$class] = $isAuditable;
return $isAuditable;
}
/**
* @return list<string>
*/
private function getIgnoredProperties(string $class): array
{
if (array_key_exists($class, $this->ignoredPropertiesCache)) {
return $this->ignoredPropertiesCache[$class];
}
$ignored = [];
$reflection = new ReflectionClass($class);
foreach ($reflection->getProperties(ReflectionProperty::IS_PROTECTED | ReflectionProperty::IS_PRIVATE | ReflectionProperty::IS_PUBLIC) as $property) {
if ([] !== $property->getAttributes(AuditIgnore::class)) {
$ignored[] = $property->getName();
}
}
$this->ignoredPropertiesCache[$class] = $ignored;
return $ignored;
}
/**
* Transforme un FQCN `App\Module\Core\Domain\Entity\User` en `core.User`.
*
* Format `module.Entity` pour eviter les collisions inter-modules.
*/
private function formatEntityType(string $class): string
{
if (1 === preg_match('#^App\\\Module\\\(?<module>[^\\\]+)\\\.+\\\(?<entity>[^\\\]+)$#', $class, $matches)) {
return strtolower($matches['module']).'.'.$matches['entity'];
}
// Fallback : on retourne le FQCN complet si la regex ne matche pas
// (entite hors structure modulaire — ne devrait pas arriver).
return $class;
}
private function resolveEntityId(object $entity, ClassMetadata $metadata): ?string
{
$identifier = $metadata->getIdentifierValues($entity);
if ([] === $identifier) {
return null;
}
// Cle composee : on concatene les valeurs. Cas rare sur le projet.
return implode('-', array_map(static fn ($v) => (string) $v, $identifier));
}
/**
* Normalise une valeur pour encodage JSON stable.
*/
private function normalizeValue(mixed $value): mixed
{
if ($value instanceof DateTimeInterface) {
return $value->format(DateTimeInterface::ATOM);
}
if (is_object($value)) {
// Relation to-one non parsee par buildSnapshot (cas update sur
// un champ qui devient un objet) : on tente getId() si possible.
if (method_exists($value, 'getId')) {
return $value->getId();
}
return (string) $value;
}
return $value;
}
}
@@ -2,10 +2,10 @@
declare(strict_types=1);
namespace App\Repository;
namespace App\Module\Core\Infrastructure\Doctrine;
use App\Entity\Notification;
use App\Entity\User;
use App\Module\Core\Domain\Entity\Notification;
use App\Shared\Domain\Contract\UserInterface as SharedUserInterface;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\ORM\QueryBuilder;
use Doctrine\Persistence\ManagerRegistry;
@@ -14,7 +14,7 @@ use Symfony\Component\Security\Core\User\UserInterface;
/**
* @extends ServiceEntityRepository<Notification>
*/
class NotificationRepository extends ServiceEntityRepository
class DoctrineNotificationRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
@@ -30,7 +30,7 @@ class NotificationRepository extends ServiceEntityRepository
;
}
public function countUnreadByUser(User $user): int
public function countUnreadByUser(SharedUserInterface $user): int
{
return (int) $this->createQueryBuilder('n')
->select('COUNT(n.id)')
@@ -42,7 +42,7 @@ class NotificationRepository extends ServiceEntityRepository
;
}
public function markAllReadByUser(User $user): int
public function markAllReadByUser(SharedUserInterface $user): int
{
return $this->createQueryBuilder('n')
->update()
@@ -0,0 +1,51 @@
<?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>
*/
final 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 list<Permission> */
public function findAll(): array
{
return array_values($this->findBy([]));
}
/** @return list<string> */
public function findAllCodes(): array
{
/** @var list<array{code: string}> $rows */
$rows = $this->createQueryBuilder('p')->select('p.code')->getQuery()->getArrayResult();
return array_map(static fn (array $r): string => $r['code'], $rows);
}
public function save(Permission $permission): void
{
$this->getEntityManager()->persist($permission);
}
}
@@ -0,0 +1,42 @@
<?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>
*/
final 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 list<Role> */
public function findAll(): array
{
return array_values($this->findBy([]));
}
public function save(Role $role): void
{
$this->getEntityManager()->persist($role);
}
}
@@ -2,9 +2,11 @@
declare(strict_types=1);
namespace App\Repository;
namespace App\Module\Core\Infrastructure\Doctrine;
use App\Entity\User;
use App\Module\Core\Domain\Entity\User;
use App\Module\Core\Domain\Repository\UserRepositoryInterface;
use App\Shared\Domain\Contract\UserInterface;
use DateTimeInterface;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
@@ -12,7 +14,7 @@ use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<User>
*/
class UserRepository extends ServiceEntityRepository
class DoctrineUserRepository extends ServiceEntityRepository implements UserRepositoryInterface
{
public function __construct(ManagerRegistry $registry)
{
@@ -20,7 +22,7 @@ class UserRepository extends ServiceEntityRepository
}
/**
* @return User[]
* @return list<UserInterface>
*/
public function findByRole(string $role): array
{
@@ -43,7 +45,7 @@ class UserRepository extends ServiceEntityRepository
/**
* Employees active on the given date (hired on/before it, not yet left).
*
* @return User[]
* @return list<UserInterface>
*/
public function findActiveEmployees(DateTimeInterface $date): array
{
@@ -59,4 +61,9 @@ class UserRepository extends ServiceEntityRepository
->getResult()
;
}
public function findOneByUsername(string $username): ?UserInterface
{
return $this->findOneBy(['username' => $username]);
}
}
@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace App\Module\Core\Infrastructure;
use App\Module\Core\Domain\Entity\Notification;
use App\Shared\Domain\Contract\NotifierInterface;
use App\Shared\Domain\Contract\UserInterface;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
final readonly class Notifier implements NotifierInterface
{
public function __construct(private EntityManagerInterface $em) {}
public function notify(UserInterface $user, string $type, string $title, string $message): void
{
$notification = new Notification();
$notification->setUser($user);
$notification->setType($type);
$notification->setTitle($title);
$notification->setMessage($message);
$notification->setCreatedAt(new DateTimeImmutable());
$this->em->persist($notification);
$this->em->flush();
}
}
@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace App\Module\Core\Infrastructure\Security;
use App\Module\Core\Domain\Entity\User;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Vote;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
/**
* @extends Voter<string, mixed>
*/
final class PermissionVoter extends Voter
{
private const string PATTERN = '/^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)+$/';
protected function supports(string $attribute, mixed $subject): bool
{
return 1 === preg_match(self::PATTERN, $attribute);
}
protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token, ?Vote $vote = null): bool
{
$user = $token->getUser();
if (!$user instanceof User) {
return false;
}
// ROLE_ADMIN = bypass total (cf. Décision 1).
if (in_array('ROLE_ADMIN', $user->getRoles(), true)) {
return true;
}
return in_array($attribute, $user->getEffectivePermissions(), true);
}
}
+2 -2
View File
@@ -5,8 +5,8 @@ declare(strict_types=1);
namespace App\Repository;
use App\Entity\AbsenceBalance;
use App\Entity\User;
use App\Enum\AbsenceType;
use App\Shared\Domain\Contract\UserInterface;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
@@ -20,7 +20,7 @@ class AbsenceBalanceRepository extends ServiceEntityRepository
parent::__construct($registry, AbsenceBalance::class);
}
public function findOneForPeriod(User $user, AbsenceType $type, string $period): ?AbsenceBalance
public function findOneForPeriod(UserInterface $user, AbsenceType $type, string $period): ?AbsenceBalance
{
return $this->findOneBy([
'user' => $user,
+3 -3
View File
@@ -5,9 +5,9 @@ declare(strict_types=1);
namespace App\Repository;
use App\Entity\AbsenceRequest;
use App\Entity\User;
use App\Enum\AbsenceStatus;
use App\Enum\AbsenceType;
use App\Shared\Domain\Contract\UserInterface;
use DateTimeInterface;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
@@ -28,7 +28,7 @@ class AbsenceRequestRepository extends ServiceEntityRepository
* end_a >= start_b.
*/
public function hasOverlap(
User $user,
UserInterface $user,
DateTimeInterface $startDate,
DateTimeInterface $endDate,
?int $excludeId = null,
@@ -77,7 +77,7 @@ class AbsenceRequestRepository extends ServiceEntityRepository
* @return AbsenceRequest[]
*/
public function findFiltered(
?User $user = null,
?UserInterface $user = null,
?AbsenceStatus $status = null,
?AbsenceType $type = null,
?DateTimeInterface $from = null,
+5 -5
View File
@@ -6,7 +6,7 @@ namespace App\Repository;
use App\Entity\Project;
use App\Entity\TimeEntry;
use App\Entity\User;
use App\Shared\Domain\Contract\UserInterface;
use DateTimeImmutable;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
@@ -21,7 +21,7 @@ class TimeEntryRepository extends ServiceEntityRepository
parent::__construct($registry, TimeEntry::class);
}
public function findActiveByUser(User $user): ?TimeEntry
public function findActiveByUser(UserInterface $user): ?TimeEntry
{
return $this->findOneBy([
'user' => $user,
@@ -30,9 +30,9 @@ class TimeEntryRepository extends ServiceEntityRepository
}
/**
* @param null|User[] $users
* @param null|Project[] $projects
* @param null|int[] $tagIds
* @param null|UserInterface[] $users
* @param null|Project[] $projects
* @param null|int[] $tagIds
*
* @return TimeEntry[]
*/
+3 -3
View File
@@ -4,8 +4,8 @@ declare(strict_types=1);
namespace App\Security;
use App\Entity\User;
use App\Repository\UserRepository;
use App\Module\Core\Domain\Entity\User;
use App\Module\Core\Infrastructure\Doctrine\DoctrineUserRepository;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
@@ -20,7 +20,7 @@ use Symfony\Component\Security\Http\Authenticator\Passport\SelfValidatingPasspor
class ApiTokenAuthenticator extends AbstractAuthenticator
{
public function __construct(
private readonly UserRepository $userRepository,
private readonly DoctrineUserRepository $userRepository,
) {}
public function supports(Request $request): ?bool
+3 -3
View File
@@ -4,7 +4,7 @@ declare(strict_types=1);
namespace App\Security;
use App\Entity\User;
use App\Shared\Domain\Contract\UserInterface as SharedUserInterface;
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
use Symfony\Component\Security\Core\User\UserInterface;
@@ -23,7 +23,7 @@ final readonly class MailAccessChecker
*/
public function ensureCanAccessMail(?UserInterface $user): void
{
if (!$user instanceof User) {
if (!$user instanceof SharedUserInterface) {
throw new AccessDeniedException('Authentication required');
}
@@ -41,7 +41,7 @@ final readonly class MailAccessChecker
*/
public function ensureIsAdmin(?UserInterface $user): void
{
if (!$user instanceof User || !$this->authorizationChecker->isGranted('ROLE_ADMIN')) {
if (!$user instanceof SharedUserInterface || !$this->authorizationChecker->isGranted('ROLE_ADMIN')) {
throw new AccessDeniedException('Admin only');
}
}
+1 -1
View File
@@ -6,8 +6,8 @@ namespace App\Service;
use App\Entity\AbsenceBalance;
use App\Entity\AbsenceRequest;
use App\Entity\User;
use App\Enum\AbsenceType;
use App\Module\Core\Domain\Entity\User;
use App\Repository\AbsenceBalanceRepository;
use DateTimeInterface;
use Doctrine\ORM\EntityManagerInterface;
@@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
namespace App\Shared\Domain\Attribute;
use Attribute;
/**
* Marker placed on an entity property to exclude it from audit tracking.
*
* Typical use: sensitive fields (password, apiToken). The AuditLogWriter also
* carries an exact-match blacklist on the most dangerous names as
* defense-in-depth, but the base rule is to annotate explicitly here.
*/
#[Attribute(Attribute::TARGET_PROPERTY)]
final class AuditIgnore {}
+17
View File
@@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
namespace App\Shared\Domain\Attribute;
use Attribute;
/**
* Marker placed on a Doctrine entity to enable audit tracking.
*
* Located in Shared (not Core) so every module can use it without a
* circular dependency on Core. Any migrated business entity that should be
* traced carries this attribute, with #[AuditIgnore] on sensitive fields.
*/
#[Attribute(Attribute::TARGET_CLASS)]
final class Auditable {}
@@ -0,0 +1,10 @@
<?php
declare(strict_types=1);
namespace App\Shared\Domain\Contract;
interface NotifierInterface
{
public function notify(UserInterface $user, string $type, string $title, string $message): void;
}
@@ -4,7 +4,29 @@ declare(strict_types=1);
namespace App\Shared\Domain\Contract;
/**
* Contrat de LECTURE de l'identité, consommé hors du module Core.
* Les écritures (setPassword, setters HR…) restent sur le concret Core\Domain\Entity\User.
*/
interface UserInterface
{
public function getId(): ?int;
public function getUserIdentifier(): string;
public function getUsername(): ?string;
/** @return list<string> */
public function getRoles(): array;
public function getFirstName(): ?string;
public function getLastName(): ?string;
public function getAvatarUrl(): ?string;
public function getIsEmployee(): bool;
/** @return list<string> */
public function getEffectivePermissions(): array;
}
+34 -2
View File
@@ -4,6 +4,8 @@ declare(strict_types=1);
namespace App\Shared\Domain\Module;
use InvalidArgumentException;
final class ModuleRegistry
{
/**
@@ -15,11 +17,41 @@ final class ModuleRegistry
{
$ids = [];
foreach ($moduleClasses as $moduleClass) {
if (is_a($moduleClass, ModuleInterface::class, true)) {
$ids[] = $moduleClass::id();
if (!is_a($moduleClass, ModuleInterface::class, true)) {
continue;
}
$id = $moduleClass::id();
if (in_array($id, $ids, true)) {
throw new InvalidArgumentException(sprintf('Module ID "%s" déclaré plusieurs fois dans la configuration des modules.', $id));
}
$ids[] = $id;
}
return $ids;
}
/**
* @param list<class-string> $moduleClasses
*
* @return list<array{code: string, label: string, module: string}>
*/
public static function permissions(array $moduleClasses): array
{
$out = [];
foreach ($moduleClasses as $moduleClass) {
if (!is_a($moduleClass, ModuleInterface::class, true)) {
continue;
}
$moduleId = $moduleClass::id();
foreach ($moduleClass::permissions() as $perm) {
$code = $perm['code'];
if (!str_starts_with($code, $moduleId.'.')) {
throw new InvalidArgumentException(sprintf('Permission "%s" du module "%s" doit être préfixée par "%s.".', $code, $moduleId, $moduleId));
}
$out[] = ['code' => $code, 'label' => $perm['label'], 'module' => $moduleId];
}
}
return $out;
}
}
+27 -4
View File
@@ -7,13 +7,14 @@ namespace App\Shared\Domain\Sidebar;
final class SidebarFilter
{
/**
* @param list<array{label:string, icon:string, roles?:list<string>, items: list<array{label:string, to:string, icon:string, module?:string, roles?:list<string>}>}> $sections
* @param list<string> $activeModuleIds
* @param list<string> $activeRoles
* @param list<array{label:string, icon:string, roles?:list<string>, permission?:string, items: list<array{label:string, to:string, icon:string, module?:string, roles?:list<string>, permission?:string}>}> $sections
* @param list<string> $activeModuleIds
* @param list<string> $activeRoles
* @param list<string> $activePermissions
*
* @return array{sections: list<array{label:string, icon:string, items: list<array{label:string, to:string, icon:string}>}>, disabledRoutes: list<string>}
*/
public static function filter(array $sections, array $activeModuleIds, array $activeRoles = []): array
public static function filter(array $sections, array $activeModuleIds, array $activeRoles = [], array $activePermissions = []): array
{
$outSections = [];
$disabledRoutes = [];
@@ -24,6 +25,11 @@ final class SidebarFilter
continue;
}
// Gate de permission au niveau section (RBAC fin).
if (!self::permissionSatisfied($section['permission'] ?? null, $activePermissions)) {
continue;
}
$items = [];
foreach ($section['items'] as $item) {
// Gate de rôle au niveau item.
@@ -31,6 +37,11 @@ final class SidebarFilter
continue;
}
// Gate de permission au niveau item (RBAC fin).
if (!self::permissionSatisfied($item['permission'] ?? null, $activePermissions)) {
continue;
}
// Filtrage par module actif (pilote la redirection front via disabledRoutes).
$module = $item['module'] ?? null;
if (null !== $module && !in_array($module, $activeModuleIds, true)) {
@@ -68,4 +79,16 @@ final class SidebarFilter
return false;
}
/**
* @param list<string> $activePermissions
*/
private static function permissionSatisfied(?string $required, array $activePermissions): bool
{
if (null === $required || '' === $required) {
return true;
}
return in_array($required, $activePermissions, true);
}
}
@@ -6,6 +6,7 @@ namespace App\Shared\Infrastructure\ApiPlatform\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\Shared\Domain\Contract\UserInterface;
use App\Shared\Domain\Module\ModuleRegistry;
use App\Shared\Domain\Sidebar\SidebarFilter;
use App\Shared\Infrastructure\ApiPlatform\Resource\SidebarResource;
@@ -31,7 +32,15 @@ final readonly class SidebarProvider implements ProviderInterface
$user = $this->security->getUser();
$roles = null !== $user ? $user->getRoles() : [];
$filtered = SidebarFilter::filter($sidebar, ModuleRegistry::ids($moduleClasses), array_values($roles));
// RBAC fin : permissions effectives du contrat. ROLE_ADMIN bypasse tout (Décision 1) :
// on lui injecte le catalogue complet des permissions déclarées pour satisfaire les gates.
if (in_array('ROLE_ADMIN', $roles, true)) {
$permissions = array_column(ModuleRegistry::permissions($moduleClasses), 'code');
} else {
$permissions = $user instanceof UserInterface ? $user->getEffectivePermissions() : [];
}
$filtered = SidebarFilter::filter($sidebar, ModuleRegistry::ids($moduleClasses), array_values($roles), $permissions);
$dto = new SidebarResource();
$dto->sections = $filtered['sections'];
+2 -2
View File
@@ -7,7 +7,7 @@ namespace App\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\Entity\AbsenceBalance;
use App\Entity\User;
use App\Shared\Domain\Contract\UserInterface;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\SecurityBundle\Security;
@@ -24,7 +24,7 @@ final readonly class AbsenceBalanceProvider implements ProviderInterface
public function provide(Operation $operation, array $uriVariables = [], array $context = []): AbsenceBalance|array|null
{
$user = $this->security->getUser();
assert($user instanceof User);
assert($user instanceof UserInterface);
$repo = $this->entityManager->getRepository(AbsenceBalance::class);
$isAdmin = $this->security->isGranted('ROLE_ADMIN');
+2 -2
View File
@@ -7,13 +7,13 @@ namespace App\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Entity\AbsenceRequest;
use App\Entity\User;
use App\Enum\AbsenceStatus;
use App\Enum\AbsenceType;
use App\Repository\AbsencePolicyRepository;
use App\Repository\AbsenceRequestRepository;
use App\Service\AbsenceBalanceService;
use App\Service\AbsenceDayCalculator;
use App\Shared\Domain\Contract\UserInterface;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\SecurityBundle\Security;
@@ -42,7 +42,7 @@ final readonly class AbsenceRequestProcessor implements ProcessorInterface
assert($data instanceof AbsenceRequest);
$user = $this->security->getUser();
assert($user instanceof User);
assert($user instanceof UserInterface);
$type = $data->getType();
$startDate = $data->getStartDate();

Some files were not shown because too many files have changed in this diff Show More