Compare commits
10 Commits
develop
...
68f072ef46
| Author | SHA1 | Date | |
|---|---|---|---|
| 68f072ef46 | |||
| e2fbf51e19 | |||
|
|
701a480442 | ||
|
|
5f5afccac0 | ||
|
|
617ee314b3 | ||
|
|
6db955f65c | ||
|
|
1505e84926 | ||
|
|
a95bb6c629 | ||
| 37eafd276c | |||
| de39fe6a3e |
@@ -234,6 +234,7 @@ Exemples : `feat : add login page`, `fix(auth) : prevent null token crash`
|
|||||||
- Controllers custom sous `/api/` : ajouter `priority: 1` sur `#[Route]` pour eviter le conflit avec API Platform `{id}`
|
- Controllers custom sous `/api/` : ajouter `priority: 1` sur `#[Route]` pour eviter le conflit avec API Platform `{id}`
|
||||||
- Serialization : pour embarquer une relation (pas IRI), ajouter le groupe du parent aux proprietes de l'entite cible
|
- Serialization : pour embarquer une relation (pas IRI), ajouter le groupe du parent aux proprietes de l'entite cible
|
||||||
- Upload fichiers : utiliser `$file->getMimeType()` (pas `getClientMimeType()`) pour valider cote serveur
|
- Upload fichiers : utiliser `$file->getMimeType()` (pas `getClientMimeType()`) pour valider cote serveur
|
||||||
|
- **Audit obligatoire** : toute entite (nouvelle ou existante) doit porter `#[Auditable]` (dans `Shared/Domain/Attribute/`). Les champs sensibles (password, token, secret) doivent etre annotes `#[AuditIgnore]`. Spec complete : `doc/audit-log.md`
|
||||||
|
|
||||||
### Frontend
|
### Frontend
|
||||||
|
|
||||||
@@ -246,6 +247,14 @@ Exemples : `feat : add login page`, `fix(auth) : prevent null token crash`
|
|||||||
- Traductions dans `frontend/i18n/locales/` avec le namespace `sidebar.*` pour la nav
|
- Traductions dans `frontend/i18n/locales/` avec le namespace `sidebar.*` pour la nav
|
||||||
- 4 espaces d'indentation
|
- 4 espaces d'indentation
|
||||||
- Les labels de sidebar sont des cles i18n, jamais du texte brut (le layout applique `t()` dessus)
|
- Les labels de sidebar sont des cles i18n, jamais du texte brut (le layout applique `t()` dessus)
|
||||||
|
- **Tableaux : pas de persistance URL.** Aucun etat de tableau (filtres, pagination, tri, tri par colonne, selection, ligne active...) ne doit etre persiste dans la query string ou reinjecte depuis `route.query` au montage. L'etat vit uniquement dans le composant (reactive locale). Seuls les deep links "de navigation metier" (ex: ouvrir un detail precis `/users/42`) sont dans l'URL, jamais l'etat UI du tableau. Exceptions autorisees sur demande explicite de l'utilisateur.
|
||||||
|
- **Composants formulaires : utiliser `@malio/layer-ui`.** Tout champ de formulaire / filtre doit utiliser les composants `Malio*` (MalioInputText, MalioSelect, MalioSelectCheckbox, MalioCheckbox, MalioRadioButton, MalioInputNumber, MalioInputAmount, MalioInputPassword, MalioInputTextArea, MalioInputUpload, MalioTime, MalioButton, MalioButtonIcon) plutot que des `<input>` / `<select>` bruts.
|
||||||
|
- **Exceptions autorisees** (a commenter en TODO lors du premier usage, pour pouvoir migrer quand la lib evoluera) :
|
||||||
|
1. Type de champ non couvert par la lib : `datetime-local`, `date`, `color picker`, `file drag & drop`, etc.
|
||||||
|
2. Bug connu bloquant du composant : ex. `MalioSelect` avec options a valeur string (cf. note dans le Lesstime CLAUDE.md). Documenter le bug avec un commentaire pointant la limitation.
|
||||||
|
- Toute autre exception doit etre validee par l'equipe avant merge.
|
||||||
|
- **Tableaux de donnees : utiliser `MalioDataTable`.** Tout affichage LISTE tabulaire (donnees metier paginees, CRUD admin) doit passer par `MalioDataTable` (pagination + slots `#header-*` pour filtres + `#cell-*` pour rendu custom). Pas de `<table>` brut avec pagination custom. **Exception** : les tableaux purement presentationnels non-paginables (diff field/old/new, grille de comparaison, matrice RBAC, etc.) peuvent rester en `<table>` HTML brut — MalioDataTable n'est pas adapte a ces cas.
|
||||||
|
- **Audit ManyToMany** : le listener trace les modifications de collections to-many (`permissions`, etc.) sous forme `{fieldName: {added: [ids], removed: [ids]}}`. Toute nouvelle entite `#[Auditable]` portant des ManyToMany a auditer beneficie automatiquement de cette couverture — aucune action supplementaire requise.
|
||||||
|
|
||||||
### Nginx
|
### Nginx
|
||||||
|
|
||||||
|
|||||||
@@ -31,6 +31,8 @@
|
|||||||
"symfony/runtime": "8.0.*",
|
"symfony/runtime": "8.0.*",
|
||||||
"symfony/security-bundle": "8.0.*",
|
"symfony/security-bundle": "8.0.*",
|
||||||
"symfony/serializer": "8.0.*",
|
"symfony/serializer": "8.0.*",
|
||||||
|
"symfony/twig-bundle": "8.0.*",
|
||||||
|
"symfony/uid": "8.0.*",
|
||||||
"symfony/validator": "8.0.*",
|
"symfony/validator": "8.0.*",
|
||||||
"symfony/yaml": "8.0.*"
|
"symfony/yaml": "8.0.*"
|
||||||
},
|
},
|
||||||
|
|||||||
274
composer.lock
generated
274
composer.lock
generated
@@ -4,7 +4,7 @@
|
|||||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||||
"This file is @generated automatically"
|
"This file is @generated automatically"
|
||||||
],
|
],
|
||||||
"content-hash": "75f8e672f2a401290886fbcf01befd3f",
|
"content-hash": "d65a546151abb6b977fbf7f1c86d14fe",
|
||||||
"packages": [
|
"packages": [
|
||||||
{
|
{
|
||||||
"name": "api-platform/doctrine-common",
|
"name": "api-platform/doctrine-common",
|
||||||
@@ -7226,6 +7226,198 @@
|
|||||||
],
|
],
|
||||||
"time": "2025-07-15T13:41:35+00:00"
|
"time": "2025-07-15T13:41:35+00:00"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "symfony/twig-bridge",
|
||||||
|
"version": "v8.0.8",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/symfony/twig-bridge.git",
|
||||||
|
"reference": "a892d0b7f3d5d51b35895467e48aafbd1f2612a0"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/symfony/twig-bridge/zipball/a892d0b7f3d5d51b35895467e48aafbd1f2612a0",
|
||||||
|
"reference": "a892d0b7f3d5d51b35895467e48aafbd1f2612a0",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"php": ">=8.4",
|
||||||
|
"symfony/translation-contracts": "^2.5|^3",
|
||||||
|
"twig/twig": "^3.21"
|
||||||
|
},
|
||||||
|
"conflict": {
|
||||||
|
"phpdocumentor/reflection-docblock": "<5.2|>=7",
|
||||||
|
"phpdocumentor/type-resolver": "<1.5.1",
|
||||||
|
"symfony/form": "<7.4.4|>8.0,<8.0.4",
|
||||||
|
"symfony/mime": "<7.4.8|>8.0,<8.0.8"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"egulias/email-validator": "^2.1.10|^3|^4",
|
||||||
|
"league/html-to-markdown": "^5.0",
|
||||||
|
"phpdocumentor/reflection-docblock": "^5.2|^6.0",
|
||||||
|
"symfony/asset": "^7.4|^8.0",
|
||||||
|
"symfony/asset-mapper": "^7.4|^8.0",
|
||||||
|
"symfony/console": "^7.4|^8.0",
|
||||||
|
"symfony/dependency-injection": "^7.4|^8.0",
|
||||||
|
"symfony/emoji": "^7.4|^8.0",
|
||||||
|
"symfony/expression-language": "^7.4|^8.0",
|
||||||
|
"symfony/finder": "^7.4|^8.0",
|
||||||
|
"symfony/form": "^7.4.4|^8.0.4",
|
||||||
|
"symfony/html-sanitizer": "^7.4|^8.0",
|
||||||
|
"symfony/http-foundation": "^7.4|^8.0",
|
||||||
|
"symfony/http-kernel": "^7.4|^8.0",
|
||||||
|
"symfony/intl": "^7.4|^8.0",
|
||||||
|
"symfony/mime": "^7.4.8|^8.0.8",
|
||||||
|
"symfony/polyfill-intl-icu": "^1.0",
|
||||||
|
"symfony/property-info": "^7.4|^8.0",
|
||||||
|
"symfony/routing": "^7.4|^8.0",
|
||||||
|
"symfony/security-acl": "^2.8|^3.0",
|
||||||
|
"symfony/security-core": "^7.4|^8.0",
|
||||||
|
"symfony/security-csrf": "^7.4|^8.0",
|
||||||
|
"symfony/security-http": "^7.4|^8.0",
|
||||||
|
"symfony/serializer": "^7.4|^8.0",
|
||||||
|
"symfony/stopwatch": "^7.4|^8.0",
|
||||||
|
"symfony/translation": "^7.4|^8.0",
|
||||||
|
"symfony/validator": "^7.4|^8.0",
|
||||||
|
"symfony/web-link": "^7.4|^8.0",
|
||||||
|
"symfony/workflow": "^7.4|^8.0",
|
||||||
|
"symfony/yaml": "^7.4|^8.0",
|
||||||
|
"twig/cssinliner-extra": "^3",
|
||||||
|
"twig/inky-extra": "^3",
|
||||||
|
"twig/markdown-extra": "^3"
|
||||||
|
},
|
||||||
|
"type": "symfony-bridge",
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"Symfony\\Bridge\\Twig\\": ""
|
||||||
|
},
|
||||||
|
"exclude-from-classmap": [
|
||||||
|
"/Tests/"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"MIT"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Fabien Potencier",
|
||||||
|
"email": "fabien@symfony.com"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Symfony Community",
|
||||||
|
"homepage": "https://symfony.com/contributors"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Provides integration for Twig with various Symfony components",
|
||||||
|
"homepage": "https://symfony.com",
|
||||||
|
"support": {
|
||||||
|
"source": "https://github.com/symfony/twig-bridge/tree/v8.0.8"
|
||||||
|
},
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"url": "https://symfony.com/sponsor",
|
||||||
|
"type": "custom"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://github.com/fabpot",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://github.com/nicolas-grekas",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
|
||||||
|
"type": "tidelift"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"time": "2026-03-30T15:14:47+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "symfony/twig-bundle",
|
||||||
|
"version": "v8.0.8",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/symfony/twig-bundle.git",
|
||||||
|
"reference": "f83767b78e2580ca9fe9a2cf6fcff19cd5389bc1"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/symfony/twig-bundle/zipball/f83767b78e2580ca9fe9a2cf6fcff19cd5389bc1",
|
||||||
|
"reference": "f83767b78e2580ca9fe9a2cf6fcff19cd5389bc1",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"composer-runtime-api": ">=2.1",
|
||||||
|
"php": ">=8.4",
|
||||||
|
"symfony/config": "^7.4|^8.0",
|
||||||
|
"symfony/dependency-injection": "^7.4|^8.0",
|
||||||
|
"symfony/http-foundation": "^7.4|^8.0",
|
||||||
|
"symfony/http-kernel": "^7.4|^8.0",
|
||||||
|
"symfony/twig-bridge": "^7.4|^8.0"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"symfony/asset": "^7.4|^8.0",
|
||||||
|
"symfony/expression-language": "^7.4|^8.0",
|
||||||
|
"symfony/finder": "^7.4|^8.0",
|
||||||
|
"symfony/form": "^7.4|^8.0",
|
||||||
|
"symfony/framework-bundle": "^7.4|^8.0",
|
||||||
|
"symfony/routing": "^7.4|^8.0",
|
||||||
|
"symfony/runtime": "^7.4|^8.0",
|
||||||
|
"symfony/stopwatch": "^7.4|^8.0",
|
||||||
|
"symfony/translation": "^7.4|^8.0",
|
||||||
|
"symfony/web-link": "^7.4|^8.0",
|
||||||
|
"symfony/yaml": "^7.4|^8.0"
|
||||||
|
},
|
||||||
|
"type": "symfony-bundle",
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"Symfony\\Bundle\\TwigBundle\\": ""
|
||||||
|
},
|
||||||
|
"exclude-from-classmap": [
|
||||||
|
"/Tests/"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"MIT"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Fabien Potencier",
|
||||||
|
"email": "fabien@symfony.com"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Symfony Community",
|
||||||
|
"homepage": "https://symfony.com/contributors"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Provides a tight integration of Twig into the Symfony full-stack framework",
|
||||||
|
"homepage": "https://symfony.com",
|
||||||
|
"support": {
|
||||||
|
"source": "https://github.com/symfony/twig-bundle/tree/v8.0.8"
|
||||||
|
},
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"url": "https://symfony.com/sponsor",
|
||||||
|
"type": "custom"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://github.com/fabpot",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://github.com/nicolas-grekas",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
|
||||||
|
"type": "tidelift"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"time": "2026-03-30T15:14:47+00:00"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "symfony/type-info",
|
"name": "symfony/type-info",
|
||||||
"version": "v8.0.8",
|
"version": "v8.0.8",
|
||||||
@@ -7807,6 +7999,86 @@
|
|||||||
],
|
],
|
||||||
"time": "2026-03-30T15:14:47+00:00"
|
"time": "2026-03-30T15:14:47+00:00"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "twig/twig",
|
||||||
|
"version": "v3.24.0",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/twigphp/Twig.git",
|
||||||
|
"reference": "a6769aefb305efef849dc25c9fd1653358c148f0"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/twigphp/Twig/zipball/a6769aefb305efef849dc25c9fd1653358c148f0",
|
||||||
|
"reference": "a6769aefb305efef849dc25c9fd1653358c148f0",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"php": ">=8.1.0",
|
||||||
|
"symfony/deprecation-contracts": "^2.5|^3",
|
||||||
|
"symfony/polyfill-ctype": "^1.8",
|
||||||
|
"symfony/polyfill-mbstring": "^1.3"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"php-cs-fixer/shim": "^3.0@stable",
|
||||||
|
"phpstan/phpstan": "^2.0@stable",
|
||||||
|
"psr/container": "^1.0|^2.0",
|
||||||
|
"symfony/phpunit-bridge": "^5.4.9|^6.4|^7.0"
|
||||||
|
},
|
||||||
|
"type": "library",
|
||||||
|
"autoload": {
|
||||||
|
"files": [
|
||||||
|
"src/Resources/core.php",
|
||||||
|
"src/Resources/debug.php",
|
||||||
|
"src/Resources/escaper.php",
|
||||||
|
"src/Resources/string_loader.php"
|
||||||
|
],
|
||||||
|
"psr-4": {
|
||||||
|
"Twig\\": "src/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"BSD-3-Clause"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Fabien Potencier",
|
||||||
|
"email": "fabien@symfony.com",
|
||||||
|
"homepage": "http://fabien.potencier.org",
|
||||||
|
"role": "Lead Developer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Twig Team",
|
||||||
|
"role": "Contributors"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Armin Ronacher",
|
||||||
|
"email": "armin.ronacher@active-4.com",
|
||||||
|
"role": "Project Founder"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Twig, the flexible, fast, and secure template language for PHP",
|
||||||
|
"homepage": "https://twig.symfony.com",
|
||||||
|
"keywords": [
|
||||||
|
"templating"
|
||||||
|
],
|
||||||
|
"support": {
|
||||||
|
"issues": "https://github.com/twigphp/Twig/issues",
|
||||||
|
"source": "https://github.com/twigphp/Twig/tree/v3.24.0"
|
||||||
|
},
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"url": "https://github.com/fabpot",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://tidelift.com/funding/github/packagist/twig/twig",
|
||||||
|
"type": "tidelift"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"time": "2026-03-17T21:31:11+00:00"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "webmozart/assert",
|
"name": "webmozart/assert",
|
||||||
"version": "2.1.6",
|
"version": "2.1.6",
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ use Nelmio\CorsBundle\NelmioCorsBundle;
|
|||||||
use Symfony\Bundle\FrameworkBundle\FrameworkBundle;
|
use Symfony\Bundle\FrameworkBundle\FrameworkBundle;
|
||||||
use Symfony\Bundle\MonologBundle\MonologBundle;
|
use Symfony\Bundle\MonologBundle\MonologBundle;
|
||||||
use Symfony\Bundle\SecurityBundle\SecurityBundle;
|
use Symfony\Bundle\SecurityBundle\SecurityBundle;
|
||||||
|
use Symfony\Bundle\TwigBundle\TwigBundle;
|
||||||
|
|
||||||
return [
|
return [
|
||||||
FrameworkBundle::class => ['all' => true],
|
FrameworkBundle::class => ['all' => true],
|
||||||
@@ -22,4 +23,5 @@ return [
|
|||||||
DoctrineFixturesBundle::class => ['dev' => true, 'test' => true],
|
DoctrineFixturesBundle::class => ['dev' => true, 'test' => true],
|
||||||
LexikJWTAuthenticationBundle::class => ['all' => true],
|
LexikJWTAuthenticationBundle::class => ['all' => true],
|
||||||
MonologBundle::class => ['all' => true],
|
MonologBundle::class => ['all' => true],
|
||||||
|
TwigBundle::class => ['all' => true],
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -9,6 +9,9 @@ api_platform:
|
|||||||
mapping:
|
mapping:
|
||||||
paths:
|
paths:
|
||||||
- '%kernel.project_dir%/src/Module/Core/Domain/Entity'
|
- '%kernel.project_dir%/src/Module/Core/Domain/Entity'
|
||||||
|
# Resources virtuelles (sans entite Doctrine) declarees via #[ApiResource]
|
||||||
|
# en dehors de Domain/Entity : AuditLogResource, etc.
|
||||||
|
- '%kernel.project_dir%/src/Module/Core/Infrastructure/ApiPlatform/Resource'
|
||||||
formats:
|
formats:
|
||||||
jsonld: ['application/ld+json']
|
jsonld: ['application/ld+json']
|
||||||
json: ['application/json']
|
json: ['application/json']
|
||||||
|
|||||||
@@ -1,7 +1,25 @@
|
|||||||
doctrine:
|
doctrine:
|
||||||
dbal:
|
dbal:
|
||||||
|
# Deux connexions pointant sur le meme DSN : l'ORM utilise `default`,
|
||||||
|
# l'AuditLogWriter utilise `audit` pour ecrire hors de la transaction
|
||||||
|
# Doctrine et eviter tout entanglement transactionnel en batch.
|
||||||
|
default_connection: default
|
||||||
|
connections:
|
||||||
|
default:
|
||||||
url: '%env(resolve:DATABASE_URL)%'
|
url: '%env(resolve:DATABASE_URL)%'
|
||||||
profiling_collect_backtrace: '%kernel.debug%'
|
profiling_collect_backtrace: '%kernel.debug%'
|
||||||
|
# Exclut `audit_log` de toute operation de comparaison de schema
|
||||||
|
# (doctrine:schema:update, schema:validate, diff de migrations...).
|
||||||
|
# Cette table n'a volontairement aucune entite mappee : elle est
|
||||||
|
# append-only via DBAL brut (AuditLogWriter) pour eviter la
|
||||||
|
# recursion du listener Doctrine. Sans ce filtre, schema:update
|
||||||
|
# la considere comme "orpheline" et genere un `DROP TABLE
|
||||||
|
# audit_log` qui casse la base de test apres chaque
|
||||||
|
# `make test-db-setup`. La creation / suppression de la table
|
||||||
|
# reste pilotee par les migrations (cf. Version20260420202749).
|
||||||
|
schema_filter: '~^(?!audit_log$).+~'
|
||||||
|
audit:
|
||||||
|
url: '%env(resolve:DATABASE_URL)%'
|
||||||
orm:
|
orm:
|
||||||
validate_xml_mapping: true
|
validate_xml_mapping: true
|
||||||
naming_strategy: doctrine.orm.naming_strategy.underscore_number_aware
|
naming_strategy: doctrine.orm.naming_strategy.underscore_number_aware
|
||||||
@@ -31,7 +49,24 @@ doctrine:
|
|||||||
when@test:
|
when@test:
|
||||||
doctrine:
|
doctrine:
|
||||||
dbal:
|
dbal:
|
||||||
|
# Le suffixe "_test" doit etre propage aux deux connexions : l'ORM
|
||||||
|
# l'herite via `default`, l'AuditLogWriter via `audit`. Sans cela,
|
||||||
|
# la connexion `audit` ecrirait dans la base dev pendant que l'ORM
|
||||||
|
# ecrit dans la base test — divergence invisible en apparence mais
|
||||||
|
# fatale pour les tests du journal d'audit.
|
||||||
|
#
|
||||||
|
# `idle_connection_ttl: 1` (au lieu du defaut 600s) : en test on
|
||||||
|
# reboote le kernel a chaque test. Sans TTL court, les connexions
|
||||||
|
# orphelines s'accumulent dans PG et on finit par saturer le pool
|
||||||
|
# (max_connections=100) sur une suite de 200+ tests qui utilisent
|
||||||
|
# 2 connexions chacun (default + audit).
|
||||||
|
connections:
|
||||||
|
default:
|
||||||
dbname_suffix: '_test%env(default::TEST_TOKEN)%'
|
dbname_suffix: '_test%env(default::TEST_TOKEN)%'
|
||||||
|
idle_connection_ttl: 1
|
||||||
|
audit:
|
||||||
|
dbname_suffix: '_test%env(default::TEST_TOKEN)%'
|
||||||
|
idle_connection_ttl: 1
|
||||||
orm:
|
orm:
|
||||||
mappings:
|
mappings:
|
||||||
# Entite fictive SiteAware utilisee uniquement en tests du
|
# Entite fictive SiteAware utilisee uniquement en tests du
|
||||||
|
|||||||
6
config/packages/twig.yaml
Normal file
6
config/packages/twig.yaml
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
twig:
|
||||||
|
file_name_pattern: '*.twig'
|
||||||
|
|
||||||
|
when@test:
|
||||||
|
twig:
|
||||||
|
strict_variables: true
|
||||||
@@ -467,7 +467,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
|||||||
* },
|
* },
|
||||||
* disallow_search_engine_index?: bool|Param, // Enabled by default when debug is enabled. // Default: true
|
* disallow_search_engine_index?: bool|Param, // Enabled by default when debug is enabled. // Default: true
|
||||||
* http_client?: bool|array{ // HTTP Client configuration
|
* http_client?: bool|array{ // HTTP Client configuration
|
||||||
* enabled?: bool|Param, // Default: true
|
* enabled?: bool|Param, // Default: false
|
||||||
* max_host_connections?: int|Param, // The maximum number of connections to a single host.
|
* max_host_connections?: int|Param, // The maximum number of connections to a single host.
|
||||||
* default_options?: array{
|
* default_options?: array{
|
||||||
* headers?: array<string, mixed>,
|
* headers?: array<string, mixed>,
|
||||||
|
|||||||
@@ -6,34 +6,62 @@ declare(strict_types=1);
|
|||||||
* Sidebar configuration.
|
* Sidebar configuration.
|
||||||
*
|
*
|
||||||
* This file defines the sidebar sections displayed in the frontend.
|
* This file defines the sidebar sections displayed in the frontend.
|
||||||
* Each item references the module that owns it via the `module` key.
|
*
|
||||||
* Items whose module is not active (see config/modules.php) are filtered out.
|
* Each SECTION may declare :
|
||||||
* Items may also declare a `permission` key (RBAC permission code) : the item
|
* - `label` (required) : i18n key resolved by the frontend
|
||||||
* is hidden from users who do not hold that permission.
|
* - `icon` (required) : MDI icon name
|
||||||
|
* - `items` (required) : list of items (see below)
|
||||||
|
* - `permission` (opt.) : RBAC permission code ; when set, the whole
|
||||||
|
* section (and every one of its items) is hidden
|
||||||
|
* from users who do not hold that permission.
|
||||||
|
* Use this for "umbrella" sections like
|
||||||
|
* Administration where you want to gate the
|
||||||
|
* entire group behind one coarse permission.
|
||||||
|
*
|
||||||
|
* Each ITEM may declare :
|
||||||
|
* - `label` (required) : i18n key
|
||||||
|
* - `to` (required) : Nuxt route
|
||||||
|
* - `icon` (required) : MDI icon name
|
||||||
|
* - `module` (required) : owner module ID ; item is hidden if the
|
||||||
|
* module is not listed in config/modules.php
|
||||||
|
* - `permission` (opt.) : RBAC permission code ; finer-grained gate
|
||||||
|
* applied in addition to the section-level one
|
||||||
|
*
|
||||||
|
* Precedence : section-level `permission` is evaluated first. If it fails,
|
||||||
|
* the whole section is skipped and every item's `to` is added to the
|
||||||
|
* `disabledRoutes` payload of /api/sidebar (so the front middleware can
|
||||||
|
* redirect any direct navigation). Individual items without their own
|
||||||
|
* permission are implicitly protected by the section-level one.
|
||||||
*
|
*
|
||||||
* This config is decoupled from the modules themselves: you can freely
|
* This config is decoupled from the modules themselves: you can freely
|
||||||
* move an item from one section to another without touching the module code.
|
* move an item from one section to another without touching the module code.
|
||||||
*
|
|
||||||
* Label keys are i18n keys resolved by the frontend (see frontend/i18n/locales/).
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
return [
|
return [
|
||||||
|
// Section "Administration" : regroupe toutes les pages de configuration
|
||||||
|
// applicative (RBAC, users, sites, audit log).
|
||||||
|
//
|
||||||
|
// CONVENTION : "etre admin" = detenir au moins une permission admin-scoped.
|
||||||
|
// En pratique, le groupe `core.*` represente l'administration applicative
|
||||||
|
// (users, roles, audit_log) ; les autres permissions admin-scoped proviennent
|
||||||
|
// des modules qui exposent leur propre page d'admin dans cette section
|
||||||
|
// (ex: `sites.view`). Un user qui n'a AUCUNE de ces permissions n'a pas
|
||||||
|
// acces a l'administration.
|
||||||
|
//
|
||||||
|
// Gate implicite : tous les items de cette section declarent une `permission`.
|
||||||
|
// Sans aucune permission correspondante, tous les items sont filtres, la
|
||||||
|
// section devient vide et est automatiquement masquee par SidebarProvider
|
||||||
|
// (cf. la boucle de filtrage : section vide => `continue`). Inutile donc
|
||||||
|
// d'ajouter un gate explicite au niveau section tant que chaque item porte
|
||||||
|
// sa propre permission.
|
||||||
|
//
|
||||||
|
// Pour imposer un gate explicite supplementaire (ex: "seuls les membres du
|
||||||
|
// groupe support voient l'administration, meme s'ils ont des permissions
|
||||||
|
// individuelles"), ajouter : 'permission' => 'core.admin.access'.
|
||||||
[
|
[
|
||||||
'label' => 'sidebar.general.section',
|
'label' => 'sidebar.administration.section',
|
||||||
'icon' => 'mdi:view-dashboard-outline',
|
|
||||||
'items' => [
|
|
||||||
[
|
|
||||||
'label' => 'sidebar.general.dashboard',
|
|
||||||
'to' => '/',
|
|
||||||
'icon' => 'mdi:view-dashboard-outline',
|
|
||||||
'module' => 'core',
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'label' => 'sidebar.general.admin',
|
|
||||||
'to' => '/admin',
|
|
||||||
'icon' => 'mdi:cog-outline',
|
'icon' => 'mdi:cog-outline',
|
||||||
'module' => 'core',
|
'items' => [
|
||||||
],
|
|
||||||
[
|
[
|
||||||
'label' => 'sidebar.core.roles',
|
'label' => 'sidebar.core.roles',
|
||||||
'to' => '/admin/roles',
|
'to' => '/admin/roles',
|
||||||
@@ -56,10 +84,11 @@ return [
|
|||||||
'permission' => 'sites.view',
|
'permission' => 'sites.view',
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
'label' => 'sidebar.general.logout',
|
'label' => 'sidebar.core.audit_log',
|
||||||
'to' => '/logout',
|
'to' => '/admin/audit-log',
|
||||||
'icon' => 'mdi:logout',
|
'icon' => 'mdi:clipboard-text-clock',
|
||||||
'module' => 'core',
|
'module' => 'core',
|
||||||
|
'permission' => 'core.audit_log.view',
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
@@ -75,4 +104,25 @@ return [
|
|||||||
],
|
],
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
|
// Section "Mon compte" : espace personnel. Accessible a tout user authentifie
|
||||||
|
// (aucune permission RBAC requise, tous les items restent dans `core` pour
|
||||||
|
// rester toujours presents meme quand les modules metier sont desactives).
|
||||||
|
[
|
||||||
|
'label' => 'sidebar.account.section',
|
||||||
|
'icon' => 'mdi:account-circle-outline',
|
||||||
|
'items' => [
|
||||||
|
[
|
||||||
|
'label' => 'sidebar.account.dashboard',
|
||||||
|
'to' => '/',
|
||||||
|
'icon' => 'mdi:view-dashboard-outline',
|
||||||
|
'module' => 'core',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'label' => 'sidebar.account.logout',
|
||||||
|
'to' => '/logout',
|
||||||
|
'icon' => 'mdi:logout',
|
||||||
|
'module' => 'core',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
parameters:
|
parameters:
|
||||||
app.version: '0.1.32'
|
app.version: '0.1.34'
|
||||||
|
|||||||
411
doc/audit-log.md
Normal file
411
doc/audit-log.md
Normal file
@@ -0,0 +1,411 @@
|
|||||||
|
# Audit Log — Specification technique
|
||||||
|
|
||||||
|
## Objectif
|
||||||
|
|
||||||
|
Tracer l'historique de toutes les modifications BDD dans une table `audit_log` append-only. L'audit est opt-in via l'attribut `#[Auditable]` sur les entites, expose en lecture seule via API Platform (permission RBAC `core.audit_log.view`), et visualise dans le frontend via une page dediee et un composant timeline reutilisable.
|
||||||
|
|
||||||
|
**Regle projet** : toute entite (nouvelle ou existante) doit etre annotee `#[Auditable]` avec `#[AuditIgnore]` sur les champs sensibles. L'audit n'est pas optionnel — il est obligatoire sur toutes les entites metier.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
Shared/
|
||||||
|
Domain/
|
||||||
|
Attribute/
|
||||||
|
Auditable.php # Attribut classe — active le tracking
|
||||||
|
AuditIgnore.php # Attribut propriete — exclut un champ
|
||||||
|
Module/
|
||||||
|
Core/
|
||||||
|
CoreModule.php # + permission core.audit_log.view
|
||||||
|
Application/
|
||||||
|
DTO/
|
||||||
|
AuditLogOutput.php # DTO lecture seule
|
||||||
|
Infrastructure/
|
||||||
|
Audit/
|
||||||
|
AuditLogWriter.php # Ecrit via DBAL (pas Doctrine ORM)
|
||||||
|
RequestIdProvider.php # UUID v4 par requete HTTP
|
||||||
|
Doctrine/
|
||||||
|
AuditListener.php # Listener onFlush/postFlush
|
||||||
|
Migrations/ # (migration dans migrations/ racine — cf. bug tri FQCN)
|
||||||
|
ApiPlatform/
|
||||||
|
Resource/
|
||||||
|
AuditLogResource.php # ApiResource read-only
|
||||||
|
State/
|
||||||
|
Provider/
|
||||||
|
AuditLogProvider.php # Provider DBAL
|
||||||
|
|
||||||
|
frontend/
|
||||||
|
shared/
|
||||||
|
composables/
|
||||||
|
useAuditLog.ts # Composable partage (page + timeline)
|
||||||
|
components/
|
||||||
|
audit/
|
||||||
|
AuditTimeline.vue # Timeline verticale reutilisable
|
||||||
|
types/
|
||||||
|
index.ts # + AuditLogEntry, AuditLogFilters, HydraView
|
||||||
|
utils/
|
||||||
|
api.ts # + support hydra:view pagination
|
||||||
|
modules/
|
||||||
|
core/
|
||||||
|
pages/
|
||||||
|
admin/
|
||||||
|
audit-log.vue # Page globale admin
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Table PostgreSQL `audit_log`
|
||||||
|
|
||||||
|
Table non geree par Doctrine ORM (pas d'entite). Ecriture via DBAL uniquement pour eviter la recursion des listeners.
|
||||||
|
|
||||||
|
### Schema
|
||||||
|
|
||||||
|
| Colonne | Type | Contrainte | Description |
|
||||||
|
|---------|------|-----------|-------------|
|
||||||
|
| `id` | uuid | PK | UUID v7 genere en PHP (`Uuid::v7()->toRfc4122()`) — type natif PG (16 octets vs 36 en varchar) |
|
||||||
|
| `entity_type` | varchar(100) | NOT NULL | Format `module.Entity` (ex: `core.User`, `commercial.Client`) — evite les collisions inter-modules |
|
||||||
|
| `entity_id` | varchar(64) | NOT NULL | ID de l'entite (supporte int et UUID) |
|
||||||
|
| `action` | varchar(10) | NOT NULL | `create`, `update`, `delete` |
|
||||||
|
| `changes` | jsonb | NOT NULL DEFAULT '{}' | Changements (format selon action) |
|
||||||
|
| `performed_by` | varchar(100) | NOT NULL | Username denormalise (survit a la suppression du user) |
|
||||||
|
| `performed_at` | timestamptz | NOT NULL | Horodatage de l'action |
|
||||||
|
| `ip_address` | varchar(45) | NULL | Adresse IP (null en CLI) |
|
||||||
|
| `request_id` | varchar(36) | NULL | UUID v4 par requete HTTP (null en CLI) |
|
||||||
|
|
||||||
|
### Index
|
||||||
|
|
||||||
|
- `idx_audit_entity_time` : `(entity_type, entity_id, performed_at)` — recherche par entite
|
||||||
|
- `idx_audit_performer` : `(performed_by, performed_at)` — recherche par utilisateur
|
||||||
|
- `idx_audit_time` : `(performed_at)` — tri chronologique global
|
||||||
|
|
||||||
|
### Regles
|
||||||
|
|
||||||
|
- **Append-only** : pas d'UPDATE, pas de DELETE
|
||||||
|
- **Colonnes en minuscules** (convention PostgreSQL du projet)
|
||||||
|
- **Champs sensibles exclus** : `password`, `plainPassword`, `token`, `secret` ne doivent jamais apparaitre dans `changes`
|
||||||
|
- **`performed_by` denormalise** : string, pas FK — le nom persiste meme si l'utilisateur est supprime
|
||||||
|
- **Migration** : dans `migrations/` (namespace racine `DoctrineMigrations`) a cause du bug de tri alphabetique FQCN de Doctrine Migrations 3.x entre namespaces
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Composants backend
|
||||||
|
|
||||||
|
### `AuditLogWriter`
|
||||||
|
|
||||||
|
**Emplacement** : `src/Module/Core/Infrastructure/Audit/AuditLogWriter.php`
|
||||||
|
|
||||||
|
Service responsable de l'ecriture dans `audit_log` via `Connection::executeStatement()`.
|
||||||
|
|
||||||
|
**Dependances** :
|
||||||
|
- `Doctrine\DBAL\Connection` — connexion DBAL dediee `audit` (meme DSN, service separe) pour eviter l'entanglement transactionnel avec l'ORM. Config : `doctrine.dbal.connections.audit` dans `doctrine.yaml`. Injection via `#[Autowire(service: 'doctrine.dbal.audit_connection')]`.
|
||||||
|
- `Symfony\Bundle\SecurityBundle\Security`
|
||||||
|
- `Symfony\Component\HttpFoundation\RequestStack`
|
||||||
|
- `RequestIdProvider`
|
||||||
|
|
||||||
|
**Methode principale** :
|
||||||
|
```php
|
||||||
|
public function log(
|
||||||
|
string $entityType,
|
||||||
|
string $entityId,
|
||||||
|
string $action,
|
||||||
|
array $changes,
|
||||||
|
): void
|
||||||
|
```
|
||||||
|
|
||||||
|
**Comportement** :
|
||||||
|
- Genere `id` via `Uuid::v7()->toRfc4122()`
|
||||||
|
- `performed_by` = `$security->getUser()?->getUserIdentifier() ?? 'system'`
|
||||||
|
- `ip_address` = `$requestStack->getCurrentRequest()?->getClientIp()`
|
||||||
|
- `request_id` = `$requestIdProvider->getRequestId()`
|
||||||
|
- `performed_at` = `new \DateTimeImmutable('now', new \DateTimeZone('UTC'))`
|
||||||
|
- Filtre les cles sensibles (`password`, `plainPassword`, `token`, `secret`) de `$changes`
|
||||||
|
- INSERT SQL brut via DBAL
|
||||||
|
|
||||||
|
**Necessite** : `composer require symfony/uid`
|
||||||
|
|
||||||
|
### `RequestIdProvider`
|
||||||
|
|
||||||
|
**Emplacement** : `src/Module/Core/Infrastructure/Audit/RequestIdProvider.php`
|
||||||
|
|
||||||
|
Service singleton qui genere un UUID v4 unique par requete HTTP principale.
|
||||||
|
|
||||||
|
**Comportement** :
|
||||||
|
- Ecoute `kernel.request` via `#[AsEventListener]`
|
||||||
|
- Ignore les sub-requests : `if (!$event->isMainRequest()) return;`
|
||||||
|
- Genere `Uuid::v4()->toRfc4122()` a chaque requete principale
|
||||||
|
- Expose `getRequestId(): ?string` (null en CLI)
|
||||||
|
|
||||||
|
### Attributs `#[Auditable]` et `#[AuditIgnore]`
|
||||||
|
|
||||||
|
**Emplacement** : `src/Shared/Domain/Attribute/` (dans Shared, pas Core — tous les modules doivent y acceder)
|
||||||
|
|
||||||
|
- `#[Auditable]` : attribut de classe, marqueur vide. Active le tracking sur l'entite.
|
||||||
|
- `#[AuditIgnore]` : attribut de propriete, marqueur vide. Exclut un champ du tracking.
|
||||||
|
|
||||||
|
### `AuditListener`
|
||||||
|
|
||||||
|
**Emplacement** : `src/Module/Core/Infrastructure/Doctrine/AuditListener.php`
|
||||||
|
|
||||||
|
Listener Doctrine (pas EventSubscriber — deprecie Symfony 8) utilisant `#[AsDoctrineListener]`.
|
||||||
|
|
||||||
|
**Evenements** :
|
||||||
|
- `onFlush` : collecte les changesets (aucune ecriture)
|
||||||
|
- `postFlush` : ecrit via `AuditLogWriter` (hors transaction Doctrine)
|
||||||
|
|
||||||
|
**Dependances** :
|
||||||
|
- `AuditLogWriter`
|
||||||
|
- `LoggerInterface`
|
||||||
|
|
||||||
|
**Logique `onFlush`** :
|
||||||
|
1. Recupere `UnitOfWork`
|
||||||
|
2. Parcourt insertions, updates, deletions
|
||||||
|
3. Pour chaque entite : verifie `#[Auditable]` via `ReflectionClass::getAttributes()`
|
||||||
|
4. Filtre les proprietes `#[AuditIgnore]` + blacklist hardcodee
|
||||||
|
5. Formate les changements :
|
||||||
|
- **create** : snapshot complet de toutes les proprietes non-ignorees
|
||||||
|
- **update** : `{champ: {old: x, new: y}}` via `getEntityChangeSet()`
|
||||||
|
- **delete** : snapshot complet
|
||||||
|
6. ManyToOne : log l'ID via `?->getId()` (null-safe pour les relations nullable), pas l'objet
|
||||||
|
7. Stocke dans `$pendingLogs` (propriete privee)
|
||||||
|
|
||||||
|
**Logique `postFlush`** — pattern swap-and-clear (protection contre flush re-entrant) :
|
||||||
|
1. Copie `$pendingLogs` dans variable locale, vide immediatement `$this->pendingLogs = []`
|
||||||
|
2. Pour chaque log copie → `AuditLogWriter::log()`
|
||||||
|
3. Try/catch : erreur → `$logger->error(...)`, jamais de crash
|
||||||
|
|
||||||
|
**Cas particuliers** :
|
||||||
|
- Flush sans changement → rien
|
||||||
|
- Entite sans `#[Auditable]` → ignoree
|
||||||
|
- Batch (fixtures, import) → chaque entite auditee, groupees par `request_id`
|
||||||
|
- Console → `performed_by = 'system'`, `ip_address = null`, `request_id = null`
|
||||||
|
- ManyToMany : non couvert par `getEntityChangeSet()` — limitation connue. Les changements de collections (ex: `User::$rbacRoles`) ne sont pas audites. Ajout futur possible via `getScheduledCollectionUpdates()`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## API Platform — Lecture seule
|
||||||
|
|
||||||
|
### `AuditLogResource`
|
||||||
|
|
||||||
|
**Emplacement** : `src/Module/Core/Infrastructure/ApiPlatform/Resource/AuditLogResource.php`
|
||||||
|
|
||||||
|
**Operations** :
|
||||||
|
- `GET /api/audit-logs` — collection paginee (30 items/page), tri `performed_at DESC`
|
||||||
|
- `GET /api/audit-logs/{id}` — detail
|
||||||
|
|
||||||
|
**Securite** : `is_granted('core.audit_log.view')` — permission RBAC, 403 sinon
|
||||||
|
|
||||||
|
**Pas d'endpoints d'ecriture** : POST, PUT, PATCH, DELETE → 405
|
||||||
|
|
||||||
|
### `AuditLogOutput`
|
||||||
|
|
||||||
|
**Emplacement** : `src/Module/Core/Application/DTO/AuditLogOutput.php`
|
||||||
|
|
||||||
|
DTO readonly avec les champs : `id`, `entityType`, `entityId`, `action`, `changes`, `performedBy`, `performedAt`, `ipAddress`, `requestId`.
|
||||||
|
|
||||||
|
### `AuditLogProvider`
|
||||||
|
|
||||||
|
**Emplacement** : `src/Module/Core/Infrastructure/ApiPlatform/State/Provider/AuditLogProvider.php`
|
||||||
|
|
||||||
|
Provider DBAL (pas Doctrine ORM).
|
||||||
|
|
||||||
|
**Filtres** (query params, combinables en AND) :
|
||||||
|
- `entity_type` : filtre exact
|
||||||
|
- `entity_id` : filtre exact
|
||||||
|
- `action` : filtre exact
|
||||||
|
- `performed_by` : filtre exact
|
||||||
|
- `performed_at[after]` : date minimum (incluse)
|
||||||
|
- `performed_at[before]` : date maximum (incluse)
|
||||||
|
|
||||||
|
**Pagination** : via un `DbalPaginator` implementant `ApiPlatform\State\Pagination\PaginatorInterface` (`getCurrentPage()`, `getLastPage()`, `getTotalItems()`, `getItemsPerPage()`, `count()`, `getIterator()`). Le provider retourne ce paginator — API Platform genere automatiquement `hydra:view`. Pas de construction manuelle de la pagination.
|
||||||
|
|
||||||
|
### Permission RBAC
|
||||||
|
|
||||||
|
Ajouter dans `CoreModule::permissions()` :
|
||||||
|
- `core.audit_log.view`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Frontend
|
||||||
|
|
||||||
|
### Composable `useAuditLog.ts`
|
||||||
|
|
||||||
|
**Emplacement** : `frontend/shared/composables/useAuditLog.ts`
|
||||||
|
|
||||||
|
Composable partage, reutilise par la page globale (ticket 4) et le composant timeline (ticket 5).
|
||||||
|
|
||||||
|
**Methodes** :
|
||||||
|
- `fetchLogs(filters?: AuditLogFilters): Promise<HydraCollection<AuditLogEntry>>`
|
||||||
|
- `fetchLogById(id: string): Promise<AuditLogEntry>`
|
||||||
|
- `fetchEntityLogs(entityType: string, entityId: string, page?: number): Promise<HydraCollection<AuditLogEntry>>`
|
||||||
|
|
||||||
|
Utilise `useApi().get()`.
|
||||||
|
|
||||||
|
Si le composable maintient du state singleton (refs module-level pour cache), il doit exposer `resetAuditLog()` et etre reinitialise au logout (regle CLAUDE.md).
|
||||||
|
|
||||||
|
### Types
|
||||||
|
|
||||||
|
Ajouter dans `frontend/shared/types/index.ts` :
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export interface AuditLogEntry {
|
||||||
|
id: string
|
||||||
|
entityType: string
|
||||||
|
entityId: string
|
||||||
|
action: 'create' | 'update' | 'delete'
|
||||||
|
changes: Record<string, unknown>
|
||||||
|
performedBy: string
|
||||||
|
performedAt: string
|
||||||
|
ipAddress: string | null
|
||||||
|
requestId: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuditLogFilters {
|
||||||
|
entityType?: string
|
||||||
|
entityId?: string
|
||||||
|
action?: string
|
||||||
|
performedBy?: string
|
||||||
|
performedAtAfter?: string
|
||||||
|
performedAtBefore?: string
|
||||||
|
page?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface HydraView {
|
||||||
|
'hydra:first'?: string
|
||||||
|
'hydra:last'?: string
|
||||||
|
'hydra:next'?: string
|
||||||
|
'hydra:previous'?: string
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Le type `HydraView` doit etre ajoute dans `frontend/shared/utils/api.ts` (a cote de `HydraCollection`) et `HydraCollection` doit etre etendu avec un champ optionnel `'hydra:view'?: HydraView`.
|
||||||
|
|
||||||
|
### Page `admin/audit-log.vue`
|
||||||
|
|
||||||
|
**Emplacement** : `frontend/modules/core/pages/admin/audit-log.vue`
|
||||||
|
|
||||||
|
**Acces** : permission RBAC `core.audit_log.view` (verifie via `usePermissions().can('core.audit_log.view')`)
|
||||||
|
|
||||||
|
**Elements** :
|
||||||
|
- Tableau pagine avec style projet (header `bg-tertiary-500`, rows hover)
|
||||||
|
- Filtres : plage dates, type entite (select), utilisateur (input), action (checkboxes), bouton reset
|
||||||
|
- Filtres persistes dans les query params URL
|
||||||
|
- Ligne expandable au clic :
|
||||||
|
- update : tableau champ / ancienne valeur / nouvelle valeur
|
||||||
|
- create/delete : snapshot complet
|
||||||
|
- Badges action :
|
||||||
|
- create : `bg-green-100 text-green-800`
|
||||||
|
- update : `bg-yellow-100 text-yellow-800`
|
||||||
|
- delete : `bg-red-100 text-red-800`
|
||||||
|
- Pagination prev/next via `hydra:view`
|
||||||
|
- Etat vide : message i18n "Aucune activite enregistree"
|
||||||
|
- Chargement initial : 30 dernieres entrees sans filtre
|
||||||
|
|
||||||
|
### Sidebar
|
||||||
|
|
||||||
|
Ajouter entree dans `config/sidebar.php` :
|
||||||
|
- Label : `sidebar.core.audit_log`
|
||||||
|
- Route : `/admin/audit-log`
|
||||||
|
- Icon : a definir (ex: `mdi:clipboard-text-clock`)
|
||||||
|
- Module : `core`
|
||||||
|
- Permission : `core.audit_log.view` — filtre automatiquement cote SidebarProvider
|
||||||
|
|
||||||
|
### Composant `AuditTimeline.vue`
|
||||||
|
|
||||||
|
**Emplacement** : `frontend/shared/components/audit/AuditTimeline.vue`
|
||||||
|
|
||||||
|
Composant reutilisable, auto-importe par Nuxt.
|
||||||
|
|
||||||
|
**Props** :
|
||||||
|
- `entityType: string`
|
||||||
|
- `entityId: string | number`
|
||||||
|
|
||||||
|
**Comportement** :
|
||||||
|
- Garde permission : si `!usePermissions().can('core.audit_log.view')` → rendu vide, aucun appel API
|
||||||
|
- Timeline verticale : bordure gauche (`border-l-2 border-gray-200`) + dots colores par action
|
||||||
|
- Chaque entree : icone + date relative FR (`Intl.RelativeTimeFormat('fr')`) + date absolue en tooltip + utilisateur + resume
|
||||||
|
- Update : affiche old → new par champ
|
||||||
|
- Lazy loading : 10 items initiaux + bouton "Voir plus"
|
||||||
|
- Skeleton loader pendant le chargement
|
||||||
|
- Etat vide : "Aucun historique"
|
||||||
|
|
||||||
|
**Premiere integration** : sur la page `admin/audit-log.vue`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## i18n
|
||||||
|
|
||||||
|
Cles a ajouter dans `frontend/i18n/locales/fr.json` :
|
||||||
|
|
||||||
|
Structure imbriquee (respecte le format existant de `fr.json`) :
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"sidebar": {
|
||||||
|
"core": {
|
||||||
|
"audit_log": "Journal d'audit"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"audit": {
|
||||||
|
"action": {
|
||||||
|
"create": "Création",
|
||||||
|
"update": "Modification",
|
||||||
|
"delete": "Suppression"
|
||||||
|
},
|
||||||
|
"entity": {
|
||||||
|
"user": "Utilisateur"
|
||||||
|
},
|
||||||
|
"empty": "Aucune activité enregistrée",
|
||||||
|
"no_results": "Aucun résultat pour ces filtres",
|
||||||
|
"timeline": {
|
||||||
|
"empty": "Aucun historique",
|
||||||
|
"load_more": "Voir plus"
|
||||||
|
},
|
||||||
|
"filters": {
|
||||||
|
"reset": "Réinitialiser",
|
||||||
|
"date_from": "Du",
|
||||||
|
"date_to": "Au",
|
||||||
|
"entity_type": "Type d'entité",
|
||||||
|
"user": "Utilisateur",
|
||||||
|
"action": "Action"
|
||||||
|
},
|
||||||
|
"detail": {
|
||||||
|
"field": "Champ",
|
||||||
|
"old_value": "Ancienne valeur",
|
||||||
|
"new_value": "Nouvelle valeur"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ordre d'implementation
|
||||||
|
|
||||||
|
```
|
||||||
|
Ticket 1 ────► Ticket 2 ────► Ticket 3 ────┬──► Ticket 4
|
||||||
|
Table + Attributs + API │ Page admin
|
||||||
|
Writer Listener read-only │
|
||||||
|
└──► Ticket 5
|
||||||
|
Timeline
|
||||||
|
(4 et 5 en parallele)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Decisions techniques (issues reviews)
|
||||||
|
|
||||||
|
- **Connexion DBAL dediee** : `AuditLogWriter` utilise une connexion separee `audit` (meme DSN) pour eviter l'entanglement transactionnel avec l'ORM en batch
|
||||||
|
- **PaginatorInterface** : le provider retourne un `DbalPaginator` implementant l'interface API Platform — pas de construction manuelle `hydra:view`
|
||||||
|
- **Type natif `uuid` PG** : 16 octets vs 36 en varchar, index 40% plus petit sur table append-only a croissance infinie
|
||||||
|
- **Pattern swap-and-clear** dans `postFlush` : protection contre flush re-entrant
|
||||||
|
- **Blacklist exact-match** sur noms de proprietes (`password`, `plainPassword`, `token`, `secret`) — en defense-in-depth avec `#[AuditIgnore]`
|
||||||
|
- **ManyToMany non audite** : limitation connue, `getEntityChangeSet()` ne couvre pas les collections
|
||||||
|
- **Erreur audit silencieuse** : loguee, jamais propagee — pas de retry/dead-letter (acceptable pour CRM interne)
|
||||||
|
- **`entity_type` format `module.Entity`** : evite collisions si deux modules ont des entites de meme nom
|
||||||
|
|
||||||
|
## Dependances externes
|
||||||
|
|
||||||
|
- `symfony/uid` : generation UUID v7 (id) et v4 (request_id)
|
||||||
@@ -45,7 +45,10 @@ services:
|
|||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
db:
|
db:
|
||||||
image: postgres:16-alpine
|
image: postgres:16-alpine
|
||||||
command: -p ${POSTGRES_PORT:-5436}
|
# max_connections eleve (defaut PG=100) pour absorber la suite de tests :
|
||||||
|
# ~220 tests * kernel reboot par test * 2 connexions (default + audit)
|
||||||
|
# peut saturer le pool, meme avec idle_connection_ttl court cote Doctrine.
|
||||||
|
command: -p ${POSTGRES_PORT:-5436} -c max_connections=300
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_DB: ${POSTGRES_DB}
|
POSTGRES_DB: ${POSTGRES_DB}
|
||||||
POSTGRES_USER: ${POSTGRES_USER}
|
POSTGRES_USER: ${POSTGRES_USER}
|
||||||
|
|||||||
@@ -572,3 +572,78 @@ Chaque etape doit etre revue (spec compliance + code quality) avant de passer a
|
|||||||
- Branche de travail : `feat/rbac-voter`, tiree de `feat/rbac-api`.
|
- Branche de travail : `feat/rbac-voter`, tiree de `feat/rbac-api`.
|
||||||
- Pas de PR dediee : les commits #345 s'empilent sur la PR #3 existante ouverte vers `develop`.
|
- Pas de PR dediee : les commits #345 s'empilent sur la PR #3 existante ouverte vers `develop`.
|
||||||
- Une fois la PR #3 mergee, la branche finale de l'epic RBAC (`feat/rbac-admin-ui` pour #346) partira de `develop`.
|
- Une fois la PR #3 mergee, la branche finale de l'epic RBAC (`feat/rbac-admin-ui` pour #346) partira de `develop`.
|
||||||
|
|
||||||
|
## 18. Evolutions post-livraison — `UserRbacProcessor` defense in depth
|
||||||
|
|
||||||
|
Voir aussi : `docs/sites/ticket-02-spec.md` § 10 pour la problematique cote
|
||||||
|
Sites qui a motive cette evolution.
|
||||||
|
|
||||||
|
### 18.1 — Semantique `merge-patch+json` respectee
|
||||||
|
|
||||||
|
Le processor originel appliquait telles quelles les mutations produites par la
|
||||||
|
denormalisation API Platform. Or API Platform reinstancie par defaut une
|
||||||
|
`ArrayCollection` vide pour chaque propriete ManyToMany absente du payload,
|
||||||
|
ce qui viole la semantique `application/merge-patch+json` : les cles absentes
|
||||||
|
ne doivent PAS muter les proprietes correspondantes.
|
||||||
|
|
||||||
|
Consequence concrete du bug : un PATCH minimal comme `{ "isAdmin": true }`
|
||||||
|
detruisait silencieusement toutes les collections (`rbacRoles`,
|
||||||
|
`directPermissions`, `sites`) du user cible.
|
||||||
|
|
||||||
|
La garde `restoreAbsentCollections()` introduite dans `UserRbacProcessor`
|
||||||
|
resout cela en :
|
||||||
|
|
||||||
|
1. Injectant `RequestStack` pour lire le body JSON brut de la requete.
|
||||||
|
2. Decodant les cles effectivement envoyees par le client.
|
||||||
|
3. Pour chaque cle RBAC (`roles`, `directPermissions`, `sites`) absente du
|
||||||
|
payload : restaurant la collection a son etat d'origine a partir du
|
||||||
|
snapshot Doctrine (`PersistentCollection::getSnapshot()`), puis appelant
|
||||||
|
`takeSnapshot()` pour marquer la collection comme non-dirty (aucune query
|
||||||
|
`UPDATE` n'est emise sur les tables de jointure).
|
||||||
|
4. No-op si la cle est presente (la denormalisation fait foi).
|
||||||
|
|
||||||
|
Matrice finale :
|
||||||
|
|
||||||
|
| Payload | Effet |
|
||||||
|
|---------------------------------|-------------------------------------|
|
||||||
|
| Cle absente | Propriete preservee (BDD inchangee) |
|
||||||
|
| Cle presente = `[]` | Collection videe (vidage explicite) |
|
||||||
|
| Cle presente = `[...]` | Collection remplacee |
|
||||||
|
|
||||||
|
### 18.2 — Nouvelle operation `GET /users/{id}/rbac`
|
||||||
|
|
||||||
|
Le drawer d'edition (`UserRbacDrawer.vue`) ne peut plus dependre du payload
|
||||||
|
de liste `/api/users` (groupe `user:list`) pour initialiser l'etat `sites`
|
||||||
|
car ce groupe reste volontairement leger (cf. ticket Sites #02). Une
|
||||||
|
operation `Get` dediee est ajoutee, symetrique au `Patch` existant :
|
||||||
|
|
||||||
|
- URI : `/users/{id}/rbac`
|
||||||
|
- Security : `is_granted('core.users.manage')` (plus strict que `.view`)
|
||||||
|
- Groupe : `user:rbac:read` (contient `isAdmin`, `roles`, `directPermissions`,
|
||||||
|
`sites`).
|
||||||
|
|
||||||
|
Le drawer charge desormais ce GET en parallele des referentiels au moment
|
||||||
|
de l'ouverture, via un watch combine `[modelValue, user.id]` qui recharge
|
||||||
|
correctement si le user change sans fermeture du drawer entre-temps.
|
||||||
|
|
||||||
|
### 18.3 — Impact sur les tests
|
||||||
|
|
||||||
|
`UserRbacProcessorTest` : le constructor gagne un argument `RequestStack`.
|
||||||
|
Les tests existants injectent une `RequestStack` avec une `Request` vide
|
||||||
|
(body `""`), ce qui rend la garde no-op — le comportement des assertions
|
||||||
|
existantes est conserve. De nouveaux tests couvrent la garde :
|
||||||
|
|
||||||
|
- PATCH sans cle `sites` ne mute pas la collection d'origine.
|
||||||
|
- PATCH avec `sites: []` vide bien la collection (pas de regression du cas
|
||||||
|
"vidage explicite").
|
||||||
|
- PATCH avec `sites: [...]` remplace comme avant.
|
||||||
|
|
||||||
|
### 18.4 — Criteres de validation additionnels
|
||||||
|
|
||||||
|
- [ ] `GET /users/{id}/rbac` retourne 200 avec `core.users.manage`, 403 sans.
|
||||||
|
- [ ] Le payload contient `{ id, isAdmin, roles, directPermissions, sites }`.
|
||||||
|
- [ ] `PATCH /users/{id}/rbac` avec cle absente preserve la collection BDD.
|
||||||
|
- [ ] `PATCH /users/{id}/rbac` avec `[]` vide la collection et declenche
|
||||||
|
`ensureCurrentSiteConsistency` (cas sites).
|
||||||
|
- [ ] Les 228 tests PHPUnit existants passent apres ajout du parametre
|
||||||
|
`RequestStack` au constructor.
|
||||||
|
|||||||
@@ -590,3 +590,112 @@ Le ticket autorise un user sans sites (`sites: []`, `currentSite: null`). Mais a
|
|||||||
- [ ] `make test` passe toutes les suites (les 5 nouvelles + les existantes ajustees aux fixtures).
|
- [ ] `make test` passe toutes les suites (les 5 nouvelles + les existantes ajustees aux fixtures).
|
||||||
- [ ] `make php-cs-fixer-allow-risky` propre sur les fichiers nouveaux et modifies.
|
- [ ] `make php-cs-fixer-allow-risky` propre sur les fichiers nouveaux et modifies.
|
||||||
- [ ] Desactiver `SitesModule::class` dans `config/modules.php` ne casse pas les endpoints Core (la DB reste valide, les users conservent leurs sites meme si l'UI ne les expose plus).
|
- [ ] Desactiver `SitesModule::class` dans `config/modules.php` ne casse pas les endpoints Core (la DB reste valide, les users conservent leurs sites meme si l'UI ne les expose plus).
|
||||||
|
|
||||||
|
## 10. Evolutions post-livraison — drawer RBAC et defense in depth
|
||||||
|
|
||||||
|
Apres la livraison initiale du ticket, un bug utilisateur a revele que le drawer
|
||||||
|
`UserRbacDrawer.vue` demarrait toujours avec 0 site coche pour un user qui en
|
||||||
|
avait en BDD, et que la sauvegarde ecrasait silencieusement les sites
|
||||||
|
existants. Root cause : l'endpoint `GET /api/users` utilise le groupe `user:list`
|
||||||
|
qui n'expose pas la collection `sites` (choix assume pour garder le payload
|
||||||
|
leger et eviter toute fuite croisee site). Le drawer initialisait donc
|
||||||
|
`selectedSiteIds` a partir d'un `user.sites` toujours `undefined`.
|
||||||
|
|
||||||
|
Deux evolutions ont ete apportees pour corriger cela proprement sans elargir la
|
||||||
|
surface de fuite de `/api/users` :
|
||||||
|
|
||||||
|
### 10.1 — Nouvelle operation `GET /users/{id}/rbac`
|
||||||
|
|
||||||
|
Une operation API Platform `Get` est ajoutee sur `User`, symetrique au `Patch`
|
||||||
|
existant, sous la meme URI `/users/{id}/rbac` :
|
||||||
|
|
||||||
|
```php
|
||||||
|
new Get(
|
||||||
|
name: 'user_rbac_get',
|
||||||
|
uriTemplate: '/users/{id}/rbac',
|
||||||
|
security: "is_granted('core.users.manage')",
|
||||||
|
normalizationContext: ['groups' => ['user:rbac:read']],
|
||||||
|
),
|
||||||
|
```
|
||||||
|
|
||||||
|
Raisons du design :
|
||||||
|
- **Symetrie REST** : GET et PATCH partagent la meme URI et le meme groupe de
|
||||||
|
normalisation, documentation OpenAPI et appels clients lisibles.
|
||||||
|
- **Separation list/detail** : `/api/users` (`user:list`) reste maigre — pas de
|
||||||
|
collection, pas de fuite. `/users/{id}/rbac` (`user:rbac:read`) porte le
|
||||||
|
detail riche requis par le drawer d'edition.
|
||||||
|
- **Garde de permission plus stricte** : `core.users.manage` (et non `.view`)
|
||||||
|
— le detail RBAC est concu pour l'edition, pas la consultation.
|
||||||
|
- **Isolation du couplage Sites** : la dependance au module Sites reste scopee
|
||||||
|
a cet endpoint et a `/api/me`. Elle n'est pas disseminee dans tous les
|
||||||
|
payloads de liste.
|
||||||
|
|
||||||
|
Cote frontend (`UserRbacDrawer.vue`) :
|
||||||
|
- `loadData(userId)` fetch desormais `/users/{id}/rbac` en parallele des
|
||||||
|
referentiels (roles, permissions, sites globaux).
|
||||||
|
- Le watch combine `[modelValue, user.id]` recharge le detail a chaque
|
||||||
|
ouverture ou changement de user sans dependance fragile sur `props.user`.
|
||||||
|
- Le type `UserListItem` perd `sites` (inutilise) ; un nouveau type
|
||||||
|
`UserRbacDetail` represente le payload du GET dedie.
|
||||||
|
- La colonne "Sites" de `/admin/users` est retiree : l'info est consultee
|
||||||
|
dans le drawer. Cela supprime aussi le second fetch `/api/sites` sur la
|
||||||
|
page de liste.
|
||||||
|
|
||||||
|
### 10.2 — Garde anti-ecrasement dans `UserRbacProcessor`
|
||||||
|
|
||||||
|
API Platform denormalize les collections ManyToMany comme des `ArrayCollection`
|
||||||
|
vides quand la cle JSON correspondante est absente du payload, violant la
|
||||||
|
semantique `merge-patch+json` qui impose que les cles absentes ne mutent PAS
|
||||||
|
les proprietes. Pour un PATCH qui ne veut toucher que `isAdmin`, cela
|
||||||
|
detruirait tous les sites/roles/directPermissions du user.
|
||||||
|
|
||||||
|
Le processor injecte desormais `RequestStack`, lit le body JSON brut au debut
|
||||||
|
de `process()`, et pour chaque collection absente du payload restaure l'etat
|
||||||
|
d'origine a partir du snapshot Doctrine :
|
||||||
|
|
||||||
|
```php
|
||||||
|
// Mapping cle JSON → accesseurs PHP (note : 'roles' → getRbacRoles)
|
||||||
|
private const COLLECTION_MAP = [
|
||||||
|
'roles' => ['getter' => 'getRbacRoles', ...],
|
||||||
|
'directPermissions' => ['getter' => 'getDirectPermissions', ...],
|
||||||
|
'sites' => ['getter' => 'getSites', ...],
|
||||||
|
];
|
||||||
|
|
||||||
|
private function restoreAbsentCollections(User $user): void
|
||||||
|
{
|
||||||
|
$payload = json_decode($this->requestStack->getCurrentRequest()?->getContent() ?? '', true);
|
||||||
|
foreach (self::COLLECTION_MAP as $jsonKey => $accessors) {
|
||||||
|
if (array_key_exists($jsonKey, $payload)) {
|
||||||
|
continue; // cle presente = la denormalisation fait foi
|
||||||
|
}
|
||||||
|
// cle absente = restaurer le snapshot PersistentCollection
|
||||||
|
// (voir implementation complete dans UserRbacProcessor.php)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Semantique finale garantie :
|
||||||
|
|
||||||
|
| Payload | Effet sur la collection |
|
||||||
|
|---------------------------------|------------------------------------|
|
||||||
|
| Cle absente | Preservee (etat BDD inchange) |
|
||||||
|
| `"sites": []` | Collection videe explicitement |
|
||||||
|
| `"sites": ["/api/sites/1"]` | Collection remplacee |
|
||||||
|
|
||||||
|
La garde `ensureCurrentSiteConsistency` continue de s'executer apres persist
|
||||||
|
avec la meme logique : elle est triggered uniquement si la collection a
|
||||||
|
effectivement mute (detection via `PersistentCollection::isDirty()` post-restore).
|
||||||
|
|
||||||
|
### 10.3 — Criteres de validation additionnels
|
||||||
|
|
||||||
|
- [ ] `GET /users/{id}/rbac` retourne 200 pour `core.users.manage`, 403 sinon.
|
||||||
|
- [ ] Le payload contient `{ id, isAdmin, roles, directPermissions, sites }`.
|
||||||
|
- [ ] `GET /api/users` ne contient plus `sites` (verification non-regression).
|
||||||
|
- [ ] Ouvrir le drawer d'un user avec des sites en BDD affiche les cases
|
||||||
|
pre-cochees correspondantes.
|
||||||
|
- [ ] `PATCH /users/{id}/rbac` avec `{ "isAdmin": true }` (sans autre cle) ne
|
||||||
|
modifie pas sites/roles/directPermissions.
|
||||||
|
- [ ] `PATCH /users/{id}/rbac` avec `{ "sites": [] }` vide explicitement la
|
||||||
|
collection et bascule `currentSite` a `NULL` via la garde existante.
|
||||||
|
- [ ] `PATCH /users/{id}/rbac` avec `{ "sites": [...] }` remplace la
|
||||||
|
collection comme auparavant.
|
||||||
|
|||||||
@@ -13,10 +13,12 @@
|
|||||||
"actions": "Actions"
|
"actions": "Actions"
|
||||||
},
|
},
|
||||||
"sidebar": {
|
"sidebar": {
|
||||||
"general": {
|
"administration": {
|
||||||
"section": "Général",
|
"section": "Administration"
|
||||||
|
},
|
||||||
|
"account": {
|
||||||
|
"section": "Mon compte",
|
||||||
"dashboard": "Tableau de bord",
|
"dashboard": "Tableau de bord",
|
||||||
"admin": "Administration",
|
|
||||||
"logout": "Déconnexion"
|
"logout": "Déconnexion"
|
||||||
},
|
},
|
||||||
"commercial": {
|
"commercial": {
|
||||||
@@ -26,7 +28,8 @@
|
|||||||
"core": {
|
"core": {
|
||||||
"roles": "Gestion des rôles",
|
"roles": "Gestion des rôles",
|
||||||
"users": "Utilisateurs",
|
"users": "Utilisateurs",
|
||||||
"sites": "Sites"
|
"sites": "Sites",
|
||||||
|
"audit_log": "Journal d'audit"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"dashboard": {
|
"dashboard": {
|
||||||
@@ -66,6 +69,37 @@
|
|||||||
"switchSuccess": "Site courant changé"
|
"switchSuccess": "Site courant changé"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"audit": {
|
||||||
|
"action": {
|
||||||
|
"create": "Création",
|
||||||
|
"update": "Modification",
|
||||||
|
"delete": "Suppression"
|
||||||
|
},
|
||||||
|
"entity": {
|
||||||
|
"user": "Utilisateur"
|
||||||
|
},
|
||||||
|
"empty": "Aucune activité enregistrée",
|
||||||
|
"no_results": "Aucun résultat pour ces filtres",
|
||||||
|
"timeline": {
|
||||||
|
"empty": "Aucun historique",
|
||||||
|
"load_more": "Voir plus"
|
||||||
|
},
|
||||||
|
"filters": {
|
||||||
|
"reset": "Réinitialiser",
|
||||||
|
"date_from": "Du",
|
||||||
|
"date_to": "Au",
|
||||||
|
"entity_type": "Type d'entité",
|
||||||
|
"user": "Utilisateur",
|
||||||
|
"action": "Action",
|
||||||
|
"all_actions": "Toutes les actions"
|
||||||
|
},
|
||||||
|
"detail": {
|
||||||
|
"field": "Champ",
|
||||||
|
"old_value": "Ancienne valeur",
|
||||||
|
"new_value": "Nouvelle valeur"
|
||||||
|
},
|
||||||
|
"detail_title": "Détail de l'entrée"
|
||||||
|
},
|
||||||
"success": {
|
"success": {
|
||||||
"auth": {
|
"auth": {
|
||||||
"logout": "Deconnexion reussie"
|
"logout": "Deconnexion reussie"
|
||||||
@@ -132,6 +166,21 @@
|
|||||||
"updated": "Permissions mises à jour avec succès"
|
"updated": "Permissions mises à jour avec succès"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"auditLog": {
|
||||||
|
"title": "Journal d'audit",
|
||||||
|
"table": {
|
||||||
|
"performedAt": "Date",
|
||||||
|
"performedBy": "Utilisateur",
|
||||||
|
"entityType": "Entité",
|
||||||
|
"entityId": "ID",
|
||||||
|
"action": "Action",
|
||||||
|
"summary": "Résumé"
|
||||||
|
},
|
||||||
|
"pagination": {
|
||||||
|
"previous": "Précédent",
|
||||||
|
"next": "Suivant"
|
||||||
|
}
|
||||||
|
},
|
||||||
"sites": {
|
"sites": {
|
||||||
"title": "Gestion des sites",
|
"title": "Gestion des sites",
|
||||||
"newSite": "Nouveau site",
|
"newSite": "Nouveau site",
|
||||||
|
|||||||
@@ -112,7 +112,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { Permission, Role, UserListItem, EffectivePermission } from '~/shared/types/rbac'
|
import type { Permission, Role, UserListItem, UserRbacDetail, EffectivePermission } from '~/shared/types/rbac'
|
||||||
import type { Site } from '~/shared/types/sites'
|
import type { Site } from '~/shared/types/sites'
|
||||||
|
|
||||||
interface PermissionModule {
|
interface PermissionModule {
|
||||||
@@ -206,38 +206,43 @@ const effectivePermissions = computed<EffectivePermission[]>(() => {
|
|||||||
.sort((a, b) => a.code.localeCompare(b.code))
|
.sort((a, b) => a.code.localeCompare(b.code))
|
||||||
})
|
})
|
||||||
|
|
||||||
// Charger roles, permissions et sites en parallele pour minimiser le TTFB
|
// Charger les referentiels (roles, permissions, sites) + le detail RBAC du user
|
||||||
// a l'ouverture du drawer.
|
// en parallele pour minimiser le TTFB a l'ouverture du drawer.
|
||||||
async function loadData() {
|
// Le detail RBAC est la seule source de verite pour l'etat initial du formulaire :
|
||||||
const [rolesData, permsData, sitesData] = await Promise.all([
|
// props.user vient de la liste /api/users qui n'expose pas les sites (groupe leger).
|
||||||
|
async function loadData(userId: number) {
|
||||||
|
const [rolesData, permsData, sitesData, userRbac] = await Promise.all([
|
||||||
api.get<{ member: Role[] }>('/roles', {}, { toast: false }),
|
api.get<{ member: Role[] }>('/roles', {}, { toast: false }),
|
||||||
api.get<{ member: Permission[] }>('/permissions', { orphan: false, itemsPerPage: 999 }, { toast: false }),
|
api.get<{ member: Permission[] }>('/permissions', { orphan: false, itemsPerPage: 999 }, { toast: false }),
|
||||||
api.get<{ member: Site[] }>('/sites', { itemsPerPage: 999 }, { toast: false }),
|
api.get<{ member: Site[] }>('/sites', { itemsPerPage: 999 }, { toast: false }),
|
||||||
|
api.get<UserRbacDetail>(`/users/${userId}/rbac`, {}, { toast: false }),
|
||||||
])
|
])
|
||||||
allRoles.value = rolesData.member
|
allRoles.value = rolesData.member
|
||||||
allPermissions.value = permsData.member
|
allPermissions.value = permsData.member
|
||||||
allSites.value = sitesData.member
|
allSites.value = sitesData.member
|
||||||
|
|
||||||
|
form.value.isAdmin = userRbac.isAdmin
|
||||||
|
selectedRoleIds.value = new Set((userRbac.roles ?? []).map(iriToId))
|
||||||
|
selectedDirectPermissionIds.value = new Set((userRbac.directPermissions ?? []).map(iriToId))
|
||||||
|
selectedSiteIds.value = new Set((userRbac.sites ?? []).map(iriToId))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remplir le formulaire quand le user change
|
function resetForm() {
|
||||||
watch(() => props.user, (user) => {
|
|
||||||
if (user) {
|
|
||||||
form.value.isAdmin = user.isAdmin
|
|
||||||
selectedRoleIds.value = new Set(user.roles.map(iriToId))
|
|
||||||
selectedDirectPermissionIds.value = new Set(user.directPermissions.map(iriToId))
|
|
||||||
selectedSiteIds.value = new Set((user.sites ?? []).map(iriToId))
|
|
||||||
} else {
|
|
||||||
form.value.isAdmin = false
|
form.value.isAdmin = false
|
||||||
selectedRoleIds.value = new Set()
|
selectedRoleIds.value = new Set()
|
||||||
selectedDirectPermissionIds.value = new Set()
|
selectedDirectPermissionIds.value = new Set()
|
||||||
selectedSiteIds.value = new Set()
|
selectedSiteIds.value = new Set()
|
||||||
}
|
}
|
||||||
}, { immediate: true })
|
|
||||||
|
|
||||||
// Charger les donnees quand le drawer s'ouvre
|
// Recharger a l'ouverture OU quand le user change pendant que le drawer est ouvert.
|
||||||
watch(() => props.modelValue, (open) => {
|
// Le watch combine evite un double fetch si les deux changent dans le meme tick.
|
||||||
if (open) loadData()
|
watch([() => props.modelValue, () => props.user?.id], ([open, userId]) => {
|
||||||
})
|
if (open && userId) {
|
||||||
|
loadData(userId)
|
||||||
|
} else if (!open) {
|
||||||
|
resetForm()
|
||||||
|
}
|
||||||
|
}, { immediate: true })
|
||||||
|
|
||||||
function toggleRole(id: number, selected: boolean) {
|
function toggleRole(id: number, selected: boolean) {
|
||||||
const ids = new Set(selectedRoleIds.value)
|
const ids = new Set(selectedRoleIds.value)
|
||||||
|
|||||||
405
frontend/modules/core/pages/admin/audit-log.vue
Normal file
405
frontend/modules/core/pages/admin/audit-log.vue
Normal file
@@ -0,0 +1,405 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<h1 class="text-xl font-bold text-primary-500 sm:text-2xl">
|
||||||
|
{{ t('admin.auditLog.title') }}
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Filtres -->
|
||||||
|
<section class="mt-4 rounded border border-gray-200 bg-white p-4">
|
||||||
|
<!-- Labels uniformes au-dessus : les composants Malio sont utilises sans
|
||||||
|
leur `label` flottant interne pour ne pas mixer deux patterns de label. -->
|
||||||
|
<div class="grid grid-cols-1 items-start gap-3 md:grid-cols-5">
|
||||||
|
<!-- TODO(malio-ui): remplacer par un composant Malio quand la lib
|
||||||
|
exposera un datetime picker. Cf. exception documentee dans
|
||||||
|
CLAUDE.md (section "Composants formulaires"). -->
|
||||||
|
<div>
|
||||||
|
<label class="mb-1 block text-xs font-medium text-gray-600">
|
||||||
|
{{ t('audit.filters.date_from') }}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
v-model="filters.performedAtAfter"
|
||||||
|
type="datetime-local"
|
||||||
|
class="h-[40px] w-full rounded-md border border-m-muted bg-white px-3 text-sm outline-none focus-visible:border-2 focus-visible:border-m-primary"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<!-- TODO(malio-ui): idem ci-dessus. -->
|
||||||
|
<div>
|
||||||
|
<label class="mb-1 block text-xs font-medium text-gray-600">
|
||||||
|
{{ t('audit.filters.date_to') }}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
v-model="filters.performedAtBefore"
|
||||||
|
type="datetime-local"
|
||||||
|
class="h-[40px] w-full rounded-md border border-m-muted bg-white px-3 text-sm outline-none focus-visible:border-2 focus-visible:border-m-primary"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="mb-1 block text-xs font-medium text-gray-600">
|
||||||
|
{{ t('audit.filters.entity_type') }}
|
||||||
|
</label>
|
||||||
|
<div class="[&>div>div]:!mt-0">
|
||||||
|
<MalioSelectCheckbox
|
||||||
|
v-model="selectedEntityTypes"
|
||||||
|
:options="entityTypeOptions"
|
||||||
|
:display-select-all="true"
|
||||||
|
:display-tag="true"
|
||||||
|
min-width="w-full"
|
||||||
|
text-field="text-sm"
|
||||||
|
text-value="text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="mb-1 block text-xs font-medium text-gray-600">
|
||||||
|
{{ t('audit.filters.user') }}
|
||||||
|
</label>
|
||||||
|
<MalioInputText
|
||||||
|
v-model="performedByInput"
|
||||||
|
icon-name="mdi:account-search"
|
||||||
|
input-class="text-sm"
|
||||||
|
group-class="h-10"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<!-- TODO(malio-ui): remplacer par MalioSelect quand la lib
|
||||||
|
supportera de maniere fiable des options a valeur string
|
||||||
|
(cf. note Lesstime CLAUDE.md). Exception documentee dans
|
||||||
|
CLAUDE.md (section "Composants formulaires"). -->
|
||||||
|
<div>
|
||||||
|
<label class="mb-1 block text-xs font-medium text-gray-600">
|
||||||
|
{{ t('audit.filters.action') }}
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
v-model="actionValue"
|
||||||
|
class="h-[40px] w-full rounded-md border border-m-muted bg-white px-3 text-sm outline-none focus-visible:border-2 focus-visible:border-m-primary"
|
||||||
|
>
|
||||||
|
<option value="">{{ t('audit.filters.all_actions') }}</option>
|
||||||
|
<option v-for="opt in actionOptions" :key="opt.value" :value="opt.value">
|
||||||
|
{{ opt.label }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-3 flex justify-end">
|
||||||
|
<MalioButton
|
||||||
|
variant="tertiary"
|
||||||
|
:label="t('audit.filters.reset')"
|
||||||
|
button-class="text-xs"
|
||||||
|
@click="resetFilters"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Tableau -->
|
||||||
|
<MalioDataTable
|
||||||
|
class="mt-4"
|
||||||
|
:columns="columns"
|
||||||
|
:items="rows"
|
||||||
|
:total-items="totalItems"
|
||||||
|
:page="filters.page ?? 1"
|
||||||
|
:per-page="filters.itemsPerPage ?? 10"
|
||||||
|
:per-page-options="[10, 25, 50]"
|
||||||
|
:empty-message="isFiltered ? t('audit.no_results') : t('audit.empty')"
|
||||||
|
@update:page="onPageChange"
|
||||||
|
@update:per-page="onPerPageChange"
|
||||||
|
@row-click="onRowClick"
|
||||||
|
>
|
||||||
|
<template #cell-action="{ item }">
|
||||||
|
<span
|
||||||
|
class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium"
|
||||||
|
:class="actionBadgeClass(item.action as string)"
|
||||||
|
>
|
||||||
|
{{ t(`audit.action.${item.action}`) }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
<template #cell-entityType="{ item }">
|
||||||
|
<span class="font-mono text-xs">{{ item.entityType }}</span>
|
||||||
|
</template>
|
||||||
|
<template #cell-entityId="{ item }">
|
||||||
|
<span class="font-mono text-xs">{{ item.entityId }}</span>
|
||||||
|
</template>
|
||||||
|
<template #cell-summary="{ item }">
|
||||||
|
<span class="text-xs text-gray-600">{{ item.summary }}</span>
|
||||||
|
</template>
|
||||||
|
</MalioDataTable>
|
||||||
|
|
||||||
|
<!-- Drawer detail : diff courant + timeline complete de l'entite -->
|
||||||
|
<MalioDrawer
|
||||||
|
v-model="drawerOpen"
|
||||||
|
:title="drawerTitle"
|
||||||
|
drawer-class="max-w-2xl"
|
||||||
|
>
|
||||||
|
<div v-if="selectedEntry">
|
||||||
|
<AuditLogDetail :entry="selectedEntry" />
|
||||||
|
<div class="mt-4 border-t border-gray-200 pt-3">
|
||||||
|
<h3 class="text-sm font-medium text-gray-700 mb-2">
|
||||||
|
{{ selectedEntry.entityType }} #{{ selectedEntry.entityId }}
|
||||||
|
</h3>
|
||||||
|
<AuditTimeline
|
||||||
|
:entity-type="selectedEntry.entityType"
|
||||||
|
:entity-id="selectedEntry.entityId"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</MalioDrawer>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, nextTick, onMounted, reactive, ref, watch } from 'vue'
|
||||||
|
import type { AuditLogEntry, AuditLogFilters } from '~/shared/types'
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
const { can } = usePermissions()
|
||||||
|
const { fetchLogs, fetchEntityTypes } = useAuditLog()
|
||||||
|
|
||||||
|
// Protection cote UI : le middleware `modules.global.ts` filtre deja les
|
||||||
|
// routes desactivees, mais si quelqu'un atterit ici sans la permission on
|
||||||
|
// renvoie une 403 plutot que de flasher un ecran vide.
|
||||||
|
if (!can('core.audit_log.view')) {
|
||||||
|
throw createError({ statusCode: 403, statusMessage: 'Forbidden' })
|
||||||
|
}
|
||||||
|
|
||||||
|
useHead({ title: t('admin.auditLog.title') })
|
||||||
|
|
||||||
|
// Etat des filtres : local uniquement, JAMAIS persiste dans l'URL (cf. regle
|
||||||
|
// CLAUDE.md "Tableau : pas de persistance URL").
|
||||||
|
const filters = reactive<AuditLogFilters>({
|
||||||
|
performedAtAfter: undefined,
|
||||||
|
performedAtBefore: undefined,
|
||||||
|
entityType: undefined,
|
||||||
|
performedBy: undefined,
|
||||||
|
action: undefined,
|
||||||
|
page: 1,
|
||||||
|
itemsPerPage: 10,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Multi-selection entity_type : bind dedie au MalioSelectCheckbox.
|
||||||
|
// Attention : les composants Malio attendent `{ label, value }` (pas `{ text }`).
|
||||||
|
const selectedEntityTypes = ref<(string | number)[]>([])
|
||||||
|
const entityTypes = ref<string[]>([])
|
||||||
|
const entityTypeOptions = computed(() =>
|
||||||
|
entityTypes.value.map(t => ({ value: t, label: t })),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Bind champ performedBy : MalioInputText attend `string | null`, on ne peut
|
||||||
|
// pas binder directement un `string | undefined` reactive.
|
||||||
|
const performedByInput = ref<string>('')
|
||||||
|
|
||||||
|
// Action : MalioSelect ne gere pas fiablement des options a valeur string (cf.
|
||||||
|
// note Lesstime CLAUDE.md). On utilise un `<select>` natif stylise comme les
|
||||||
|
// inputs dates pour garder un look coherent. '' = "toutes les actions".
|
||||||
|
const actionValue = ref<string>('')
|
||||||
|
const actionOptions = [
|
||||||
|
{ value: 'create', label: t('audit.action.create') },
|
||||||
|
{ value: 'update', label: t('audit.action.update') },
|
||||||
|
{ value: 'delete', label: t('audit.action.delete') },
|
||||||
|
]
|
||||||
|
|
||||||
|
const entries = ref<AuditLogEntry[]>([])
|
||||||
|
const totalItems = ref(0)
|
||||||
|
const loading = ref(false)
|
||||||
|
|
||||||
|
const drawerOpen = ref(false)
|
||||||
|
const selectedEntry = ref<AuditLogEntry | null>(null)
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{ key: 'performedAt', label: t('admin.auditLog.table.performedAt') },
|
||||||
|
{ key: 'performedBy', label: t('admin.auditLog.table.performedBy') },
|
||||||
|
{ key: 'entityType', label: t('admin.auditLog.table.entityType') },
|
||||||
|
{ key: 'entityId', label: t('admin.auditLog.table.entityId') },
|
||||||
|
{ key: 'action', label: t('admin.auditLog.table.action') },
|
||||||
|
{ key: 'summary', label: t('admin.auditLog.table.summary') },
|
||||||
|
]
|
||||||
|
|
||||||
|
// Transforme chaque AuditLogEntry en ligne compatible MalioDataTable.
|
||||||
|
// On conserve `id` pour retrouver l'entry complete sur row-click.
|
||||||
|
const rows = computed(() =>
|
||||||
|
entries.value.map(entry => ({
|
||||||
|
id: entry.id,
|
||||||
|
performedAt: formatDate(entry.performedAt),
|
||||||
|
performedBy: entry.performedBy,
|
||||||
|
entityType: entry.entityType,
|
||||||
|
entityId: entry.entityId,
|
||||||
|
action: entry.action,
|
||||||
|
summary: summarize(entry),
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
|
||||||
|
const drawerTitle = computed(() =>
|
||||||
|
selectedEntry.value
|
||||||
|
? `${selectedEntry.value.entityType} #${selectedEntry.value.entityId}`
|
||||||
|
: t('audit.detail_title'),
|
||||||
|
)
|
||||||
|
|
||||||
|
const isFiltered = computed(() =>
|
||||||
|
Boolean(filters.performedAtAfter || filters.performedAtBefore
|
||||||
|
|| (Array.isArray(filters.entityType) ? filters.entityType.length : filters.entityType)
|
||||||
|
|| filters.performedBy || filters.action),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Anti-race : chaque fetch incremente un compteur ; seul le dernier en date
|
||||||
|
// ecrit les resultats dans `entries`/`totalItems`. Evite qu'une reponse tardive
|
||||||
|
// (reseau lent) n'ecrase les resultats d'une requete ulterieure.
|
||||||
|
let requestToken = 0
|
||||||
|
|
||||||
|
// Pendant un reset, on suspend temporairement les watchers pour ne pas
|
||||||
|
// declencher 4 fetchs paralleles (un par champ mute). Les watchers Vue 3
|
||||||
|
// sont asynchrones (microtask) : il faut attendre un `nextTick` avant de
|
||||||
|
// les relacher, sinon le flag est deja `false` au moment ou ils s'executent
|
||||||
|
// et les fetchs partent quand meme. Un seul loadEntries() est appele
|
||||||
|
// explicitement apres la liberation.
|
||||||
|
let watchersSuspended = false
|
||||||
|
|
||||||
|
async function resetFilters(): Promise<void> {
|
||||||
|
watchersSuspended = true
|
||||||
|
filters.performedAtAfter = undefined
|
||||||
|
filters.performedAtBefore = undefined
|
||||||
|
filters.entityType = undefined
|
||||||
|
filters.performedBy = undefined
|
||||||
|
filters.action = undefined
|
||||||
|
filters.page = 1
|
||||||
|
selectedEntityTypes.value = []
|
||||||
|
performedByInput.value = ''
|
||||||
|
actionValue.value = ''
|
||||||
|
// Les watchers mute de Vue 3 se planifient en microtask : on attend
|
||||||
|
// leur execution avec le flag `true`, puis on libere.
|
||||||
|
await nextTick()
|
||||||
|
watchersSuspended = false
|
||||||
|
loadEntries()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadEntries(): Promise<void> {
|
||||||
|
const token = ++requestToken
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const data = await fetchLogs({
|
||||||
|
...filters,
|
||||||
|
// Convertit datetime-local (YYYY-MM-DDTHH:MM) en ISO pour l'API.
|
||||||
|
performedAtAfter: filters.performedAtAfter ? toIso(filters.performedAtAfter) : undefined,
|
||||||
|
performedAtBefore: filters.performedAtBefore ? toIso(filters.performedAtBefore) : undefined,
|
||||||
|
})
|
||||||
|
// Reponse obsolete (un fetch plus recent a ete lance entre-temps) :
|
||||||
|
// on ignore le resultat pour ne pas overwrite l'etat courant.
|
||||||
|
if (token !== requestToken) return
|
||||||
|
entries.value = data.member ?? []
|
||||||
|
totalItems.value = data.totalItems ?? 0
|
||||||
|
} finally {
|
||||||
|
if (token === requestToken) {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Debounce utilitaire pour le champ texte performedBy : evite un refetch a
|
||||||
|
// chaque frappe (reseau + SQL) et laisse l'utilisateur finir sa saisie.
|
||||||
|
function debounce<T extends (...args: never[]) => void>(fn: T, delay: number): T {
|
||||||
|
let timer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
return ((...args: Parameters<T>) => {
|
||||||
|
if (null !== timer) clearTimeout(timer)
|
||||||
|
timer = setTimeout(() => fn(...args), delay)
|
||||||
|
}) as T
|
||||||
|
}
|
||||||
|
|
||||||
|
const debouncedReload = debounce(() => loadEntries(), 300)
|
||||||
|
|
||||||
|
function toIso(localDateTime: string): string {
|
||||||
|
// datetime-local n'a pas de timezone : on assume heure locale et on
|
||||||
|
// laisse le navigateur generer l'ISO via Date().
|
||||||
|
return new Date(localDateTime).toISOString()
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(iso: string): string {
|
||||||
|
return new Date(iso).toLocaleString('fr-FR', {
|
||||||
|
dateStyle: 'short',
|
||||||
|
timeStyle: 'short',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function actionBadgeClass(action: string): string {
|
||||||
|
switch (action) {
|
||||||
|
case 'create': return 'bg-green-100 text-green-800'
|
||||||
|
case 'update': return 'bg-yellow-100 text-yellow-800'
|
||||||
|
case 'delete': return 'bg-red-100 text-red-800'
|
||||||
|
default: return 'bg-gray-100 text-gray-800'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function summarize(entry: AuditLogEntry): string {
|
||||||
|
const keys = Object.keys(entry.changes)
|
||||||
|
if (keys.length === 0) return '—'
|
||||||
|
if (keys.length <= 3) return keys.join(', ')
|
||||||
|
return `${keys.slice(0, 3).join(', ')}… (+${keys.length - 3})`
|
||||||
|
}
|
||||||
|
|
||||||
|
function onRowClick(item: Record<string, unknown>): void {
|
||||||
|
const entry = entries.value.find(e => e.id === item.id)
|
||||||
|
if (entry) {
|
||||||
|
selectedEntry.value = entry
|
||||||
|
drawerOpen.value = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPageChange(value: number): void {
|
||||||
|
filters.page = value
|
||||||
|
loadEntries()
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPerPageChange(value: number): void {
|
||||||
|
filters.itemsPerPage = value
|
||||||
|
filters.page = 1
|
||||||
|
loadEntries()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sync MalioSelectCheckbox -> filters.entityType + reset page 1 + reload.
|
||||||
|
watch(selectedEntityTypes, values => {
|
||||||
|
if (watchersSuspended) return
|
||||||
|
filters.entityType = values.length > 0 ? values.map(v => String(v)) : undefined
|
||||||
|
filters.page = 1
|
||||||
|
loadEntries()
|
||||||
|
})
|
||||||
|
|
||||||
|
// Sync select action natif -> filters.action.
|
||||||
|
watch(actionValue, value => {
|
||||||
|
if (watchersSuspended) return
|
||||||
|
filters.action = value === '' ? undefined : value
|
||||||
|
filters.page = 1
|
||||||
|
loadEntries()
|
||||||
|
})
|
||||||
|
|
||||||
|
// Sync performedBy : frappe utilisateur -> debounce 300ms pour eviter un
|
||||||
|
// refetch par caractere. Le reset passe par debouncedReload egalement pour
|
||||||
|
// coalescer si plusieurs watchers tirent en meme temps.
|
||||||
|
watch(performedByInput, value => {
|
||||||
|
if (watchersSuspended) return
|
||||||
|
filters.performedBy = value === '' ? undefined : value
|
||||||
|
filters.page = 1
|
||||||
|
debouncedReload()
|
||||||
|
})
|
||||||
|
|
||||||
|
// Synchronisation reactive : tout changement de dates declenche un fetch +
|
||||||
|
// reset de la pagination a la page 1.
|
||||||
|
watch(
|
||||||
|
() => [filters.performedAtAfter, filters.performedAtBefore],
|
||||||
|
() => {
|
||||||
|
if (watchersSuspended) return
|
||||||
|
filters.page = 1
|
||||||
|
loadEntries()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
// Charge les entity types en parallele de la liste principale : un
|
||||||
|
// echec du premier endpoint (ex: reseau flaky) ne doit pas empecher
|
||||||
|
// le tableau d'audit de s'afficher. En cas d'erreur, on laisse le
|
||||||
|
// filtre vide — l'utilisateur pourra quand meme consulter le journal.
|
||||||
|
try {
|
||||||
|
entityTypes.value = await fetchEntityTypes()
|
||||||
|
} catch {
|
||||||
|
entityTypes.value = []
|
||||||
|
}
|
||||||
|
await loadEntries()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@@ -38,7 +38,6 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { UserListItem } from '~/shared/types/rbac'
|
import type { UserListItem } from '~/shared/types/rbac'
|
||||||
import type { Site } from '~/shared/types/sites'
|
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const api = useApi()
|
const api = useApi()
|
||||||
@@ -49,24 +48,21 @@ useHead({ title: t('admin.users.title') })
|
|||||||
const canManage = computed(() => can('core.users.manage'))
|
const canManage = computed(() => can('core.users.manage'))
|
||||||
|
|
||||||
const users = ref<UserListItem[]>([])
|
const users = ref<UserListItem[]>([])
|
||||||
const sitesById = ref(new Map<number, Site>())
|
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const drawerOpen = ref(false)
|
const drawerOpen = ref(false)
|
||||||
const selectedUser = ref<UserListItem | null>(null)
|
const selectedUser = ref<UserListItem | null>(null)
|
||||||
|
|
||||||
|
// La colonne "Sites" n'est plus affichee dans la liste : le detail des sites
|
||||||
|
// rattaches est consulte/edite via le drawer (GET /users/{id}/rbac). Garder
|
||||||
|
// un payload leger sur /api/users facilite la pagination et evite de fuiter
|
||||||
|
// l'info cross-site aux users partageant juste un site avec l'appelant.
|
||||||
const columns = [
|
const columns = [
|
||||||
{ key: 'username', label: t('admin.users.table.username') },
|
{ key: 'username', label: t('admin.users.table.username') },
|
||||||
{ key: 'admin', label: t('admin.users.table.admin') },
|
{ key: 'admin', label: t('admin.users.table.admin') },
|
||||||
{ key: 'roles', label: t('admin.users.table.roles') },
|
{ key: 'roles', label: t('admin.users.table.roles') },
|
||||||
{ key: 'directPermissions', label: t('admin.users.table.directPermissions') },
|
{ key: 'directPermissions', label: t('admin.users.table.directPermissions') },
|
||||||
{ key: 'sites', label: t('admin.users.table.sites') },
|
|
||||||
]
|
]
|
||||||
|
|
||||||
// Extraire l'id numerique depuis une IRI API Platform type `/api/sites/3`.
|
|
||||||
function iriToId(iri: string): number {
|
|
||||||
return Number(iri.split('/').pop())
|
|
||||||
}
|
|
||||||
|
|
||||||
const userItems = computed(() =>
|
const userItems = computed(() =>
|
||||||
users.value.map(user => ({
|
users.value.map(user => ({
|
||||||
id: user.id,
|
id: user.id,
|
||||||
@@ -74,27 +70,14 @@ const userItems = computed(() =>
|
|||||||
admin: user.isAdmin,
|
admin: user.isAdmin,
|
||||||
roles: user.roles.length,
|
roles: user.roles.length,
|
||||||
directPermissions: user.directPermissions.length,
|
directPermissions: user.directPermissions.length,
|
||||||
// Affichage : liste des noms de sites separes par virgule. Les IRIs
|
|
||||||
// du payload /api/users (groupe user:list) sont resolues via la Map
|
|
||||||
// construite en parallele depuis /api/sites.
|
|
||||||
sites: (user.sites ?? [])
|
|
||||||
.map(iri => sitesById.value.get(iriToId(iri))?.name)
|
|
||||||
.filter((name): name is string => Boolean(name))
|
|
||||||
.join(', '),
|
|
||||||
})),
|
})),
|
||||||
)
|
)
|
||||||
|
|
||||||
async function loadUsers() {
|
async function loadUsers() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
// Chargement parallele : les sites alimentent la Map de resolution
|
const usersData = await api.get<{ member: UserListItem[] }>('/users', {}, { toast: false })
|
||||||
// IRI→name pour la colonne "Sites" de la table.
|
|
||||||
const [usersData, sitesData] = await Promise.all([
|
|
||||||
api.get<{ member: UserListItem[] }>('/users', {}, { toast: false }),
|
|
||||||
api.get<{ member: Site[] }>('/sites', { itemsPerPage: 999 }, { toast: false }),
|
|
||||||
])
|
|
||||||
users.value = usersData.member
|
users.value = usersData.member
|
||||||
sitesById.value = new Map(sitesData.member.map(s => [s.id, s]))
|
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ const auth = useAuthStore()
|
|||||||
const { resetSidebar } = useSidebar()
|
const { resetSidebar } = useSidebar()
|
||||||
const { resetModules } = useModules()
|
const { resetModules } = useModules()
|
||||||
const { resetCurrentSite } = useCurrentSite()
|
const { resetCurrentSite } = useCurrentSite()
|
||||||
|
const { resetAuditLog } = useAuditLog()
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -18,13 +19,14 @@ onMounted(async () => {
|
|||||||
} finally {
|
} finally {
|
||||||
// Les resets sont garantis meme si auth.logout() rejette : eviter
|
// Les resets sont garantis meme si auth.logout() rejette : eviter
|
||||||
// qu'un user suivant (connecte sur le meme onglet) voie l'etat de
|
// qu'un user suivant (connecte sur le meme onglet) voie l'etat de
|
||||||
// l'ancien. Les trois fonctions reset sont synchrones et ne
|
// l'ancien. Toutes les fonctions reset sont synchrones et ne
|
||||||
// peuvent pas throw (juste des assignations reactives).
|
// peuvent pas throw (juste des assignations reactives).
|
||||||
// navigateTo est dans le finally pour garantir la redirection
|
// navigateTo est dans le finally pour garantir la redirection
|
||||||
// meme si auth.logout() lance une exception (ex: reseau coupé).
|
// meme si auth.logout() lance une exception (ex: reseau coupé).
|
||||||
resetSidebar()
|
resetSidebar()
|
||||||
resetModules()
|
resetModules()
|
||||||
resetCurrentSite()
|
resetCurrentSite()
|
||||||
|
resetAuditLog()
|
||||||
await navigateTo('/login')
|
await navigateTo('/login')
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
98
frontend/shared/components/audit/AuditLogDetail.vue
Normal file
98
frontend/shared/components/audit/AuditLogDetail.vue
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
<template>
|
||||||
|
<!--
|
||||||
|
Vue de detail d'une ligne d'audit : tableau field/old/new pour une
|
||||||
|
update, sinon snapshot complet sous forme de liste { cle: valeur }.
|
||||||
|
-->
|
||||||
|
<div class="text-sm">
|
||||||
|
<p class="text-xs text-gray-500 mb-2">
|
||||||
|
<span v-if="entry.ipAddress">IP: {{ entry.ipAddress }}</span>
|
||||||
|
<span v-if="entry.requestId" class="ml-3">Req: {{ entry.requestId }}</span>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div v-if="entry.action === 'update'">
|
||||||
|
<!-- Tableau de comparaison field/old/new. MalioDataTable n'est
|
||||||
|
pas adapte ici : cas presentationnel non-paginable (cf.
|
||||||
|
exception documentee dans CLAUDE.md). -->
|
||||||
|
<table class="min-w-full border border-gray-200 text-xs">
|
||||||
|
<thead class="bg-gray-100">
|
||||||
|
<tr>
|
||||||
|
<th class="px-2 py-1 text-left font-medium">{{ t('audit.detail.field') }}</th>
|
||||||
|
<th class="px-2 py-1 text-left font-medium">{{ t('audit.detail.old_value') }}</th>
|
||||||
|
<th class="px-2 py-1 text-left font-medium">{{ t('audit.detail.new_value') }}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="(diff, field) in updateDiff" :key="field" class="border-t border-gray-200">
|
||||||
|
<td class="px-2 py-1 font-mono">{{ field }}</td>
|
||||||
|
<td class="px-2 py-1 text-red-700">{{ formatValue(diff.old) }}</td>
|
||||||
|
<td class="px-2 py-1 text-green-700">{{ formatValue(diff.new) }}</td>
|
||||||
|
</tr>
|
||||||
|
<!-- Modifications de collections to-many : shape different
|
||||||
|
{ added: [ids], removed: [ids] } → affiche + et - sur
|
||||||
|
la meme ligne pour garder une colonne field unique. -->
|
||||||
|
<tr v-for="(diff, field) in collectionDiff" :key="`col-${field}`" class="border-t border-gray-200">
|
||||||
|
<td class="px-2 py-1 font-mono">{{ field }}</td>
|
||||||
|
<td class="px-2 py-1 text-red-700">
|
||||||
|
<span v-if="diff.removed.length">− {{ diff.removed.join(', ') }}</span>
|
||||||
|
<span v-else class="text-gray-400">∅</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-2 py-1 text-green-700">
|
||||||
|
<span v-if="diff.added.length">+ {{ diff.added.join(', ') }}</span>
|
||||||
|
<span v-else class="text-gray-400">∅</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="space-y-1">
|
||||||
|
<div v-for="(value, key) in entry.changes" :key="key" class="flex gap-2">
|
||||||
|
<span class="font-mono text-xs text-gray-600">{{ key }}:</span>
|
||||||
|
<span class="text-xs">{{ formatValue(value) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import type { AuditLogEntry } from '~/shared/types'
|
||||||
|
|
||||||
|
const props = defineProps<{ entry: AuditLogEntry }>()
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
// Extrait les entrees au shape { old, new } pour les updates scalaires.
|
||||||
|
const updateDiff = computed<Record<string, { old: unknown; new: unknown }>>(() => {
|
||||||
|
const out: Record<string, { old: unknown; new: unknown }> = {}
|
||||||
|
for (const [key, value] of Object.entries(props.entry.changes)) {
|
||||||
|
if (value && typeof value === 'object' && 'old' in value && 'new' in value) {
|
||||||
|
out[key] = value as { old: unknown; new: unknown }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
})
|
||||||
|
|
||||||
|
// Extrait les entrees au shape { added, removed } pour les modifications
|
||||||
|
// de collections to-many (cf. AuditListener::captureCollectionChange).
|
||||||
|
const collectionDiff = computed<Record<string, { added: unknown[]; removed: unknown[] }>>(() => {
|
||||||
|
const out: Record<string, { added: unknown[]; removed: unknown[] }> = {}
|
||||||
|
for (const [key, value] of Object.entries(props.entry.changes)) {
|
||||||
|
if (value && typeof value === 'object' && 'added' in value && 'removed' in value) {
|
||||||
|
const diff = value as { added: unknown; removed: unknown }
|
||||||
|
out[key] = {
|
||||||
|
added: Array.isArray(diff.added) ? diff.added : [],
|
||||||
|
removed: Array.isArray(diff.removed) ? diff.removed : [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
})
|
||||||
|
|
||||||
|
function formatValue(value: unknown): string {
|
||||||
|
if (value === null || value === undefined) return '∅'
|
||||||
|
if (typeof value === 'boolean') return value ? 'oui' : 'non'
|
||||||
|
if (typeof value === 'object') return JSON.stringify(value)
|
||||||
|
return String(value)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
242
frontend/shared/components/audit/AuditTimeline.vue
Normal file
242
frontend/shared/components/audit/AuditTimeline.vue
Normal file
@@ -0,0 +1,242 @@
|
|||||||
|
<template>
|
||||||
|
<!--
|
||||||
|
Garde permission : aucun rendu DOM ni appel API si l'utilisateur n'a
|
||||||
|
pas le droit. On wrappe le contenu dans un bloc v-if plutot qu'un div
|
||||||
|
vide pour eviter de polluer la layout quand le composant est embarque
|
||||||
|
dans une page qui rend deja sa propre structure.
|
||||||
|
-->
|
||||||
|
<div v-if="canView" class="audit-timeline">
|
||||||
|
<!-- Skeleton loader initial -->
|
||||||
|
<ul v-if="loading && entries.length === 0" class="space-y-3">
|
||||||
|
<li v-for="i in 3" :key="i" class="flex gap-3">
|
||||||
|
<div class="h-3 w-3 rounded-full bg-gray-200 animate-pulse mt-1.5" />
|
||||||
|
<div class="flex-1 space-y-2">
|
||||||
|
<div class="h-3 w-1/3 rounded bg-gray-200 animate-pulse" />
|
||||||
|
<div class="h-2 w-2/3 rounded bg-gray-100 animate-pulse" />
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<p
|
||||||
|
v-else-if="!loading && entries.length === 0"
|
||||||
|
class="text-sm text-gray-500 italic"
|
||||||
|
>
|
||||||
|
{{ t('audit.timeline.empty') }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<ul v-else class="relative border-l-2 border-gray-200 pl-6 space-y-5">
|
||||||
|
<li
|
||||||
|
v-for="entry in entries"
|
||||||
|
:key="entry.id"
|
||||||
|
class="relative"
|
||||||
|
>
|
||||||
|
<!-- Dot sur la barre verticale. Couleur selon action. -->
|
||||||
|
<span
|
||||||
|
class="absolute -left-[31px] top-1 h-3 w-3 rounded-full ring-2 ring-white"
|
||||||
|
:class="dotClass(entry.action)"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="flex items-start justify-between gap-4">
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<p class="text-sm">
|
||||||
|
<span class="font-medium">{{ entry.performedBy }}</span>
|
||||||
|
<span class="text-gray-500"> — {{ t(`audit.action.${entry.action}`) }}</span>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- Update : diff field-by-field. Create/Delete : liste des champs. -->
|
||||||
|
<div v-if="entry.action === 'update'" class="mt-1 text-xs text-gray-600 space-y-0.5">
|
||||||
|
<div v-for="(diff, field) in updateDiff(entry)" :key="field">
|
||||||
|
<span class="font-medium">{{ field }}</span> :
|
||||||
|
<span class="line-through text-red-600">{{ formatValue(diff.old) }}</span>
|
||||||
|
<span class="mx-1">→</span>
|
||||||
|
<span class="text-green-700">{{ formatValue(diff.new) }}</span>
|
||||||
|
</div>
|
||||||
|
<!-- Modifications de collections to-many. -->
|
||||||
|
<div v-for="(diff, field) in collectionDiff(entry)" :key="`col-${field}`">
|
||||||
|
<span class="font-medium">{{ field }}</span> :
|
||||||
|
<span v-if="diff.removed.length" class="text-red-600">−{{ diff.removed.join(', ') }}</span>
|
||||||
|
<span v-if="diff.removed.length && diff.added.length" class="mx-1"> </span>
|
||||||
|
<span v-if="diff.added.length" class="text-green-700">+{{ diff.added.join(', ') }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else class="mt-1 text-xs text-gray-600">
|
||||||
|
{{ snapshotSummary(entry) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Date relative FR + tooltip absolu -->
|
||||||
|
<time
|
||||||
|
:title="absoluteDate(entry.performedAt)"
|
||||||
|
class="shrink-0 text-xs text-gray-500"
|
||||||
|
>
|
||||||
|
{{ relativeDate(entry.performedAt) }}
|
||||||
|
</time>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<!-- Lazy loading : bouton "Voir plus" si plus de pages. -->
|
||||||
|
<div v-if="hasMore" class="mt-4 flex justify-center">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="px-3 py-1.5 text-sm rounded border border-gray-300 hover:bg-gray-50 disabled:opacity-60"
|
||||||
|
:disabled="loading"
|
||||||
|
@click="loadMore"
|
||||||
|
>
|
||||||
|
{{ loading ? t('common.loading') : t('audit.timeline.load_more') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, ref, toRefs, watch } from 'vue'
|
||||||
|
import type { AuditLogEntry } from '~/shared/types'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
entityType: string
|
||||||
|
entityId: string | number
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const { entityType, entityId } = toRefs(props)
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
const { can } = usePermissions()
|
||||||
|
const { fetchEntityLogs } = useAuditLog()
|
||||||
|
|
||||||
|
const canView = computed(() => can('core.audit_log.view'))
|
||||||
|
|
||||||
|
const entries = ref<AuditLogEntry[]>([])
|
||||||
|
const page = ref(1)
|
||||||
|
const totalItems = ref(0)
|
||||||
|
const loading = ref(false)
|
||||||
|
|
||||||
|
// Lazy loading : 10 items par page cote UX. On aligne la pagination backend
|
||||||
|
// (itemsPerPage=10 dans fetchEntityLogs) avec cette taille pour eviter de
|
||||||
|
// slicer cote client — sinon les items 11-30 de chaque page etaient ignores.
|
||||||
|
const PAGE_SIZE = 10
|
||||||
|
|
||||||
|
// Anti-race : un utilisateur qui change rapidement d'entite affichee (ouvre
|
||||||
|
// une ligne puis une autre dans le tableau admin) peut declencher deux fetchs
|
||||||
|
// dont le premier repond en retard et ecrase l'etat de la seconde timeline.
|
||||||
|
// On incremente un token a chaque fetch ; seule la derniere requete ecrit le
|
||||||
|
// resultat. loadMore() est aussi protege : une reponse tardive append sur
|
||||||
|
// une timeline dont l'entite a deja change serait visuellement confuse.
|
||||||
|
let requestToken = 0
|
||||||
|
|
||||||
|
const hasMore = computed(() => entries.value.length < totalItems.value)
|
||||||
|
|
||||||
|
async function loadPage(targetPage: number, append: boolean): Promise<void> {
|
||||||
|
if (!canView.value) return
|
||||||
|
|
||||||
|
const token = ++requestToken
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const data = await fetchEntityLogs(entityType.value, entityId.value, targetPage, PAGE_SIZE)
|
||||||
|
if (token !== requestToken) return
|
||||||
|
const items = data.member ?? []
|
||||||
|
entries.value = append ? [...entries.value, ...items] : items
|
||||||
|
totalItems.value = data.totalItems ?? entries.value.length
|
||||||
|
page.value = targetPage
|
||||||
|
} catch {
|
||||||
|
if (token !== requestToken) return
|
||||||
|
// Erreur silencieuse (timeline secondaire) — useApi n'affiche pas de toast avec toast: false.
|
||||||
|
entries.value = append ? entries.value : []
|
||||||
|
} finally {
|
||||||
|
if (token === requestToken) {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadMore(): Promise<void> {
|
||||||
|
await loadPage(page.value + 1, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
function dotClass(action: string): string {
|
||||||
|
switch (action) {
|
||||||
|
case 'create': return 'bg-green-500'
|
||||||
|
case 'update': return 'bg-yellow-500'
|
||||||
|
case 'delete': return 'bg-red-500'
|
||||||
|
default: return 'bg-gray-400'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Relativise une date en francais via Intl.RelativeTimeFormat. On selectionne
|
||||||
|
// l'unite la plus grossiere possible (minutes < heures < jours < semaines).
|
||||||
|
const rtf = new Intl.RelativeTimeFormat('fr', { numeric: 'auto' })
|
||||||
|
|
||||||
|
function relativeDate(iso: string): string {
|
||||||
|
const diffMs = Date.now() - new Date(iso).getTime()
|
||||||
|
const diffSec = Math.round(diffMs / 1000)
|
||||||
|
const absSec = Math.abs(diffSec)
|
||||||
|
|
||||||
|
if (absSec < 60) return rtf.format(-Math.sign(diffSec) * Math.abs(diffSec), 'second')
|
||||||
|
if (absSec < 3600) return rtf.format(-Math.sign(diffSec) * Math.round(absSec / 60), 'minute')
|
||||||
|
if (absSec < 86400) return rtf.format(-Math.sign(diffSec) * Math.round(absSec / 3600), 'hour')
|
||||||
|
if (absSec < 604800) return rtf.format(-Math.sign(diffSec) * Math.round(absSec / 86400), 'day')
|
||||||
|
return rtf.format(-Math.sign(diffSec) * Math.round(absSec / 604800), 'week')
|
||||||
|
}
|
||||||
|
|
||||||
|
function absoluteDate(iso: string): string {
|
||||||
|
return new Date(iso).toLocaleString('fr-FR', {
|
||||||
|
dateStyle: 'medium',
|
||||||
|
timeStyle: 'short',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateDiff(entry: AuditLogEntry): Record<string, { old: unknown; new: unknown }> {
|
||||||
|
// Format attendu: { champ: { old, new } }. On filtre defensivement les
|
||||||
|
// valeurs qui ne correspondent pas a ce shape (pas d'erreur runtime).
|
||||||
|
const out: Record<string, { old: unknown; new: unknown }> = {}
|
||||||
|
for (const [key, value] of Object.entries(entry.changes)) {
|
||||||
|
if (value && typeof value === 'object' && 'old' in value && 'new' in value) {
|
||||||
|
const diff = value as { old: unknown; new: unknown }
|
||||||
|
out[key] = diff
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectionDiff(entry: AuditLogEntry): Record<string, { added: unknown[]; removed: unknown[] }> {
|
||||||
|
// Format to-many : { champ: { added: [ids], removed: [ids] } } produit
|
||||||
|
// par AuditListener::captureCollectionChange.
|
||||||
|
const out: Record<string, { added: unknown[]; removed: unknown[] }> = {}
|
||||||
|
for (const [key, value] of Object.entries(entry.changes)) {
|
||||||
|
if (value && typeof value === 'object' && 'added' in value && 'removed' in value) {
|
||||||
|
const diff = value as { added: unknown; removed: unknown }
|
||||||
|
out[key] = {
|
||||||
|
added: Array.isArray(diff.added) ? diff.added : [],
|
||||||
|
removed: Array.isArray(diff.removed) ? diff.removed : [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
function snapshotSummary(entry: AuditLogEntry): string {
|
||||||
|
const keys = Object.keys(entry.changes)
|
||||||
|
if (keys.length === 0) return '—'
|
||||||
|
if (keys.length <= 4) return keys.join(', ')
|
||||||
|
return `${keys.slice(0, 4).join(', ')}…`
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatValue(value: unknown): string {
|
||||||
|
if (value === null || value === undefined) return '∅'
|
||||||
|
if (typeof value === 'boolean') return value ? 'oui' : 'non'
|
||||||
|
if (typeof value === 'object') return JSON.stringify(value)
|
||||||
|
return String(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reload si l'entite affichee change.
|
||||||
|
watch([entityType, entityId], () => {
|
||||||
|
entries.value = []
|
||||||
|
page.value = 1
|
||||||
|
totalItems.value = 0
|
||||||
|
loadPage(1, false)
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadPage(1, false)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
139
frontend/shared/composables/useAuditLog.ts
Normal file
139
frontend/shared/composables/useAuditLog.ts
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
import { ref } from 'vue'
|
||||||
|
import type { AuditLogEntityTypes, AuditLogEntry, AuditLogFilters } from '~/shared/types'
|
||||||
|
import type { HydraCollection } from '~/shared/utils/api'
|
||||||
|
import { onAuthSessionCleared } from '~/shared/stores/auth'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cache module-level : evite un double-fetch si la page et le composant
|
||||||
|
* Timeline demandent la meme page simultanement. Volontairement minimaliste :
|
||||||
|
* on ne cache que le dernier resultat, pas un LRU par filtre — un CRM interne
|
||||||
|
* n'en a pas besoin et le cache complexe complique le reset.
|
||||||
|
*
|
||||||
|
* Un logout / 401 doit purger ce cache : on s'enregistre au callback
|
||||||
|
* `onAuthSessionCleared` expose par auth.ts.
|
||||||
|
*/
|
||||||
|
const lastCollection = ref<HydraCollection<AuditLogEntry> | null>(null)
|
||||||
|
|
||||||
|
function resetAuditLog(): void {
|
||||||
|
lastCollection.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-enregistrement singleton : si la session est invalidee (401,
|
||||||
|
// logout) le cache est purge automatiquement, evitant qu'un autre user
|
||||||
|
// connecte ensuite ne voit des donnees residuelles.
|
||||||
|
onAuthSessionCleared(resetAuditLog)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Traduit le modele front (camelCase) en query params API Platform
|
||||||
|
* (snake_case, avec la syntaxe performed_at[after] / [before]).
|
||||||
|
*
|
||||||
|
* @returns objet plat directement consommable par `useApi().get(url, query)`.
|
||||||
|
*/
|
||||||
|
function buildQuery(filters: AuditLogFilters | undefined): Record<string, string | number | string[]> {
|
||||||
|
const query: Record<string, string | number | string[]> = {}
|
||||||
|
if (!filters) return query
|
||||||
|
|
||||||
|
// `entity_type` : chaine simple ou liste pour un filtre multi-selection.
|
||||||
|
// Cote PHP, la syntaxe `entity_type[]=X&entity_type[]=Y` est requise pour
|
||||||
|
// que $_GET['entity_type'] soit un tableau (sinon "last wins").
|
||||||
|
if (Array.isArray(filters.entityType)) {
|
||||||
|
if (filters.entityType.length > 0) query['entity_type[]'] = filters.entityType
|
||||||
|
} else if (filters.entityType) {
|
||||||
|
query.entity_type = filters.entityType
|
||||||
|
}
|
||||||
|
if (filters.entityId) query.entity_id = filters.entityId
|
||||||
|
if (filters.action) query.action = filters.action
|
||||||
|
if (filters.performedBy) query.performed_by = filters.performedBy
|
||||||
|
if (filters.performedAtAfter) query['performed_at[after]'] = filters.performedAtAfter
|
||||||
|
if (filters.performedAtBefore) query['performed_at[before]'] = filters.performedAtBefore
|
||||||
|
if (filters.page) query.page = filters.page
|
||||||
|
if (filters.itemsPerPage) query.itemsPerPage = filters.itemsPerPage
|
||||||
|
|
||||||
|
return query
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Composable partage entre la page globale d'audit (admin) et le composant
|
||||||
|
* Timeline. Expose des methodes de lecture + une fonction `resetAuditLog()`
|
||||||
|
* pour purger le cache (conforme a la regle CLAUDE.md sur les composables
|
||||||
|
* singletons, cf. `useSidebar.resetSidebar`).
|
||||||
|
*/
|
||||||
|
// Accept explicitement JSON-LD : API Platform 4 retourne un tableau PLAT (liste
|
||||||
|
// d'items, sans envelope de pagination) sous `application/json`, et un objet
|
||||||
|
// Hydra complet avec `member`, `totalItems` et `view` (first/last/next/previous)
|
||||||
|
// sous `application/ld+json`. Pour obtenir `view` cote front — indispensable
|
||||||
|
// a la pagination prev/next — on force donc ld+json.
|
||||||
|
const JSONLD_HEADERS = { Accept: 'application/ld+json' } as const
|
||||||
|
|
||||||
|
export function useAuditLog() {
|
||||||
|
const api = useApi()
|
||||||
|
|
||||||
|
async function fetchLogs(filters?: AuditLogFilters): Promise<HydraCollection<AuditLogEntry>> {
|
||||||
|
return api.get<HydraCollection<AuditLogEntry>>(
|
||||||
|
'/audit-logs',
|
||||||
|
buildQuery(filters),
|
||||||
|
{ toast: false, headers: JSONLD_HEADERS },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Variante de `fetchLogs` qui met a jour le cache `lastCollection`.
|
||||||
|
* N'est utilisee que par la page admin — le composant Timeline appelle
|
||||||
|
* `fetchEntityLogs` qui bypass le cache pour ne pas polluer la reference
|
||||||
|
* page-level quand plusieurs timelines sont ouvertes.
|
||||||
|
*/
|
||||||
|
async function fetchLogsCached(filters?: AuditLogFilters): Promise<HydraCollection<AuditLogEntry>> {
|
||||||
|
const data = await fetchLogs(filters)
|
||||||
|
lastCollection.value = data
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchLogById(id: string): Promise<AuditLogEntry> {
|
||||||
|
return api.get<AuditLogEntry>(`/audit-logs/${id}`, {}, { toast: false, headers: JSONLD_HEADERS })
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liste des valeurs distinctes de `entity_type` pour alimenter le filtre
|
||||||
|
* multi-selection. Alimente par un endpoint DBAL, aucune cache cote front
|
||||||
|
* (la liste peut evoluer a chaque nouvelle ecriture d'audit).
|
||||||
|
*/
|
||||||
|
async function fetchEntityTypes(): Promise<string[]> {
|
||||||
|
const data = await api.get<AuditLogEntityTypes>(
|
||||||
|
'/audit-log-entity-types',
|
||||||
|
{},
|
||||||
|
{ toast: false, headers: JSONLD_HEADERS },
|
||||||
|
)
|
||||||
|
return data.entityTypes ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchEntityLogs(
|
||||||
|
entityType: string,
|
||||||
|
entityId: string | number,
|
||||||
|
page: number = 1,
|
||||||
|
itemsPerPage: number = 10,
|
||||||
|
): Promise<HydraCollection<AuditLogEntry>> {
|
||||||
|
// Volontairement via `fetchLogs` (sans cache) pour ne pas ecraser
|
||||||
|
// `lastCollection` — la timeline peut etre rendue simultanement a
|
||||||
|
// la page globale et doit rester independante.
|
||||||
|
//
|
||||||
|
// Le backend pagine a 30 par defaut (paginationItemsPerPage) ; on
|
||||||
|
// passe explicitement itemsPerPage ici pour que la taille de page
|
||||||
|
// soit alignee avec l'UX timeline (10 items + bouton "Voir plus").
|
||||||
|
// Sans ce param, le client slice a 10 et rate 20 entrees par page.
|
||||||
|
return fetchLogs({
|
||||||
|
entityType,
|
||||||
|
entityId: String(entityId),
|
||||||
|
page,
|
||||||
|
itemsPerPage,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
lastCollection,
|
||||||
|
fetchLogs: fetchLogsCached,
|
||||||
|
fetchLogById,
|
||||||
|
fetchEntityLogs,
|
||||||
|
fetchEntityTypes,
|
||||||
|
resetAuditLog,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,3 +9,44 @@ export interface SidebarSection {
|
|||||||
icon: string
|
icon: string
|
||||||
items: SidebarItem[]
|
items: SidebarItem[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Entree d'audit telle qu'elle est renvoyee par GET /api/audit-logs.
|
||||||
|
*
|
||||||
|
* `changes` est un payload libre dont le format depend de `action` :
|
||||||
|
* - `create` / `delete` : snapshot complet { champ: valeur } ;
|
||||||
|
* - `update` : diff { champ: { old, new } }.
|
||||||
|
*/
|
||||||
|
export interface AuditLogEntry {
|
||||||
|
id: string
|
||||||
|
entityType: string
|
||||||
|
entityId: string
|
||||||
|
action: 'create' | 'update' | 'delete'
|
||||||
|
changes: Record<string, unknown>
|
||||||
|
performedBy: string
|
||||||
|
performedAt: string
|
||||||
|
ipAddress: string | null
|
||||||
|
requestId: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filtres combinables en query params (AND) pour GET /api/audit-logs.
|
||||||
|
* Les bornes de date utilisent la syntaxe API Platform `performed_at[after]` /
|
||||||
|
* `performed_at[before]`.
|
||||||
|
*/
|
||||||
|
export interface AuditLogFilters {
|
||||||
|
/** Chaine pour un seul type, liste pour un filtre multi-selection. */
|
||||||
|
entityType?: string | string[]
|
||||||
|
entityId?: string
|
||||||
|
action?: string
|
||||||
|
performedBy?: string
|
||||||
|
performedAtAfter?: string
|
||||||
|
performedAtBefore?: string
|
||||||
|
page?: number
|
||||||
|
itemsPerPage?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuditLogEntityTypes {
|
||||||
|
id: string
|
||||||
|
entityTypes: string[]
|
||||||
|
}
|
||||||
|
|||||||
@@ -21,7 +21,19 @@ export interface UserListItem {
|
|||||||
isAdmin: boolean
|
isAdmin: boolean
|
||||||
roles: string[]
|
roles: string[]
|
||||||
directPermissions: string[]
|
directPermissions: string[]
|
||||||
/** IRIs des sites autorises (ticket 2 module Sites). */
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detail RBAC d'un user, renvoye par GET /api/users/{id}/rbac (groupe user:rbac:read).
|
||||||
|
* Utilise par UserRbacDrawer pour initialiser son formulaire avec l'etat complet
|
||||||
|
* (sites inclus). Le endpoint de liste /api/users reste volontairement leger et
|
||||||
|
* n'expose pas ces champs.
|
||||||
|
*/
|
||||||
|
export interface UserRbacDetail {
|
||||||
|
id: number
|
||||||
|
isAdmin: boolean
|
||||||
|
roles: string[]
|
||||||
|
directPermissions: string[]
|
||||||
sites: string[]
|
sites: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,33 @@
|
|||||||
|
/**
|
||||||
|
* Schemas Hydra / API Platform 4.
|
||||||
|
*
|
||||||
|
* Important : API Platform 4 abandonne le prefixe `hydra:` dans les noms de
|
||||||
|
* proprietes (compare a la version 3). Un GET /api/audit-logs renvoie :
|
||||||
|
* { "@context": ..., "@id": ..., "@type": "...",
|
||||||
|
* "member": [...],
|
||||||
|
* "totalItems": 30,
|
||||||
|
* "view": { "@id": ..., "@type": "...", "first": ..., "next": ..., ... } }
|
||||||
|
*
|
||||||
|
* En `application/json` (sans ld), API Platform retourne un simple tableau
|
||||||
|
* plat sans ces metadonnees — on doit donc explicitement demander
|
||||||
|
* `application/ld+json` (via l'option `headers: { Accept: ... }` de useApi)
|
||||||
|
* pour avoir acces a la pagination.
|
||||||
|
*/
|
||||||
|
export interface HydraView {
|
||||||
|
'@id'?: string
|
||||||
|
'@type'?: string
|
||||||
|
first?: string
|
||||||
|
last?: string
|
||||||
|
next?: string
|
||||||
|
previous?: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface HydraCollection<T> {
|
export interface HydraCollection<T> {
|
||||||
'hydra:member': T[]
|
member: T[]
|
||||||
'hydra:totalItems': number
|
totalItems: number
|
||||||
|
view?: HydraView
|
||||||
}
|
}
|
||||||
|
|
||||||
export function extractHydraMembers<T>(collection: HydraCollection<T>): T[] {
|
export function extractHydraMembers<T>(collection: HydraCollection<T>): T[] {
|
||||||
return collection['hydra:member'] ?? []
|
return collection.member ?? []
|
||||||
}
|
}
|
||||||
|
|||||||
63
migrations/Version20260420202749.php
Normal file
63
migrations/Version20260420202749.php
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Audit log — Ticket 1 : table append-only `audit_log`.
|
||||||
|
*
|
||||||
|
* Table non geree par Doctrine ORM (aucune entite associee). Ecriture via
|
||||||
|
* DBAL uniquement par l'AuditLogWriter pour eviter la recursion du listener
|
||||||
|
* Doctrine (flush re-entrant). Colonnes en minuscules snake_case comme
|
||||||
|
* partout dans le projet.
|
||||||
|
*
|
||||||
|
* Type natif PostgreSQL `uuid` (16 octets) plutot que varchar(36) : index
|
||||||
|
* 40% plus petit sur une table append-only a croissance infinie.
|
||||||
|
*
|
||||||
|
* Migration placee au namespace racine `DoctrineMigrations` a cause du bug
|
||||||
|
* de tri FQCN alphabetique de Doctrine Migrations 3.x documente dans
|
||||||
|
* CLAUDE.md.
|
||||||
|
*/
|
||||||
|
final class Version20260420202749 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return 'Audit log : creation de la table append-only audit_log + index.';
|
||||||
|
}
|
||||||
|
|
||||||
|
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(0) WITH TIME ZONE NOT NULL,
|
||||||
|
ip_address VARCHAR(45) DEFAULT NULL,
|
||||||
|
request_id VARCHAR(36) DEFAULT NULL,
|
||||||
|
PRIMARY KEY(id)
|
||||||
|
)
|
||||||
|
SQL);
|
||||||
|
|
||||||
|
// Index pour recherche par entite (detail d'historique d'un objet).
|
||||||
|
$this->addSql('CREATE INDEX idx_audit_entity_time ON audit_log (entity_type, entity_id, performed_at)');
|
||||||
|
|
||||||
|
// Index pour recherche par utilisateur (qui a fait quoi).
|
||||||
|
$this->addSql('CREATE INDEX idx_audit_performer ON audit_log (performed_by, performed_at)');
|
||||||
|
|
||||||
|
// Index pour tri chronologique global (listing pagine DESC).
|
||||||
|
$this->addSql('CREATE INDEX idx_audit_time ON audit_log (performed_at)');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('DROP TABLE audit_log');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,6 +16,46 @@
|
|||||||
<server name="APP_ENV" value="test" force="true" />
|
<server name="APP_ENV" value="test" force="true" />
|
||||||
<server name="SHELL_VERBOSITY" value="-1" />
|
<server name="SHELL_VERBOSITY" value="-1" />
|
||||||
<server name="KERNEL_CLASS" value="App\Kernel" />
|
<server name="KERNEL_CLASS" value="App\Kernel" />
|
||||||
|
|
||||||
|
<!-- ###+ symfony/framework-bundle ### -->
|
||||||
|
<!-- APP_ENV est force a "test" en <server> ci-dessus : on ne doit PAS
|
||||||
|
re-injecter "dev" ici via <env>, sinon la suite tourne sous
|
||||||
|
framework.test=false et `test.service_container` n'est pas cable
|
||||||
|
(cf. cc8d5 du fix pre-existant). -->
|
||||||
|
<env name="APP_ENV" value="test"/>
|
||||||
|
<env name="APP_SECRET" value=""/>
|
||||||
|
<env name="APP_SHARE_DIR" value="var/share"/>
|
||||||
|
<!-- ###- symfony/framework-bundle ### -->
|
||||||
|
|
||||||
|
<!-- ###+ symfony/routing ### -->
|
||||||
|
<!-- Configure how to generate URLs in non-HTTP contexts, such as CLI commands. -->
|
||||||
|
<!-- See https://symfony.com/doc/current/routing.html#generating-urls-in-commands -->
|
||||||
|
<env name="DEFAULT_URI" value="http://localhost"/>
|
||||||
|
<!-- ###- symfony/routing ### -->
|
||||||
|
|
||||||
|
<!-- ###+ doctrine/doctrine-bundle ### -->
|
||||||
|
<!-- Format described at https://www.doctrine-project.org/projects/doctrine-dbal/en/latest/reference/configuration.html#connecting-using-a-url -->
|
||||||
|
<!-- IMPORTANT: You MUST configure your server version, either here or in config/packages/doctrine.yaml -->
|
||||||
|
<!-- -->
|
||||||
|
<!-- DATABASE_URL="sqlite:///%kernel.project_dir%/var/data_%kernel.environment%.db" -->
|
||||||
|
<!-- DATABASE_URL="mysql://app:!ChangeMe!@127.0.0.1:3306/app?serverVersion=8.0.32&charset=utf8mb4" -->
|
||||||
|
<!-- DATABASE_URL="mysql://app:!ChangeMe!@127.0.0.1:3306/app?serverVersion=10.11.2-MariaDB&charset=utf8mb4" -->
|
||||||
|
<env name="DATABASE_URL" value="postgresql://app:!ChangeMe!@127.0.0.1:5432/app?serverVersion=16&charset=utf8"/>
|
||||||
|
<!-- ###- doctrine/doctrine-bundle ### -->
|
||||||
|
|
||||||
|
<!-- ###+ lexik/jwt-authentication-bundle ### -->
|
||||||
|
<env name="JWT_SECRET_KEY" value="%kernel.project_dir%/config/jwt/private.pem"/>
|
||||||
|
<env name="JWT_PUBLIC_KEY" value="%kernel.project_dir%/config/jwt/public.pem"/>
|
||||||
|
<!-- Doit correspondre a la passphrase utilisee lors de la generation
|
||||||
|
des cles JWT (config/jwt/*.pem). En local dev, c'est la valeur
|
||||||
|
par defaut "change_me_in_env_local" du .env (override possible
|
||||||
|
via .env.test.local si les cles ont ete regenerees autrement). -->
|
||||||
|
<env name="JWT_PASSPHRASE" value="change_me_in_env_local"/>
|
||||||
|
<!-- ###- lexik/jwt-authentication-bundle ### -->
|
||||||
|
|
||||||
|
<!-- ###+ nelmio/cors-bundle ### -->
|
||||||
|
<env name="CORS_ALLOW_ORIGIN" value="'^https?://(localhost|127\.0\.0\.1)(:[0-9]+)?$'"/>
|
||||||
|
<!-- ###- nelmio/cors-bundle ### -->
|
||||||
</php>
|
</php>
|
||||||
|
|
||||||
<testsuites>
|
<testsuites>
|
||||||
|
|||||||
30
src/Module/Core/Application/DTO/AuditLogOutput.php
Normal file
30
src/Module/Core/Application/DTO/AuditLogOutput.php
Normal file
@@ -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,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
@@ -34,6 +34,7 @@ final class CoreModule
|
|||||||
['code' => 'core.users.manage', 'label' => 'Gerer les utilisateurs (creer, editer, supprimer)'],
|
['code' => 'core.users.manage', 'label' => 'Gerer les utilisateurs (creer, editer, supprimer)'],
|
||||||
['code' => 'core.roles.view', 'label' => 'Voir les roles RBAC'],
|
['code' => 'core.roles.view', 'label' => 'Voir les roles RBAC'],
|
||||||
['code' => 'core.roles.manage', 'label' => 'Gerer les roles et permissions'],
|
['code' => 'core.roles.manage', 'label' => 'Gerer les roles et permissions'],
|
||||||
|
['code' => 'core.audit_log.view', 'label' => 'Consulter le journal d\'audit'],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ use ApiPlatform\Metadata\ApiResource;
|
|||||||
use ApiPlatform\Metadata\Get;
|
use ApiPlatform\Metadata\Get;
|
||||||
use ApiPlatform\Metadata\GetCollection;
|
use ApiPlatform\Metadata\GetCollection;
|
||||||
use App\Module\Core\Infrastructure\Doctrine\DoctrinePermissionRepository;
|
use App\Module\Core\Infrastructure\Doctrine\DoctrinePermissionRepository;
|
||||||
|
use App\Shared\Domain\Attribute\Auditable;
|
||||||
use Doctrine\ORM\Mapping as ORM;
|
use Doctrine\ORM\Mapping as ORM;
|
||||||
use InvalidArgumentException;
|
use InvalidArgumentException;
|
||||||
use Symfony\Component\Serializer\Attribute\Groups;
|
use Symfony\Component\Serializer\Attribute\Groups;
|
||||||
@@ -31,6 +32,7 @@ use Symfony\Component\Serializer\Attribute\Groups;
|
|||||||
#[ApiFilter(BooleanFilter::class, properties: ['orphan'])]
|
#[ApiFilter(BooleanFilter::class, properties: ['orphan'])]
|
||||||
#[ORM\Entity(repositoryClass: DoctrinePermissionRepository::class)]
|
#[ORM\Entity(repositoryClass: DoctrinePermissionRepository::class)]
|
||||||
#[ORM\Table(name: 'permission')]
|
#[ORM\Table(name: 'permission')]
|
||||||
|
#[Auditable]
|
||||||
#[ORM\UniqueConstraint(name: 'uniq_permission_code', columns: ['code'])]
|
#[ORM\UniqueConstraint(name: 'uniq_permission_code', columns: ['code'])]
|
||||||
#[ORM\Index(name: 'idx_permission_module', columns: ['module'])]
|
#[ORM\Index(name: 'idx_permission_module', columns: ['module'])]
|
||||||
#[ORM\Index(name: 'idx_permission_orphan', columns: ['orphan'])]
|
#[ORM\Index(name: 'idx_permission_orphan', columns: ['orphan'])]
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ use ApiPlatform\Metadata\Post;
|
|||||||
use App\Module\Core\Domain\Exception\SystemRoleDeletionException;
|
use App\Module\Core\Domain\Exception\SystemRoleDeletionException;
|
||||||
use App\Module\Core\Infrastructure\ApiPlatform\State\Processor\RoleProcessor;
|
use App\Module\Core\Infrastructure\ApiPlatform\State\Processor\RoleProcessor;
|
||||||
use App\Module\Core\Infrastructure\Doctrine\DoctrineRoleRepository;
|
use App\Module\Core\Infrastructure\Doctrine\DoctrineRoleRepository;
|
||||||
|
use App\Shared\Domain\Attribute\Auditable;
|
||||||
use Doctrine\Common\Collections\ArrayCollection;
|
use Doctrine\Common\Collections\ArrayCollection;
|
||||||
use Doctrine\Common\Collections\Collection;
|
use Doctrine\Common\Collections\Collection;
|
||||||
use Doctrine\DBAL\Types\Types;
|
use Doctrine\DBAL\Types\Types;
|
||||||
@@ -64,6 +65,7 @@ use Symfony\Component\Validator\Constraints as Assert;
|
|||||||
#[ApiFilter(BooleanFilter::class, properties: ['isSystem'])]
|
#[ApiFilter(BooleanFilter::class, properties: ['isSystem'])]
|
||||||
#[ORM\Entity(repositoryClass: DoctrineRoleRepository::class)]
|
#[ORM\Entity(repositoryClass: DoctrineRoleRepository::class)]
|
||||||
#[ORM\Table(name: '`role`')]
|
#[ORM\Table(name: '`role`')]
|
||||||
|
#[Auditable]
|
||||||
#[ORM\UniqueConstraint(name: 'uniq_role_code', columns: ['code'])]
|
#[ORM\UniqueConstraint(name: 'uniq_role_code', columns: ['code'])]
|
||||||
#[ORM\Index(name: 'idx_role_is_system', columns: ['is_system'])]
|
#[ORM\Index(name: 'idx_role_is_system', columns: ['is_system'])]
|
||||||
#[UniqueEntity(fields: ['code'], message: 'Un role avec ce code existe deja.')]
|
#[UniqueEntity(fields: ['code'], message: 'Un role avec ce code existe deja.')]
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ use App\Module\Core\Infrastructure\Doctrine\DoctrineUserRepository;
|
|||||||
// (targetEntity) via FQCN string, ce qui est obligatoire pour Doctrine.
|
// (targetEntity) via FQCN string, ce qui est obligatoire pour Doctrine.
|
||||||
// SiteNotAuthorizedException est importee depuis Shared (sa location canonique).
|
// SiteNotAuthorizedException est importee depuis Shared (sa location canonique).
|
||||||
use App\Module\Sites\Domain\Entity\Site;
|
use App\Module\Sites\Domain\Entity\Site;
|
||||||
|
use App\Shared\Domain\Attribute\Auditable;
|
||||||
|
use App\Shared\Domain\Attribute\AuditIgnore;
|
||||||
use App\Shared\Domain\Contract\SiteInterface;
|
use App\Shared\Domain\Contract\SiteInterface;
|
||||||
use App\Shared\Domain\Exception\SiteNotAuthorizedException;
|
use App\Shared\Domain\Exception\SiteNotAuthorizedException;
|
||||||
use DateTimeImmutable;
|
use DateTimeImmutable;
|
||||||
@@ -49,6 +51,16 @@ use Symfony\Component\Serializer\Attribute\SerializedName;
|
|||||||
),
|
),
|
||||||
new Post(security: "is_granted('core.users.manage')", processor: UserPasswordHasherProcessor::class),
|
new Post(security: "is_granted('core.users.manage')", processor: UserPasswordHasherProcessor::class),
|
||||||
new Patch(security: "is_granted('core.users.manage')", processor: UserPasswordHasherProcessor::class),
|
new Patch(security: "is_granted('core.users.manage')", processor: UserPasswordHasherProcessor::class),
|
||||||
|
// Lecture dediee au drawer d'edition RBAC : meme URI que le PATCH pour une
|
||||||
|
// API symetrique, groupe `user:rbac:read` qui expose sites/roles/directPermissions.
|
||||||
|
// Garde `core.users.manage` (pas `.view`) car c'est l'endpoint de detail prevu
|
||||||
|
// pour l'edition, pas la consultation generale (elle passe par GET /users/{id}).
|
||||||
|
new Get(
|
||||||
|
name: 'user_rbac_get',
|
||||||
|
uriTemplate: '/users/{id}/rbac',
|
||||||
|
security: "is_granted('core.users.manage')",
|
||||||
|
normalizationContext: ['groups' => ['user:rbac:read']],
|
||||||
|
),
|
||||||
new Patch(
|
new Patch(
|
||||||
name: 'user_rbac_patch',
|
name: 'user_rbac_patch',
|
||||||
uriTemplate: '/users/{id}/rbac',
|
uriTemplate: '/users/{id}/rbac',
|
||||||
@@ -63,6 +75,7 @@ use Symfony\Component\Serializer\Attribute\SerializedName;
|
|||||||
)]
|
)]
|
||||||
#[ORM\Entity(repositoryClass: DoctrineUserRepository::class)]
|
#[ORM\Entity(repositoryClass: DoctrineUserRepository::class)]
|
||||||
#[ORM\Table(name: '`user`')]
|
#[ORM\Table(name: '`user`')]
|
||||||
|
#[Auditable]
|
||||||
class User implements UserInterface, PasswordAuthenticatedUserInterface
|
class User implements UserInterface, PasswordAuthenticatedUserInterface
|
||||||
{
|
{
|
||||||
#[ORM\Id]
|
#[ORM\Id]
|
||||||
@@ -155,9 +168,11 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
|
|||||||
private ?SiteInterface $currentSite = null;
|
private ?SiteInterface $currentSite = null;
|
||||||
|
|
||||||
#[ORM\Column]
|
#[ORM\Column]
|
||||||
|
#[AuditIgnore]
|
||||||
private ?string $password = null;
|
private ?string $password = null;
|
||||||
|
|
||||||
#[Groups(['user:write'])]
|
#[Groups(['user:write'])]
|
||||||
|
#[AuditIgnore]
|
||||||
private ?string $plainPassword = null;
|
private ?string $plainPassword = null;
|
||||||
|
|
||||||
#[ORM\Column(type: 'datetime_immutable')]
|
#[ORM\Column(type: 'datetime_immutable')]
|
||||||
|
|||||||
@@ -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,56 @@
|
|||||||
|
<?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 est assuree par le provider via DbalPaginator (implementant
|
||||||
|
* ApiPlatform\State\Pagination\PaginatorInterface), ce qui genere
|
||||||
|
* automatiquement hydra:view — aucune construction manuelle.
|
||||||
|
*/
|
||||||
|
#[ApiResource(
|
||||||
|
shortName: 'AuditLog',
|
||||||
|
operations: [
|
||||||
|
new GetCollection(
|
||||||
|
uriTemplate: '/audit-logs',
|
||||||
|
paginationItemsPerPage: 30,
|
||||||
|
paginationClientItemsPerPage: true,
|
||||||
|
paginationMaximumItemsPerPage: 100,
|
||||||
|
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 {}
|
||||||
@@ -9,11 +9,13 @@ use ApiPlatform\State\ProcessorInterface;
|
|||||||
use App\Module\Core\Domain\Entity\User;
|
use App\Module\Core\Domain\Entity\User;
|
||||||
use App\Module\Core\Domain\Exception\LastAdminProtectionException;
|
use App\Module\Core\Domain\Exception\LastAdminProtectionException;
|
||||||
use App\Module\Core\Domain\Security\AdminHeadcountGuardInterface;
|
use App\Module\Core\Domain\Security\AdminHeadcountGuardInterface;
|
||||||
|
use Doctrine\Common\Collections\Collection;
|
||||||
use Doctrine\ORM\EntityManagerInterface;
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
use Doctrine\ORM\PersistentCollection;
|
use Doctrine\ORM\PersistentCollection;
|
||||||
use LogicException;
|
use LogicException;
|
||||||
use Symfony\Bundle\SecurityBundle\Security;
|
use Symfony\Bundle\SecurityBundle\Security;
|
||||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||||
|
use Symfony\Component\HttpFoundation\RequestStack;
|
||||||
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||||||
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
||||||
|
|
||||||
@@ -51,12 +53,31 @@ use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
|||||||
*/
|
*/
|
||||||
final class UserRbacProcessor implements ProcessorInterface
|
final class UserRbacProcessor implements ProcessorInterface
|
||||||
{
|
{
|
||||||
|
/**
|
||||||
|
* Mapping cle-payload → (property-path PHP, accesseur, setter utilise pour
|
||||||
|
* reattacher les items lors de la restauration). Permet au gardefou
|
||||||
|
* anti-ecrasement de savoir quelles collections restaurer si elles sont
|
||||||
|
* absentes du payload JSON.
|
||||||
|
*
|
||||||
|
* Note : la cle JSON "roles" correspond a la propriete PHP `rbacRoles`
|
||||||
|
* (renommee via #[SerializedName] pour eviter la collision avec
|
||||||
|
* UserInterface::getRoles()).
|
||||||
|
*
|
||||||
|
* @var array<string, array{getter: string, remover: string, adder: string}>
|
||||||
|
*/
|
||||||
|
private const array COLLECTION_MAP = [
|
||||||
|
'roles' => ['getter' => 'getRbacRoles', 'remover' => 'removeRbacRole', 'adder' => 'addRbacRole'],
|
||||||
|
'directPermissions' => ['getter' => 'getDirectPermissions', 'remover' => 'removeDirectPermission', 'adder' => 'addDirectPermission'],
|
||||||
|
'sites' => ['getter' => 'getSites', 'remover' => 'removeSite', 'adder' => 'addSite'],
|
||||||
|
];
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
#[Autowire(service: 'api_platform.doctrine.orm.state.persist_processor')]
|
#[Autowire(service: 'api_platform.doctrine.orm.state.persist_processor')]
|
||||||
private readonly ProcessorInterface $persistProcessor,
|
private readonly ProcessorInterface $persistProcessor,
|
||||||
private readonly EntityManagerInterface $entityManager,
|
private readonly EntityManagerInterface $entityManager,
|
||||||
private readonly Security $security,
|
private readonly Security $security,
|
||||||
private readonly AdminHeadcountGuardInterface $adminHeadcountGuard,
|
private readonly AdminHeadcountGuardInterface $adminHeadcountGuard,
|
||||||
|
private readonly RequestStack $requestStack,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
|
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
|
||||||
@@ -72,6 +93,19 @@ final class UserRbacProcessor implements ProcessorInterface
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Garde anti-ecrasement (defense in depth) : PATCH merge-patch+json impose
|
||||||
|
// que les cles absentes du payload ne mutent PAS les proprietes
|
||||||
|
// correspondantes. La denormalisation API Platform ne respecte pas cet
|
||||||
|
// invariant pour les collections ManyToMany — elle reinstancie une
|
||||||
|
// ArrayCollection vide des que la cle n'est pas presente. Sans cette
|
||||||
|
// garde, un client qui PATCHe juste `{ "isAdmin": true }` verrait toutes
|
||||||
|
// ses roles/directPermissions/sites detruits.
|
||||||
|
//
|
||||||
|
// On lit le body brut de la requete pour connaitre les cles envoyees,
|
||||||
|
// puis on restaure les collections absentes a partir de l'etat d'origine
|
||||||
|
// charge par Doctrine (snapshot des PersistentCollection).
|
||||||
|
$this->restoreAbsentCollections($data);
|
||||||
|
|
||||||
$currentUser = $this->security->getUser();
|
$currentUser = $this->security->getUser();
|
||||||
|
|
||||||
// Calcul partage entre les deux gardes : l'user perdait-il le flag admin ?
|
// Calcul partage entre les deux gardes : l'user perdait-il le flag admin ?
|
||||||
@@ -180,4 +214,73 @@ final class UserRbacProcessor implements ProcessorInterface
|
|||||||
$this->entityManager->flush();
|
$this->entityManager->flush();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pour chaque collection RBAC (roles, directPermissions, sites) absente du
|
||||||
|
* payload JSON, restaure l'etat d'origine a partir du snapshot Doctrine et
|
||||||
|
* marque la collection comme non-dirty. Idempotent : si la cle est presente
|
||||||
|
* dans le payload, no-op (la denormalisation fait foi).
|
||||||
|
*
|
||||||
|
* Cas d'usage : un client qui PATCHe partiellement (`{ "isAdmin": true }`)
|
||||||
|
* ne doit pas voir ses autres collections reinitialisees. API Platform
|
||||||
|
* reinstancie par defaut une collection vide pour les cles absentes, ce
|
||||||
|
* qui casse la semantique de merge-patch+json.
|
||||||
|
*
|
||||||
|
* Pas de fallback si la collection d'origine n'est pas une PersistentCollection
|
||||||
|
* (ex: User fraichement construit) : dans ce cas aucune restauration n'est
|
||||||
|
* possible puisqu'il n'y a pas d'etat persiste a restaurer.
|
||||||
|
*/
|
||||||
|
private function restoreAbsentCollections(User $user): void
|
||||||
|
{
|
||||||
|
$request = $this->requestStack->getCurrentRequest();
|
||||||
|
if (null === $request) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$rawBody = $request->getContent();
|
||||||
|
if ('' === $rawBody) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var null|array<string, mixed> $payload */
|
||||||
|
$payload = json_decode($rawBody, true);
|
||||||
|
if (!is_array($payload)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (self::COLLECTION_MAP as $jsonKey => $accessors) {
|
||||||
|
if (array_key_exists($jsonKey, $payload)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var Collection<int, object> $currentCollection */
|
||||||
|
$currentCollection = $user->{$accessors['getter']}();
|
||||||
|
|
||||||
|
if (!$currentCollection instanceof PersistentCollection) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Snapshot = etat charge depuis la BDD avant denormalisation.
|
||||||
|
// On restaure en retirant les items actuels et en ajoutant les
|
||||||
|
// originaux via l'adder/remover pour que les collections inverses
|
||||||
|
// (ex: Site::users) restent coherentes.
|
||||||
|
$snapshot = $currentCollection->getSnapshot();
|
||||||
|
|
||||||
|
foreach ($currentCollection->toArray() as $currentItem) {
|
||||||
|
if (!in_array($currentItem, $snapshot, true)) {
|
||||||
|
$user->{$accessors['remover']}($currentItem);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($snapshot as $originalItem) {
|
||||||
|
if (!$currentCollection->contains($originalItem)) {
|
||||||
|
$user->{$accessors['adder']}($originalItem);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Marquer comme non-dirty pour que Doctrine ne detecte pas de diff
|
||||||
|
// et n'emette pas de requete UPDATE inutile sur la table de jointure.
|
||||||
|
$currentCollection->takeSnapshot();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,209 @@
|
|||||||
|
<?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;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
||||||
|
{
|
||||||
|
$page = $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')
|
||||||
|
->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', 'action', 'performed_by'] as $key) {
|
||||||
|
if (isset($raw[$key]) && is_string($raw[$key]) && '' !== $raw[$key]) {
|
||||||
|
$filters[$key] = $raw[$key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filtres de plage `performed_at[after]` / `performed_at[before]`.
|
||||||
|
if (isset($raw['performed_at']) && is_array($raw['performed_at'])) {
|
||||||
|
$range = $raw['performed_at'];
|
||||||
|
if (isset($range['after']) && is_string($range['after']) && '' !== $range['after']) {
|
||||||
|
$filters['performed_at_after'] = $range['after'];
|
||||||
|
}
|
||||||
|
if (isset($range['before']) && is_string($range['before']) && '' !== $range['before']) {
|
||||||
|
$filters['performed_at_before'] = $range['before'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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). La clause `ESCAPE '\\'` indique
|
||||||
|
// a PostgreSQL le caractere d'echappement utilise dans le motif.
|
||||||
|
$escaped = str_replace(['\\', '%', '_'], ['\\\\', '\%', '\_'], $filters['performed_by']);
|
||||||
|
$qb->andWhere("performed_by ILIKE :performed_by ESCAPE '\\'")
|
||||||
|
->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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
100
src/Module/Core/Infrastructure/Audit/AuditLogWriter.php
Normal file
100
src/Module/Core/Infrastructure/Audit/AuditLogWriter.php
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
<?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;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Service bas-niveau responsable de l'ecriture dans la table `audit_log`.
|
||||||
|
*
|
||||||
|
* Utilise une connexion DBAL dediee `audit` (meme DSN que `default`, service
|
||||||
|
* separe) pour ecrire hors de la transaction ORM : indispensable pour que
|
||||||
|
* les lignes d'audit survivent meme si le flush applicatif est rollback,
|
||||||
|
* et pour eviter tout entanglement transactionnel en batch (fixtures).
|
||||||
|
*
|
||||||
|
* Les cles sensibles (password, plainPassword, token, secret) sont filtrees
|
||||||
|
* en defense-in-depth meme si les entites declarent deja ces proprietes
|
||||||
|
* #[AuditIgnore].
|
||||||
|
*
|
||||||
|
* Erreur silencieuse : en cas d'echec SQL, on lance pas l'exception plus
|
||||||
|
* haut — l'audit ne doit jamais faire crasher un flux metier. Le listener
|
||||||
|
* wrappe l'appel dans un try/catch + logger (cf. AuditListener).
|
||||||
|
*/
|
||||||
|
final class AuditLogWriter
|
||||||
|
{
|
||||||
|
/** @var list<string> cles systematiquement strippees du payload `changes` */
|
||||||
|
private const array SENSITIVE_KEYS = ['password', 'plainPassword', '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,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ecrit une ligne d'audit.
|
||||||
|
*
|
||||||
|
* @param string $entityType Format "module.Entity" (ex: "core.User")
|
||||||
|
* @param string $entityId ID de l'entite (int ou UUID serialise)
|
||||||
|
* @param string $action create|update|delete
|
||||||
|
* @param array<string, mixed> $changes Payload JSON (filtre des cles sensibles)
|
||||||
|
*/
|
||||||
|
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(),
|
||||||
|
], [
|
||||||
|
// Types de conversion DBAL : UUID natif PG + jsonb + datetimetz.
|
||||||
|
// Sans 'id' => GUID, DBAL passerait un varchar et Postgres ferait
|
||||||
|
// un cast implicite — ca marche mais l'intention est floue.
|
||||||
|
'id' => Types::GUID,
|
||||||
|
'changes' => Types::JSON,
|
||||||
|
'performed_at' => Types::DATETIMETZ_IMMUTABLE,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Supprime recursivement les cles sensibles du payload.
|
||||||
|
*
|
||||||
|
* Utile pour les snapshots complets (create/delete) ou les changes
|
||||||
|
* d'update : le listener prefiltre deja mais on garde cette garde
|
||||||
|
* en defense-in-depth si un appelant direct oublie `#[AuditIgnore]`.
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $data
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private function stripSensitive(array $data): array
|
||||||
|
{
|
||||||
|
foreach (self::SENSITIVE_KEYS as $sensitiveKey) {
|
||||||
|
unset($data[$sensitiveKey]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $data;
|
||||||
|
}
|
||||||
|
}
|
||||||
42
src/Module/Core/Infrastructure/Audit/RequestIdProvider.php
Normal file
42
src/Module/Core/Infrastructure/Audit/RequestIdProvider.php
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
<?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;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fournit un identifiant de requete HTTP (UUID v4) partage par toutes les
|
||||||
|
* lignes d'audit produites au cours d'une meme requete principale.
|
||||||
|
*
|
||||||
|
* Utilite : retrouver d'un seul coup d'oeil toutes les ecritures liees a un
|
||||||
|
* meme appel utilisateur (ex: PATCH qui cascade des updates sur plusieurs
|
||||||
|
* entites). Null en CLI (fixtures, commandes batch).
|
||||||
|
*
|
||||||
|
* Service singleton (scope container par defaut) — un unique UUID est
|
||||||
|
* genere au kernel.request principal et reutilise pour toute la requete.
|
||||||
|
*/
|
||||||
|
final class RequestIdProvider
|
||||||
|
{
|
||||||
|
private ?string $requestId = null;
|
||||||
|
|
||||||
|
#[AsEventListener(event: 'kernel.request')]
|
||||||
|
public function onKernelRequest(RequestEvent $event): void
|
||||||
|
{
|
||||||
|
// Ignorer les sub-requests (ESI, forward interne) pour ne pas
|
||||||
|
// ecraser l'UUID de la requete principale.
|
||||||
|
if (!$event->isMainRequest()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->requestId = Uuid::v4()->toRfc4122();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getRequestId(): ?string
|
||||||
|
{
|
||||||
|
return $this->requestId;
|
||||||
|
}
|
||||||
|
}
|
||||||
505
src/Module/Core/Infrastructure/Doctrine/AuditListener.php
Normal file
505
src/Module/Core/Infrastructure/Doctrine/AuditListener.php
Normal file
@@ -0,0 +1,505 @@
|
|||||||
|
<?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();
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ use ApiPlatform\Metadata\Patch;
|
|||||||
use ApiPlatform\Metadata\Post;
|
use ApiPlatform\Metadata\Post;
|
||||||
use App\Module\Core\Domain\Entity\User;
|
use App\Module\Core\Domain\Entity\User;
|
||||||
use App\Module\Sites\Infrastructure\Doctrine\DoctrineSiteRepository;
|
use App\Module\Sites\Infrastructure\Doctrine\DoctrineSiteRepository;
|
||||||
|
use App\Shared\Domain\Attribute\Auditable;
|
||||||
use App\Shared\Domain\Contract\SiteInterface;
|
use App\Shared\Domain\Contract\SiteInterface;
|
||||||
use DateTimeImmutable;
|
use DateTimeImmutable;
|
||||||
use Doctrine\Common\Collections\ArrayCollection;
|
use Doctrine\Common\Collections\ArrayCollection;
|
||||||
@@ -64,6 +65,7 @@ use Symfony\Component\Validator\Constraints as Assert;
|
|||||||
)]
|
)]
|
||||||
#[ORM\Entity(repositoryClass: DoctrineSiteRepository::class)]
|
#[ORM\Entity(repositoryClass: DoctrineSiteRepository::class)]
|
||||||
#[ORM\Table(name: 'site')]
|
#[ORM\Table(name: 'site')]
|
||||||
|
#[Auditable]
|
||||||
#[ORM\UniqueConstraint(name: 'uniq_site_name', columns: ['name'])]
|
#[ORM\UniqueConstraint(name: 'uniq_site_name', columns: ['name'])]
|
||||||
#[ORM\HasLifecycleCallbacks]
|
#[ORM\HasLifecycleCallbacks]
|
||||||
#[UniqueEntity(fields: ['name'], message: 'Un site avec ce nom existe deja.')]
|
#[UniqueEntity(fields: ['name'], message: 'Un site avec ce nom existe deja.')]
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ class DoctrineSiteRepository extends ServiceEntityRepository implements SiteRepo
|
|||||||
*/
|
*/
|
||||||
public function findAllOrderedByName(): array
|
public function findAllOrderedByName(): array
|
||||||
{
|
{
|
||||||
/** @var list<Site> $sites */
|
// @var list<Site> $sites
|
||||||
return $this->findBy([], ['name' => 'ASC']);
|
return $this->findBy([], ['name' => 'ASC']);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
19
src/Shared/Domain/Attribute/AuditIgnore.php
Normal file
19
src/Shared/Domain/Attribute/AuditIgnore.php
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Shared\Domain\Attribute;
|
||||||
|
|
||||||
|
use Attribute;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Marqueur a poser sur une propriete d'entite pour l'exclure du tracking audit.
|
||||||
|
*
|
||||||
|
* Usage typique : champs sensibles (password, token), champs bruyants (updatedAt
|
||||||
|
* si recalcule sur chaque ecriture), champs derives. L'AuditLogWriter porte
|
||||||
|
* deja une blacklist exact-match sur les noms les plus dangereux (password,
|
||||||
|
* plainPassword, token, secret) en defense-in-depth, mais la regle de base
|
||||||
|
* reste : annoter explicitement ce qu'on ne veut pas voir trace.
|
||||||
|
*/
|
||||||
|
#[Attribute(Attribute::TARGET_PROPERTY)]
|
||||||
|
final class AuditIgnore {}
|
||||||
19
src/Shared/Domain/Attribute/Auditable.php
Normal file
19
src/Shared/Domain/Attribute/Auditable.php
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Shared\Domain\Attribute;
|
||||||
|
|
||||||
|
use Attribute;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Marqueur a poser sur une entite Doctrine pour activer le tracking audit.
|
||||||
|
*
|
||||||
|
* Emplacement dans Shared (pas dans Core) pour que tous les modules puissent
|
||||||
|
* l'utiliser sans dependance circulaire vers Core.
|
||||||
|
*
|
||||||
|
* Regle projet (cf. doc/audit-log.md) : toute entite metier DOIT porter cet
|
||||||
|
* attribut, avec #[AuditIgnore] sur les champs sensibles ou bruyants.
|
||||||
|
*/
|
||||||
|
#[Attribute(Attribute::TARGET_CLASS)]
|
||||||
|
final class Auditable {}
|
||||||
@@ -17,7 +17,7 @@ class SidebarProvider implements ProviderInterface
|
|||||||
/** @var list<string> */
|
/** @var list<string> */
|
||||||
private readonly array $activeModuleIds;
|
private readonly array $activeModuleIds;
|
||||||
|
|
||||||
/** @var list<array{label: string, icon: string, items: list<array{label: string, to: string, icon: string, module: string, permission?: string}>}> */
|
/** @var list<array{label: string, icon: string, permission?: string, items: list<array{label: string, to: string, icon: string, module: string, permission?: string}>}> */
|
||||||
private readonly array $sidebarConfig;
|
private readonly array $sidebarConfig;
|
||||||
|
|
||||||
public function __construct(private readonly Security $security)
|
public function __construct(private readonly Security $security)
|
||||||
@@ -47,6 +47,23 @@ class SidebarProvider implements ProviderInterface
|
|||||||
$disabledRoutes = [];
|
$disabledRoutes = [];
|
||||||
|
|
||||||
foreach ($this->sidebarConfig as $section) {
|
foreach ($this->sidebarConfig as $section) {
|
||||||
|
// Gate de section (optionnel) : si la section declare une permission
|
||||||
|
// et que l'utilisateur ne la possede pas, la section entiere est
|
||||||
|
// masquee. Toutes les routes de ses items basculent dans
|
||||||
|
// `disabledRoutes` pour que le middleware front redirige toute
|
||||||
|
// navigation directe, y compris si l'item n'a pas de permission
|
||||||
|
// individuelle (la section agit comme un umbrella gate).
|
||||||
|
$sectionPermission = $section['permission'] ?? null;
|
||||||
|
if (null !== $sectionPermission && !$this->security->isGranted($sectionPermission)) {
|
||||||
|
foreach ($section['items'] ?? [] as $item) {
|
||||||
|
if (isset($item['to'])) {
|
||||||
|
$disabledRoutes[] = $item['to'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
$items = [];
|
$items = [];
|
||||||
foreach ($section['items'] ?? [] as $item) {
|
foreach ($section['items'] ?? [] as $item) {
|
||||||
$isActive = in_array($item['module'] ?? null, $this->activeModuleIds, true);
|
$isActive = in_array($item['module'] ?? null, $this->activeModuleIds, true);
|
||||||
|
|||||||
23
templates/base.html.twig
Normal file
23
templates/base.html.twig
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>{% block title %}Welcome!{% endblock %}</title>
|
||||||
|
<link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 128 128%22><text y=%221.2em%22 font-size=%2296%22>⚫️</text><text y=%221.3em%22 x=%220.2em%22 font-size=%2276%22 fill=%22%23fff%22>sf</text></svg>">
|
||||||
|
{% block stylesheets %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block javascripts %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% set frankenphpHotReload = app.request.server.get('FRANKENPHP_HOT_RELOAD') %}
|
||||||
|
{% if frankenphpHotReload %}
|
||||||
|
<meta name="frankenphp-hot-reload:url" content="{{ frankenphpHotReload }}">
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/idiomorph"></script>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/frankenphp-hot-reload/+esm" type="module"></script>
|
||||||
|
{% endif %}
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
{% block body %}{% endblock %}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
386
tests/Module/Core/Api/AuditLogApiTest.php
Normal file
386
tests/Module/Core/Api/AuditLogApiTest.php
Normal file
@@ -0,0 +1,386 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Module\Core\Api;
|
||||||
|
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use DateTimeZone;
|
||||||
|
use Doctrine\DBAL\Connection;
|
||||||
|
use Symfony\Component\Uid\Uuid;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests fonctionnels de l'API `/api/audit-logs`.
|
||||||
|
*
|
||||||
|
* Invariants testes :
|
||||||
|
* - 401 sans authentification ;
|
||||||
|
* - 403 pour un user authentifie sans permission `core.audit_log.view` ;
|
||||||
|
* - 200 + JSON-LD pagine pour admin et user avec la permission ;
|
||||||
|
* - filtres `entity_type`, `action` operants ;
|
||||||
|
* - ordre `performed_at DESC` ;
|
||||||
|
* - aucune operation d'ecriture exposee (POST -> 405).
|
||||||
|
*
|
||||||
|
* Seed : on insere 3 lignes temoins directement via DBAL (pas via l'ORM)
|
||||||
|
* pour eviter la recursion du listener. Les lignes sont supprimees en
|
||||||
|
* tearDown par le request_id tag specifique au run.
|
||||||
|
*
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
final class AuditLogApiTest extends AbstractApiTestCase
|
||||||
|
{
|
||||||
|
// Proprietes nullable : si `bootKernel()` ou l'acces container echoue,
|
||||||
|
// `tearDown` se declenche quand meme et doit survivre a un setUp incomplet
|
||||||
|
// (sinon on masque l'exception d'origine avec un "typed property must not
|
||||||
|
// be accessed before initialization").
|
||||||
|
private ?Connection $auditConnection = null;
|
||||||
|
|
||||||
|
private ?string $runTag = null;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
self::bootKernel();
|
||||||
|
|
||||||
|
/** @var Connection $conn */
|
||||||
|
$conn = self::getContainer()->get('doctrine.dbal.audit_connection');
|
||||||
|
$this->auditConnection = $conn;
|
||||||
|
|
||||||
|
$this->runTag = 'apiaudit'.bin2hex(random_bytes(4));
|
||||||
|
$this->seedAuditLog();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function tearDown(): void
|
||||||
|
{
|
||||||
|
if (null !== $this->auditConnection && null !== $this->runTag) {
|
||||||
|
$this->auditConnection->executeStatement(
|
||||||
|
'DELETE FROM audit_log WHERE request_id = :tag',
|
||||||
|
['tag' => $this->runTag],
|
||||||
|
);
|
||||||
|
// Close explicite pour liberer la connexion PG : en test, le
|
||||||
|
// kernel reboote et les connexions pendantes saturent le pool
|
||||||
|
// sur une suite de 200+ tests qui ouvrent 2 connexions chacun.
|
||||||
|
$this->auditConnection->close();
|
||||||
|
}
|
||||||
|
parent::tearDown();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testUnauthenticatedRequestGets401(): void
|
||||||
|
{
|
||||||
|
$client = self::createClient();
|
||||||
|
$response = $client->request('GET', '/api/audit-logs');
|
||||||
|
|
||||||
|
self::assertSame(401, $response->getStatusCode());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAuthenticatedUserWithoutPermissionGets403(): void
|
||||||
|
{
|
||||||
|
// Utilise `core.users.view` comme permission non-liee (l'user n'a pas audit_log.view).
|
||||||
|
$credentials = $this->createUserWithPermission('core.users.view');
|
||||||
|
$client = $this->authenticatedClient($credentials['username'], $credentials['password']);
|
||||||
|
$response = $client->request('GET', '/api/audit-logs');
|
||||||
|
|
||||||
|
self::assertSame(403, $response->getStatusCode());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAuthenticatedUserWithPermissionGets200(): void
|
||||||
|
{
|
||||||
|
$credentials = $this->createUserWithPermission('core.audit_log.view');
|
||||||
|
$client = $this->authenticatedClient($credentials['username'], $credentials['password']);
|
||||||
|
$response = $client->request('GET', '/api/audit-logs');
|
||||||
|
|
||||||
|
self::assertSame(200, $response->getStatusCode());
|
||||||
|
|
||||||
|
$data = $response->toArray();
|
||||||
|
self::assertArrayHasKey('member', $data);
|
||||||
|
self::assertArrayHasKey('totalItems', $data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Le frontend demande explicitement `application/ld+json` dans `useAuditLog`
|
||||||
|
* pour obtenir l'objet Hydra complet (`member`, `totalItems`, `view`). Sous
|
||||||
|
* `application/json`, API Platform 4 renvoie un tableau plat sans ces
|
||||||
|
* metadonnees, ce qui casserait la pagination prev/next. Ce test verrouille
|
||||||
|
* le contrat : un changement de format par defaut ou une desactivation de
|
||||||
|
* JSON-LD produirait un 200 trompeur mais un tableau admin vide.
|
||||||
|
*/
|
||||||
|
public function testJsonLdFormatExposesHydraEnvelope(): void
|
||||||
|
{
|
||||||
|
$client = $this->authenticatedClient('admin', 'admin');
|
||||||
|
$response = $client->request('GET', '/api/audit-logs', [
|
||||||
|
'headers' => ['Accept' => 'application/ld+json'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
self::assertSame(200, $response->getStatusCode());
|
||||||
|
self::assertStringContainsString('application/ld+json', $response->getHeaders()['content-type'][0]);
|
||||||
|
|
||||||
|
$data = $response->toArray();
|
||||||
|
self::assertArrayHasKey('member', $data);
|
||||||
|
self::assertArrayHasKey('totalItems', $data);
|
||||||
|
// `view` n'est presente que si une pagination est active (plus d'items
|
||||||
|
// que la limite par page). Avec paginationItemsPerPage=30 et les 3
|
||||||
|
// lignes seedees (+ d'autres lignes de tests precedents), la collection
|
||||||
|
// peut excelder 30. Si presente, elle doit porter au moins @id.
|
||||||
|
if (isset($data['view'])) {
|
||||||
|
self::assertArrayHasKey('@id', $data['view']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAdminGets200(): void
|
||||||
|
{
|
||||||
|
$client = $this->authenticatedClient('admin', 'admin');
|
||||||
|
$response = $client->request('GET', '/api/audit-logs');
|
||||||
|
|
||||||
|
self::assertSame(200, $response->getStatusCode());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testFilterByEntityType(): void
|
||||||
|
{
|
||||||
|
$client = $this->authenticatedClient('admin', 'admin');
|
||||||
|
$response = $client->request('GET', '/api/audit-logs?entity_type=core.User&action=update');
|
||||||
|
|
||||||
|
self::assertSame(200, $response->getStatusCode());
|
||||||
|
$data = $response->toArray();
|
||||||
|
$members = $data['member'];
|
||||||
|
|
||||||
|
// On verifie qu'il n'y a que des lignes matching nos filtres dans les resultats de notre run
|
||||||
|
// (d'autres lignes antérieures au run peuvent exister, mais le filtre doit etre respecte).
|
||||||
|
foreach ($members as $member) {
|
||||||
|
self::assertSame('core.User', $member['entityType']);
|
||||||
|
self::assertSame('update', $member['action']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testOrderedByPerformedAtDesc(): void
|
||||||
|
{
|
||||||
|
$client = $this->authenticatedClient('admin', 'admin');
|
||||||
|
// On cible les 3 lignes seedees via le filtre `entity_id=999` (unique a ce test).
|
||||||
|
$response = $client->request('GET', '/api/audit-logs?'.http_build_query(['entity_type' => 'core.User', 'entity_id' => '999']));
|
||||||
|
|
||||||
|
self::assertSame(200, $response->getStatusCode());
|
||||||
|
|
||||||
|
$data = $response->toArray();
|
||||||
|
$members = array_values(array_filter(
|
||||||
|
$data['member'],
|
||||||
|
fn (array $m) => ($m['requestId'] ?? null) === $this->runTag,
|
||||||
|
));
|
||||||
|
|
||||||
|
self::assertCount(3, $members, 'Les 3 lignes seedees doivent etre visibles');
|
||||||
|
// Tri DESC : le plus recent d'abord.
|
||||||
|
$timestamps = array_map(fn (array $m) => strtotime((string) $m['performedAt']), $members);
|
||||||
|
$sortedDesc = $timestamps;
|
||||||
|
rsort($sortedDesc);
|
||||||
|
self::assertSame($sortedDesc, $timestamps, 'Les lignes doivent etre triees par performedAt DESC');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testItemEndpointReturns200WithPermission(): void
|
||||||
|
{
|
||||||
|
$row = $this->auditConnection->fetchAssociative(
|
||||||
|
'SELECT id FROM audit_log WHERE request_id = :tag LIMIT 1',
|
||||||
|
['tag' => $this->runTag],
|
||||||
|
);
|
||||||
|
self::assertIsArray($row);
|
||||||
|
|
||||||
|
$client = $this->authenticatedClient('admin', 'admin');
|
||||||
|
$response = $client->request('GET', '/api/audit-logs/'.$row['id']);
|
||||||
|
|
||||||
|
self::assertSame(200, $response->getStatusCode());
|
||||||
|
$data = $response->toArray();
|
||||||
|
self::assertSame($row['id'], $data['id']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testPostIsNotAllowed(): void
|
||||||
|
{
|
||||||
|
$client = $this->authenticatedClient('admin', 'admin');
|
||||||
|
$response = $client->request('POST', '/api/audit-logs', [
|
||||||
|
'headers' => ['Content-Type' => 'application/ld+json'],
|
||||||
|
'json' => ['entityType' => 'core.User', 'entityId' => '1', 'action' => 'create', 'changes' => []],
|
||||||
|
]);
|
||||||
|
|
||||||
|
self::assertContains($response->getStatusCode(), [404, 405], 'POST doit etre refuse (pas d\'operation d\'ecriture exposee)');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filtre multi-valeurs `entity_type[]=X&entity_type[]=Y` : l'union des
|
||||||
|
* deux types est retournee. On seed 2 types differents (core.User et
|
||||||
|
* core.Role) et on verifie que les deux apparaissent sous notre runTag,
|
||||||
|
* et qu'une valeur non existante (`core.Nonexistent`) n'ajoute rien.
|
||||||
|
*
|
||||||
|
* On interroge avec itemsPerPage=100 pour englober nos 5 lignes quel
|
||||||
|
* que soit le bruit de lignes preexistantes dans audit_log.
|
||||||
|
*/
|
||||||
|
public function testFilterByMultipleEntityTypes(): void
|
||||||
|
{
|
||||||
|
// Seed 2 lignes supplementaires avec un autre entity_type.
|
||||||
|
$this->seedExtraRow('core.Role', '1001', 'create');
|
||||||
|
$this->seedExtraRow('core.Role', '1002', 'update');
|
||||||
|
|
||||||
|
$client = $this->authenticatedClient('admin', 'admin');
|
||||||
|
$response = $client->request('GET', '/api/audit-logs?'.http_build_query([
|
||||||
|
'entity_type' => ['core.User', 'core.Role', 'core.Nonexistent'],
|
||||||
|
'itemsPerPage' => 100,
|
||||||
|
]));
|
||||||
|
|
||||||
|
self::assertSame(200, $response->getStatusCode());
|
||||||
|
$data = $response->toArray();
|
||||||
|
|
||||||
|
// Filtre sur notre runTag pour isoler nos 5 lignes (3 User + 2 Role)
|
||||||
|
// independamment des entrees pre-existantes de la table.
|
||||||
|
$ours = array_values(array_filter(
|
||||||
|
$data['member'],
|
||||||
|
fn (array $m) => ($m['requestId'] ?? null) === $this->runTag,
|
||||||
|
));
|
||||||
|
self::assertCount(5, $ours, 'Les 3 lignes core.User + 2 lignes core.Role doivent etre retournees.');
|
||||||
|
|
||||||
|
$types = array_unique(array_map(fn (array $m) => $m['entityType'], $ours));
|
||||||
|
sort($types);
|
||||||
|
self::assertSame(['core.Role', 'core.User'], $types);
|
||||||
|
|
||||||
|
// Verifier qu'aucune ligne hors filtre n'apparait dans la reponse.
|
||||||
|
foreach ($data['member'] as $member) {
|
||||||
|
self::assertContains($member['entityType'], ['core.User', 'core.Role']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recherche partielle insensible a la casse sur `performed_by` via ILIKE.
|
||||||
|
* Le seed utilise `performed_by=admin` ; on cherche `ADM` pour tester
|
||||||
|
* a la fois la casse et le wildcard contains.
|
||||||
|
*/
|
||||||
|
public function testFilterByPerformedByPartialMatch(): void
|
||||||
|
{
|
||||||
|
$client = $this->authenticatedClient('admin', 'admin');
|
||||||
|
$response = $client->request('GET', '/api/audit-logs?performed_by=ADM&entity_id=999');
|
||||||
|
|
||||||
|
self::assertSame(200, $response->getStatusCode());
|
||||||
|
$data = $response->toArray();
|
||||||
|
$ours = array_filter($data['member'], fn (array $m) => ($m['requestId'] ?? null) === $this->runTag);
|
||||||
|
self::assertGreaterThan(0, count($ours), 'La recherche ILIKE doit matcher "ADM" -> "admin".');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Les caracteres wildcard PostgreSQL (`%`, `_`) saisis par l'utilisateur
|
||||||
|
* doivent etre echappes et traites comme caracteres litteraux, pas comme
|
||||||
|
* des metacaracteres LIKE. Idem pour le backslash qui doit etre double
|
||||||
|
* pour ne pas interferer avec la clause ESCAPE.
|
||||||
|
*/
|
||||||
|
public function testFilterByPerformedByEscapesWildcards(): void
|
||||||
|
{
|
||||||
|
$client = $this->authenticatedClient('admin', 'admin');
|
||||||
|
|
||||||
|
// `%` seul doit matcher 0 ligne (personne n'a `%` dans performed_by).
|
||||||
|
$response = $client->request('GET', '/api/audit-logs?performed_by=%25&entity_id=999');
|
||||||
|
self::assertSame(200, $response->getStatusCode());
|
||||||
|
$data = $response->toArray();
|
||||||
|
$ours = array_filter($data['member'], fn (array $m) => ($m['requestId'] ?? null) === $this->runTag);
|
||||||
|
self::assertCount(0, $ours, '% doit etre traite comme literal, pas wildcard.');
|
||||||
|
|
||||||
|
// `_` seul (wildcard single-char en LIKE) doit aussi matcher 0 ligne.
|
||||||
|
$response = $client->request('GET', '/api/audit-logs?performed_by=_&entity_id=999');
|
||||||
|
self::assertSame(200, $response->getStatusCode());
|
||||||
|
$data = $response->toArray();
|
||||||
|
$ours = array_filter($data['member'], fn (array $m) => ($m['requestId'] ?? null) === $this->runTag);
|
||||||
|
self::assertCount(0, $ours, '_ doit etre traite comme literal, pas wildcard single-char.');
|
||||||
|
|
||||||
|
// `\` (backslash) dans le motif ne doit pas casser la clause ESCAPE :
|
||||||
|
// on attend une reponse 200 (pas 500), meme si le resultat est vide.
|
||||||
|
$response = $client->request('GET', '/api/audit-logs?performed_by=%5C&entity_id=999');
|
||||||
|
self::assertSame(200, $response->getStatusCode(), 'Un backslash dans le filtre ne doit pas produire de 500.');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* L'endpoint `/api/audit-log-entity-types` retourne la liste des valeurs
|
||||||
|
* distinctes de `entity_type` presentes dans la table. La presence du
|
||||||
|
* seed runTag garantit au moins `core.User`.
|
||||||
|
*/
|
||||||
|
public function testEntityTypesEndpointReturnsDistinctTypes(): void
|
||||||
|
{
|
||||||
|
$client = $this->authenticatedClient('admin', 'admin');
|
||||||
|
$response = $client->request('GET', '/api/audit-log-entity-types');
|
||||||
|
|
||||||
|
self::assertSame(200, $response->getStatusCode());
|
||||||
|
$data = $response->toArray();
|
||||||
|
self::assertArrayHasKey('entityTypes', $data);
|
||||||
|
self::assertIsArray($data['entityTypes']);
|
||||||
|
self::assertContains('core.User', $data['entityTypes']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testEntityTypesEndpointRequiresPermission(): void
|
||||||
|
{
|
||||||
|
$credentials = $this->createUserWithPermission('core.users.view');
|
||||||
|
$client = $this->authenticatedClient($credentials['username'], $credentials['password']);
|
||||||
|
$response = $client->request('GET', '/api/audit-log-entity-types');
|
||||||
|
|
||||||
|
self::assertSame(403, $response->getStatusCode());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper interne pour seeder une ligne additionnelle avec un entity_type
|
||||||
|
* arbitraire, taggee runTag pour nettoyage en tearDown.
|
||||||
|
*/
|
||||||
|
private function seedExtraRow(string $entityType, string $entityId, string $action): void
|
||||||
|
{
|
||||||
|
$this->auditConnection->insert('audit_log', [
|
||||||
|
'id' => Uuid::v7()->toRfc4122(),
|
||||||
|
'entity_type' => $entityType,
|
||||||
|
'entity_id' => $entityId,
|
||||||
|
'action' => $action,
|
||||||
|
'changes' => json_encode(['field' => ['old' => 1, 'new' => 2]], JSON_THROW_ON_ERROR),
|
||||||
|
'performed_by' => 'admin',
|
||||||
|
'performed_at' => new DateTimeImmutable('now', new DateTimeZone('UTC'))->format('Y-m-d H:i:sO'),
|
||||||
|
'ip_address' => null,
|
||||||
|
'request_id' => $this->runTag,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Insere 3 lignes temoins taggees avec le runTag pour un nettoyage sur.
|
||||||
|
*/
|
||||||
|
private function seedAuditLog(): void
|
||||||
|
{
|
||||||
|
$now = new DateTimeImmutable('now', new DateTimeZone('UTC'));
|
||||||
|
|
||||||
|
$fixtures = [
|
||||||
|
[
|
||||||
|
'entity_type' => 'core.User',
|
||||||
|
'entity_id' => '999',
|
||||||
|
'action' => 'update',
|
||||||
|
'changes' => ['isAdmin' => ['old' => false, 'new' => true]],
|
||||||
|
'performed_by' => 'admin',
|
||||||
|
// Offsets faibles (secondes) : garantit que les 3 lignes
|
||||||
|
// restent parmi les plus recentes de audit_log meme quand la
|
||||||
|
// table contient plusieurs centaines de lignes historiques.
|
||||||
|
'performed_at' => $now->modify('-2 seconds'),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'entity_type' => 'core.User',
|
||||||
|
'entity_id' => '999',
|
||||||
|
'action' => 'update',
|
||||||
|
'changes' => ['username' => ['old' => 'x', 'new' => 'y']],
|
||||||
|
'performed_by' => 'admin',
|
||||||
|
'performed_at' => $now->modify('-1 second'),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'entity_type' => 'core.User',
|
||||||
|
'entity_id' => '999',
|
||||||
|
'action' => 'delete',
|
||||||
|
'changes' => ['username' => 'y'],
|
||||||
|
'performed_by' => 'admin',
|
||||||
|
'performed_at' => $now,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($fixtures as $row) {
|
||||||
|
$this->auditConnection->insert('audit_log', [
|
||||||
|
'id' => Uuid::v7()->toRfc4122(),
|
||||||
|
'entity_type' => $row['entity_type'],
|
||||||
|
'entity_id' => $row['entity_id'],
|
||||||
|
'action' => $row['action'],
|
||||||
|
'changes' => json_encode($row['changes'], JSON_THROW_ON_ERROR),
|
||||||
|
'performed_by' => $row['performed_by'],
|
||||||
|
'performed_at' => $row['performed_at']->format('Y-m-d H:i:sO'),
|
||||||
|
'ip_address' => null,
|
||||||
|
'request_id' => $this->runTag,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,6 +21,8 @@ use PHPUnit\Framework\TestCase;
|
|||||||
use ReflectionClass;
|
use ReflectionClass;
|
||||||
use stdClass;
|
use stdClass;
|
||||||
use Symfony\Bundle\SecurityBundle\Security;
|
use Symfony\Bundle\SecurityBundle\Security;
|
||||||
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\RequestStack;
|
||||||
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -38,6 +40,7 @@ final class UserRbacProcessorTest extends TestCase
|
|||||||
private MockObject&UnitOfWork $unitOfWork;
|
private MockObject&UnitOfWork $unitOfWork;
|
||||||
private MockObject&Security $security;
|
private MockObject&Security $security;
|
||||||
private AdminHeadcountGuardInterface&MockObject $adminHeadcountGuard;
|
private AdminHeadcountGuardInterface&MockObject $adminHeadcountGuard;
|
||||||
|
private RequestStack $requestStack;
|
||||||
private UserRbacProcessor $processor;
|
private UserRbacProcessor $processor;
|
||||||
|
|
||||||
protected function setUp(): void
|
protected function setUp(): void
|
||||||
@@ -48,6 +51,12 @@ final class UserRbacProcessorTest extends TestCase
|
|||||||
$this->security = $this->createMock(Security::class);
|
$this->security = $this->createMock(Security::class);
|
||||||
$this->adminHeadcountGuard = $this->createMock(AdminHeadcountGuardInterface::class);
|
$this->adminHeadcountGuard = $this->createMock(AdminHeadcountGuardInterface::class);
|
||||||
|
|
||||||
|
// Request vide par defaut pour les tests existants : la garde
|
||||||
|
// anti-ecrasement (restoreAbsentCollections) no-op quand le body est ''
|
||||||
|
// donc elle n'interfere pas avec les assertions deja en place.
|
||||||
|
$this->requestStack = new RequestStack();
|
||||||
|
$this->requestStack->push(new Request());
|
||||||
|
|
||||||
$this->entityManager->method('getUnitOfWork')->willReturn($this->unitOfWork);
|
$this->entityManager->method('getUnitOfWork')->willReturn($this->unitOfWork);
|
||||||
|
|
||||||
// wrapInTransaction doit executer reellement la closure pour que le
|
// wrapInTransaction doit executer reellement la closure pour que le
|
||||||
@@ -63,6 +72,7 @@ final class UserRbacProcessorTest extends TestCase
|
|||||||
$this->entityManager,
|
$this->entityManager,
|
||||||
$this->security,
|
$this->security,
|
||||||
$this->adminHeadcountGuard,
|
$this->adminHeadcountGuard,
|
||||||
|
$this->requestStack,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
163
tests/Module/Core/Infrastructure/Audit/AuditLogWriterTest.php
Normal file
163
tests/Module/Core/Infrastructure/Audit/AuditLogWriterTest.php
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Module\Core\Infrastructure\Audit;
|
||||||
|
|
||||||
|
use App\Module\Core\Infrastructure\Audit\AuditLogWriter;
|
||||||
|
use App\Module\Core\Infrastructure\Audit\RequestIdProvider;
|
||||||
|
use Doctrine\DBAL\Connection;
|
||||||
|
use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use Symfony\Bundle\SecurityBundle\Security;
|
||||||
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\RequestStack;
|
||||||
|
use Symfony\Component\Security\Core\User\InMemoryUser;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests unitaires de l'AuditLogWriter.
|
||||||
|
*
|
||||||
|
* Verifie les invariants critiques :
|
||||||
|
* - filtrage des cles sensibles (defense-in-depth par rapport a #[AuditIgnore]) ;
|
||||||
|
* - utilisation du username courant ou "system" en CLI ;
|
||||||
|
* - captation IP + request_id si requete HTTP presente ;
|
||||||
|
* - generation d'un UUID v7 (tri chronologique implicite en PK).
|
||||||
|
*
|
||||||
|
* Aucune BDD : la connexion DBAL est mockee pour capturer l'insert.
|
||||||
|
*
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
#[AllowMockObjectsWithoutExpectations]
|
||||||
|
final class AuditLogWriterTest extends TestCase
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @var null|array{0: string, 1: array<string, mixed>, 2: array<string, mixed>}
|
||||||
|
*
|
||||||
|
* Capture de l'appel `insert()` : [$table, $data, $types]
|
||||||
|
*/
|
||||||
|
private ?array $capturedInsert = null;
|
||||||
|
|
||||||
|
private Connection $connection;
|
||||||
|
|
||||||
|
private RequestStack $requestStack;
|
||||||
|
|
||||||
|
private RequestIdProvider $requestIdProvider;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
$this->capturedInsert = null;
|
||||||
|
|
||||||
|
$this->connection = $this->createMock(Connection::class);
|
||||||
|
$this->connection
|
||||||
|
->method('insert')
|
||||||
|
->willReturnCallback(function (string $table, array $data, array $types = []): int {
|
||||||
|
$this->capturedInsert = [$table, $data, $types];
|
||||||
|
|
||||||
|
return 1;
|
||||||
|
})
|
||||||
|
;
|
||||||
|
|
||||||
|
$this->requestStack = new RequestStack();
|
||||||
|
$this->requestIdProvider = new RequestIdProvider();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testLogsCreateWithAuthenticatedUser(): void
|
||||||
|
{
|
||||||
|
$security = $this->buildSecurityWithUser('alice');
|
||||||
|
$writer = new AuditLogWriter($this->connection, $security, $this->requestStack, $this->requestIdProvider);
|
||||||
|
|
||||||
|
$writer->log('core.User', '42', 'create', ['username' => 'alice']);
|
||||||
|
|
||||||
|
$this->assertNotNull($this->capturedInsert);
|
||||||
|
[$table, $data] = $this->capturedInsert;
|
||||||
|
$this->assertSame('audit_log', $table);
|
||||||
|
$this->assertSame('core.User', $data['entity_type']);
|
||||||
|
$this->assertSame('42', $data['entity_id']);
|
||||||
|
$this->assertSame('create', $data['action']);
|
||||||
|
$this->assertSame(['username' => 'alice'], $data['changes']);
|
||||||
|
$this->assertSame('alice', $data['performed_by']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testUsesSystemWhenNoAuthenticatedUser(): void
|
||||||
|
{
|
||||||
|
$security = $this->buildSecurityWithUser(null);
|
||||||
|
$writer = new AuditLogWriter($this->connection, $security, $this->requestStack, $this->requestIdProvider);
|
||||||
|
|
||||||
|
$writer->log('core.User', '1', 'update', ['isAdmin' => ['old' => false, 'new' => true]]);
|
||||||
|
|
||||||
|
$this->assertSame('system', $this->capturedInsert[1]['performed_by']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testStripsSensitiveKeys(): void
|
||||||
|
{
|
||||||
|
$security = $this->buildSecurityWithUser('alice');
|
||||||
|
$writer = new AuditLogWriter($this->connection, $security, $this->requestStack, $this->requestIdProvider);
|
||||||
|
|
||||||
|
$writer->log('core.User', '1', 'create', [
|
||||||
|
'username' => 'bob',
|
||||||
|
'password' => 'topsecrethash',
|
||||||
|
'plainPassword' => 'clear',
|
||||||
|
'token' => 'abc',
|
||||||
|
'secret' => 'xyz',
|
||||||
|
'email' => 'bob@example.com',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$changes = $this->capturedInsert[1]['changes'];
|
||||||
|
$this->assertArrayNotHasKey('password', $changes);
|
||||||
|
$this->assertArrayNotHasKey('plainPassword', $changes);
|
||||||
|
$this->assertArrayNotHasKey('token', $changes);
|
||||||
|
$this->assertArrayNotHasKey('secret', $changes);
|
||||||
|
$this->assertSame('bob', $changes['username']);
|
||||||
|
$this->assertSame('bob@example.com', $changes['email']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testCapturesIpAddressWhenRequestPresent(): void
|
||||||
|
{
|
||||||
|
$request = Request::create('/api/users', 'POST');
|
||||||
|
$request->server->set('REMOTE_ADDR', '203.0.113.42');
|
||||||
|
$this->requestStack->push($request);
|
||||||
|
|
||||||
|
$security = $this->buildSecurityWithUser('alice');
|
||||||
|
$writer = new AuditLogWriter($this->connection, $security, $this->requestStack, $this->requestIdProvider);
|
||||||
|
|
||||||
|
$writer->log('core.User', '1', 'create', []);
|
||||||
|
|
||||||
|
$this->assertSame('203.0.113.42', $this->capturedInsert[1]['ip_address']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testIpAddressNullInCli(): void
|
||||||
|
{
|
||||||
|
$security = $this->buildSecurityWithUser(null);
|
||||||
|
$writer = new AuditLogWriter($this->connection, $security, $this->requestStack, $this->requestIdProvider);
|
||||||
|
|
||||||
|
$writer->log('core.User', '1', 'create', []);
|
||||||
|
|
||||||
|
$this->assertNull($this->capturedInsert[1]['ip_address']);
|
||||||
|
$this->assertNull($this->capturedInsert[1]['request_id']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testGeneratesUuidV7PrimaryKey(): void
|
||||||
|
{
|
||||||
|
$security = $this->buildSecurityWithUser('alice');
|
||||||
|
$writer = new AuditLogWriter($this->connection, $security, $this->requestStack, $this->requestIdProvider);
|
||||||
|
|
||||||
|
$writer->log('core.User', '1', 'create', []);
|
||||||
|
|
||||||
|
$id = $this->capturedInsert[1]['id'];
|
||||||
|
// UUID v7 : le 13e caractere (apres les tirets) vaut "7".
|
||||||
|
// Format : xxxxxxxx-xxxx-7xxx-xxxx-xxxxxxxxxxxx
|
||||||
|
$this->assertMatchesRegularExpression(
|
||||||
|
'/^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[0-9a-f]{4}-[0-9a-f]{12}$/i',
|
||||||
|
$id
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildSecurityWithUser(?string $username): Security
|
||||||
|
{
|
||||||
|
$security = $this->createMock(Security::class);
|
||||||
|
$user = null !== $username ? new InMemoryUser($username, 'pwd') : null;
|
||||||
|
$security->method('getUser')->willReturn($user);
|
||||||
|
|
||||||
|
return $security;
|
||||||
|
}
|
||||||
|
}
|
||||||
367
tests/Module/Core/Infrastructure/Doctrine/AuditListenerTest.php
Normal file
367
tests/Module/Core/Infrastructure/Doctrine/AuditListenerTest.php
Normal file
@@ -0,0 +1,367 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Module\Core\Infrastructure\Doctrine;
|
||||||
|
|
||||||
|
use App\Module\Core\Domain\Entity\Permission;
|
||||||
|
use App\Module\Core\Domain\Entity\Role;
|
||||||
|
use App\Module\Core\Domain\Entity\User;
|
||||||
|
use Doctrine\DBAL\Connection;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
|
||||||
|
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests d'integration de l'AuditListener.
|
||||||
|
*
|
||||||
|
* Contrairement aux tests unitaires du writer, on fait tourner le kernel
|
||||||
|
* complet pour verifier que le listener est bien cable et que les attributs
|
||||||
|
* #[Auditable] / #[AuditIgnore] sur User sont respectes jusqu'a l'insert
|
||||||
|
* final dans audit_log.
|
||||||
|
*
|
||||||
|
* Strategie de nettoyage : chaque test supprime ses fixtures dans tearDown
|
||||||
|
* (pas de rollback transactionnel DAMA sur ce projet).
|
||||||
|
*
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
final class AuditListenerTest extends KernelTestCase
|
||||||
|
{
|
||||||
|
private EntityManagerInterface $em;
|
||||||
|
|
||||||
|
private Connection $auditConnection;
|
||||||
|
|
||||||
|
/** @var list<int> IDs de users crees par le test (nettoyage en tearDown) */
|
||||||
|
private array $createdUserIds = [];
|
||||||
|
|
||||||
|
/** @var list<int> IDs de roles crees par le test (nettoyage en tearDown) */
|
||||||
|
private array $createdRoleIds = [];
|
||||||
|
|
||||||
|
private string $testRunTag;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
self::bootKernel();
|
||||||
|
|
||||||
|
/** @var EntityManagerInterface $em */
|
||||||
|
$em = self::getContainer()->get('doctrine')->getManager();
|
||||||
|
$this->em = $em;
|
||||||
|
|
||||||
|
/** @var Connection $conn */
|
||||||
|
$conn = self::getContainer()->get('doctrine.dbal.audit_connection');
|
||||||
|
$this->auditConnection = $conn;
|
||||||
|
|
||||||
|
// Tag unique par run pour filtrer les lignes audit_log produites
|
||||||
|
// exclusivement par ce test (la table n'a ni truncate ni rollback).
|
||||||
|
$this->testRunTag = 'audittest'.bin2hex(random_bytes(4));
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function tearDown(): void
|
||||||
|
{
|
||||||
|
// Suppression explicite des users crees (cascade sur user_role /
|
||||||
|
// user_site via les ORM mappings) + nettoyage des lignes audit
|
||||||
|
// correspondantes pour ne pas polluer les runs suivants.
|
||||||
|
if ([] !== $this->createdUserIds) {
|
||||||
|
foreach ($this->createdUserIds as $id) {
|
||||||
|
$user = $this->em->find(User::class, $id);
|
||||||
|
if (null !== $user) {
|
||||||
|
$this->em->remove($user);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$this->em->flush();
|
||||||
|
}
|
||||||
|
|
||||||
|
if ([] !== $this->createdRoleIds) {
|
||||||
|
foreach ($this->createdRoleIds as $id) {
|
||||||
|
$role = $this->em->find(Role::class, $id);
|
||||||
|
if (null !== $role) {
|
||||||
|
$this->em->remove($role);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$this->em->flush();
|
||||||
|
// Nettoie egalement les lignes audit de ces roles (entity_id est
|
||||||
|
// une colonne text, on delete en boucle pour simplifier le binding).
|
||||||
|
foreach ($this->createdRoleIds as $id) {
|
||||||
|
$this->auditConnection->executeStatement(
|
||||||
|
'DELETE FROM audit_log WHERE entity_type = \'core.Role\' AND entity_id = :id',
|
||||||
|
['id' => (string) $id],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->auditConnection->executeStatement(
|
||||||
|
"DELETE FROM audit_log WHERE entity_type = 'core.User' AND changes->>'username' LIKE :tag",
|
||||||
|
['tag' => $this->testRunTag.'%'],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Libere la connexion PG : en test, le kernel reboote par test et
|
||||||
|
// sans close explicite, la connexion `audit` reste ouverte jusqu'au
|
||||||
|
// TTL Doctrine, saturant le pool sur une suite de 200+ tests.
|
||||||
|
$this->auditConnection->close();
|
||||||
|
|
||||||
|
parent::tearDown();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testLogsCreateOnUserInsertion(): void
|
||||||
|
{
|
||||||
|
$user = $this->makeUser();
|
||||||
|
|
||||||
|
$this->em->persist($user);
|
||||||
|
$this->em->flush();
|
||||||
|
$this->createdUserIds[] = $user->getId();
|
||||||
|
|
||||||
|
$rows = $this->fetchAuditRows($user->getId());
|
||||||
|
|
||||||
|
$this->assertCount(1, $rows, 'Une ligne audit attendue a la creation');
|
||||||
|
$row = $rows[0];
|
||||||
|
$this->assertSame('core.User', $row['entity_type']);
|
||||||
|
$this->assertSame('create', $row['action']);
|
||||||
|
$this->assertSame((string) $user->getId(), $row['entity_id']);
|
||||||
|
|
||||||
|
$changes = json_decode($row['changes'], true, 512, JSON_THROW_ON_ERROR);
|
||||||
|
$this->assertArrayHasKey('username', $changes);
|
||||||
|
$this->assertArrayNotHasKey('password', $changes, 'password doit etre #[AuditIgnore]');
|
||||||
|
$this->assertArrayNotHasKey('plainPassword', $changes, 'plainPassword doit etre #[AuditIgnore]');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testLogsUpdateWithDiff(): void
|
||||||
|
{
|
||||||
|
$user = $this->makeUser();
|
||||||
|
$this->em->persist($user);
|
||||||
|
$this->em->flush();
|
||||||
|
$this->createdUserIds[] = $user->getId();
|
||||||
|
|
||||||
|
// Reset de la baseline : on ne garde que la ligne update.
|
||||||
|
$this->auditConnection->executeStatement(
|
||||||
|
'DELETE FROM audit_log WHERE entity_id = :id AND entity_type = \'core.User\'',
|
||||||
|
['id' => (string) $user->getId()],
|
||||||
|
);
|
||||||
|
|
||||||
|
$user->setIsAdmin(true);
|
||||||
|
$this->em->flush();
|
||||||
|
|
||||||
|
$rows = $this->fetchAuditRows($user->getId());
|
||||||
|
$this->assertCount(1, $rows);
|
||||||
|
$this->assertSame('update', $rows[0]['action']);
|
||||||
|
|
||||||
|
$changes = json_decode($rows[0]['changes'], true, 512, JSON_THROW_ON_ERROR);
|
||||||
|
$this->assertArrayHasKey('isAdmin', $changes);
|
||||||
|
$this->assertSame(false, $changes['isAdmin']['old']);
|
||||||
|
$this->assertSame(true, $changes['isAdmin']['new']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testLogsDeleteSnapshot(): void
|
||||||
|
{
|
||||||
|
$user = $this->makeUser();
|
||||||
|
$this->em->persist($user);
|
||||||
|
$this->em->flush();
|
||||||
|
$userId = $user->getId();
|
||||||
|
|
||||||
|
$this->em->remove($user);
|
||||||
|
$this->em->flush();
|
||||||
|
|
||||||
|
$rows = $this->fetchAuditRows($userId);
|
||||||
|
// Deux lignes : la creation + la suppression.
|
||||||
|
$actions = array_column($rows, 'action');
|
||||||
|
$this->assertContains('delete', $actions);
|
||||||
|
|
||||||
|
$deleteRow = $rows[array_search('delete', $actions, true)];
|
||||||
|
$changes = json_decode($deleteRow['changes'], true, 512, JSON_THROW_ON_ERROR);
|
||||||
|
$this->assertArrayHasKey('username', $changes);
|
||||||
|
$this->assertArrayNotHasKey('password', $changes);
|
||||||
|
|
||||||
|
// On nettoie a la main les lignes restantes (user deja delete).
|
||||||
|
$this->auditConnection->executeStatement(
|
||||||
|
'DELETE FROM audit_log WHERE entity_id = :id AND entity_type = \'core.User\'',
|
||||||
|
['id' => (string) $userId],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Regression test : une entite recuperee via `getReference()` (proxy /
|
||||||
|
* ghost object lazy) doit etre auditee avec le FQCN canonique. Sur
|
||||||
|
* Doctrine ORM 3 + PHP 8.4, les lazy ghosts preservent `::class` reel
|
||||||
|
* — mais sous Doctrine 2 ou en cas de retour a un `__CG__\` proxy,
|
||||||
|
* l'audit doit toujours resoudre la classe via `ClassMetadata` et
|
||||||
|
* jamais aboutir a un `entity_type` de type `Proxies\__CG__\...\User`.
|
||||||
|
*/
|
||||||
|
public function testLogsUpdateOnProxyEntity(): void
|
||||||
|
{
|
||||||
|
$user = $this->makeUser();
|
||||||
|
$this->em->persist($user);
|
||||||
|
$this->em->flush();
|
||||||
|
$userId = (int) $user->getId();
|
||||||
|
$this->createdUserIds[] = $userId;
|
||||||
|
|
||||||
|
// Detache puis recupere via getReference : sur Doctrine 2, renvoie
|
||||||
|
// un `Proxies\__CG__\...\User` ; sur Doctrine 3 + PHP 8.4 le ghost
|
||||||
|
// object reste instance de la classe reelle — dans tous les cas la
|
||||||
|
// resolution via ClassMetadata doit produire un audit correct.
|
||||||
|
$this->em->clear();
|
||||||
|
|
||||||
|
$proxy = $this->em->getReference(User::class, $userId);
|
||||||
|
self::assertNotNull($proxy);
|
||||||
|
|
||||||
|
// Reset de la baseline : on ne garde que la ligne update du proxy.
|
||||||
|
$this->auditConnection->executeStatement(
|
||||||
|
'DELETE FROM audit_log WHERE entity_id = :id AND entity_type = \'core.User\'',
|
||||||
|
['id' => (string) $userId],
|
||||||
|
);
|
||||||
|
|
||||||
|
$proxy->setIsAdmin(true);
|
||||||
|
$this->em->flush();
|
||||||
|
|
||||||
|
$rows = $this->fetchAuditRows($userId);
|
||||||
|
self::assertCount(1, $rows, 'La mutation sur un proxy doit etre auditee.');
|
||||||
|
self::assertSame('update', $rows[0]['action']);
|
||||||
|
// L'entity_type doit etre le FQCN canonique, pas celui du proxy.
|
||||||
|
self::assertSame('core.User', $rows[0]['entity_type']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verifie que l'ajout d'une permission a un role est bien audite sous
|
||||||
|
* la forme `{permissions: {added: [id], removed: []}}`. Regression test
|
||||||
|
* pour le bug "ManyToMany collections ignorees par getEntityChangeSet".
|
||||||
|
*/
|
||||||
|
public function testLogsManyToManyCollectionAddition(): void
|
||||||
|
{
|
||||||
|
$roleCode = 'audittest_'.bin2hex(random_bytes(3));
|
||||||
|
$role = new Role($roleCode, 'Test role '.$roleCode);
|
||||||
|
$this->em->persist($role);
|
||||||
|
$this->em->flush();
|
||||||
|
$roleId = (int) $role->getId();
|
||||||
|
$this->createdRoleIds[] = $roleId;
|
||||||
|
|
||||||
|
// Reset baseline : on ne veut que le log de l'update de collection.
|
||||||
|
$this->auditConnection->executeStatement(
|
||||||
|
'DELETE FROM audit_log WHERE entity_type = \'core.Role\' AND entity_id = :id',
|
||||||
|
['id' => (string) $roleId],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Recupere une permission existante (fixtures garantissent core.users.view).
|
||||||
|
$permission = $this->em->getRepository(Permission::class)->findOneBy(['code' => 'core.users.view']);
|
||||||
|
self::assertNotNull($permission, 'Fixture core.users.view manquante.');
|
||||||
|
|
||||||
|
$role->addPermission($permission);
|
||||||
|
$this->em->flush();
|
||||||
|
|
||||||
|
$rows = $this->fetchRoleAuditRows($roleId);
|
||||||
|
self::assertCount(1, $rows, 'Une ligne update attendue pour l\'ajout de permission.');
|
||||||
|
self::assertSame('update', $rows[0]['action']);
|
||||||
|
|
||||||
|
$changes = json_decode($rows[0]['changes'], true, 512, JSON_THROW_ON_ERROR);
|
||||||
|
self::assertArrayHasKey('permissions', $changes, 'Le changeset doit contenir le champ "permissions".');
|
||||||
|
self::assertSame([], $changes['permissions']['removed']);
|
||||||
|
self::assertSame([(int) $permission->getId()], $changes['permissions']['added']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Symetrique : retirer une permission d'un role est audite sous
|
||||||
|
* `{permissions: {added: [], removed: [id]}}`.
|
||||||
|
*/
|
||||||
|
public function testLogsManyToManyCollectionRemoval(): void
|
||||||
|
{
|
||||||
|
$permission = $this->em->getRepository(Permission::class)->findOneBy(['code' => 'core.users.view']);
|
||||||
|
self::assertNotNull($permission);
|
||||||
|
|
||||||
|
$roleCode = 'audittest_'.bin2hex(random_bytes(3));
|
||||||
|
$role = new Role($roleCode, 'Test role '.$roleCode);
|
||||||
|
$role->addPermission($permission);
|
||||||
|
$this->em->persist($role);
|
||||||
|
$this->em->flush();
|
||||||
|
$roleId = (int) $role->getId();
|
||||||
|
$this->createdRoleIds[] = $roleId;
|
||||||
|
|
||||||
|
// Reset baseline.
|
||||||
|
$this->auditConnection->executeStatement(
|
||||||
|
'DELETE FROM audit_log WHERE entity_type = \'core.Role\' AND entity_id = :id',
|
||||||
|
['id' => (string) $roleId],
|
||||||
|
);
|
||||||
|
|
||||||
|
$role->removePermission($permission);
|
||||||
|
$this->em->flush();
|
||||||
|
|
||||||
|
$rows = $this->fetchRoleAuditRows($roleId);
|
||||||
|
self::assertCount(1, $rows);
|
||||||
|
$changes = json_decode($rows[0]['changes'], true, 512, JSON_THROW_ON_ERROR);
|
||||||
|
self::assertSame([], $changes['permissions']['added']);
|
||||||
|
self::assertSame([(int) $permission->getId()], $changes['permissions']['removed']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Regression test : supprimer un role avec des permissions attachees doit
|
||||||
|
* preserver la liste des permissions dans le snapshot delete. C'etait le
|
||||||
|
* trou principal du fix ManyToMany initial (reviewer Codex round 2).
|
||||||
|
*/
|
||||||
|
public function testDeleteSnapshotIncludesManyToManyIds(): void
|
||||||
|
{
|
||||||
|
$permission = $this->em->getRepository(Permission::class)->findOneBy(['code' => 'core.users.view']);
|
||||||
|
self::assertNotNull($permission);
|
||||||
|
|
||||||
|
$roleCode = 'audittest_'.bin2hex(random_bytes(3));
|
||||||
|
$role = new Role($roleCode, 'Delete test '.$roleCode);
|
||||||
|
$role->addPermission($permission);
|
||||||
|
$this->em->persist($role);
|
||||||
|
$this->em->flush();
|
||||||
|
$roleId = (int) $role->getId();
|
||||||
|
|
||||||
|
$this->em->remove($role);
|
||||||
|
$this->em->flush();
|
||||||
|
|
||||||
|
$rows = $this->fetchRoleAuditRows($roleId);
|
||||||
|
// create + update (permission ajoutee) + delete attendus.
|
||||||
|
$actions = array_column($rows, 'action');
|
||||||
|
self::assertContains('delete', $actions);
|
||||||
|
|
||||||
|
$deleteRow = $rows[array_search('delete', $actions, true)];
|
||||||
|
$changes = json_decode($deleteRow['changes'], true, 512, JSON_THROW_ON_ERROR);
|
||||||
|
|
||||||
|
// Le snapshot delete doit contenir la liste des IDs de permissions
|
||||||
|
// attachees au role au moment de la suppression.
|
||||||
|
self::assertArrayHasKey('permissions', $changes);
|
||||||
|
self::assertSame([(int) $permission->getId()], $changes['permissions']);
|
||||||
|
|
||||||
|
// Nettoyage manuel (le role est deja supprime, on ne peut plus passer par $this->em).
|
||||||
|
$this->auditConnection->executeStatement(
|
||||||
|
'DELETE FROM audit_log WHERE entity_type = \'core.Role\' AND entity_id = :id',
|
||||||
|
['id' => (string) $roleId],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<array{id: string, entity_type: string, entity_id: string, action: string, changes: string}>
|
||||||
|
*/
|
||||||
|
private function fetchAuditRows(int $userId): array
|
||||||
|
{
|
||||||
|
// @var list<array{id: string, entity_type: string, entity_id: string, action: string, changes: string}> $rows
|
||||||
|
return $this->auditConnection->fetchAllAssociative(
|
||||||
|
'SELECT id, entity_type, entity_id, action, changes FROM audit_log WHERE entity_type = :type AND entity_id = :id ORDER BY performed_at ASC',
|
||||||
|
['type' => 'core.User', 'id' => (string) $userId],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function makeUser(): User
|
||||||
|
{
|
||||||
|
/** @var UserPasswordHasherInterface $hasher */
|
||||||
|
$hasher = self::getContainer()->get(UserPasswordHasherInterface::class);
|
||||||
|
|
||||||
|
$user = new User();
|
||||||
|
$user->setUsername($this->testRunTag.'_'.bin2hex(random_bytes(2)));
|
||||||
|
$user->setIsAdmin(false);
|
||||||
|
$user->setPassword($hasher->hashPassword($user, 'testpass'));
|
||||||
|
|
||||||
|
return $user;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<array{id: string, entity_type: string, entity_id: string, action: string, changes: string}>
|
||||||
|
*/
|
||||||
|
private function fetchRoleAuditRows(int $roleId): array
|
||||||
|
{
|
||||||
|
// @var list<array{id: string, entity_type: string, entity_id: string, action: string, changes: string}> $rows
|
||||||
|
return $this->auditConnection->fetchAllAssociative(
|
||||||
|
'SELECT id, entity_type, entity_id, action, changes FROM audit_log WHERE entity_type = :type AND entity_id = :id ORDER BY performed_at ASC',
|
||||||
|
['type' => 'core.Role', 'id' => (string) $roleId],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user