diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml index c1faac741..7248f8a2a 100644 --- a/docs/mkdocs.yml +++ b/docs/mkdocs.yml @@ -86,6 +86,7 @@ nav: - development/index.md - Access Control: development/access_control_model.md - API: development/api.md + - Audit: development/audit.md - Assets: development/assets.md - Code Quality: development/code-quality.md - Create Bundle: development/create-a-new-bundle.md diff --git a/docs/source/development/audit.md b/docs/source/development/audit.md new file mode 100644 index 000000000..4a09bfa06 --- /dev/null +++ b/docs/source/development/audit.md @@ -0,0 +1,212 @@ +# Audit — developer guide + +This page explains how to trigger an audit from your code, recommended practices, how subjects are converted and displayed, and how the whole pipeline persists an `AuditTrail`. + +## Triggering an audit + +Audits are triggered through the `Chill\MainBundle\Audit\TriggerAuditInterface` service. It exposes a single method and is also invokable: + +```php +use Chill\MainBundle\Audit\TriggerAuditInterface; +use Chill\MainBundle\Entity\AuditTrail; // for action constants +use Symfony\Component\Translation\TranslatableMessage; + +final class MyController +{ + public function __construct(private readonly TriggerAuditInterface $triggerAudit) {} + + public function someAction(MyEntity $entity): Response + { + // ... persist/flush $entity so that its id is available + + $this->triggerAudit->triggerAudit( + AuditTrail::AUDIT_UPDATE, // $action + $entity, // $mainSubject (object) + subjects: [$entity->getOwner()], // optional related subjects (array of objects) + description: new TranslatableMessage('audit.entity.updated'), // optional description + metadata: ['field' => 'value'] // optional machine-readable extras + ); + + // equivalently (because the service is invokable): + ($this->triggerAudit)(AuditTrail::AUDIT_UPDATE, $entity); + + // ... + } +} +``` + +- `$action`: an action label such as `AuditTrail::AUDIT_CREATE`, `AUDIT_UPDATE`, `AUDIT_DELETE`, `AUDIT_VIEW`, `AUDIT_LIST`, ... +- `$mainSubject`: the primary domain object the action is about. +- `$subjects`: additional related domain objects to attach to the audit (optional). The always-related subject should be added through the converter. +- `$description`: a short human-readable text, already translated. You may pass a plain string or any `TranslatableInterface` (e.g. `TranslatableMessage`). +- `$metadata`: free-form associative array to add machine-readable context (ids, filters, counters, etc.). + +!!! warning + + The id of every subject must be set when you trigger the audit. In practice this means: + - trigger audits after persist operations (once Doctrine assigned an id, i.e. after `flush()` when needed), and + - trigger audits before delete operations (while the id is still available). + +See concrete usages in controllers such as: +- `Chill\PersonBundle\Controller\AccompanyingCourseApiController` (multiple calls to `$this->triggerAudit->triggerAudit(...)`). + +Example extracted and simplified from `AccompanyingCourseApiController`: + +```php +$this->managerRegistry->getManager()->flush(); // ensure ids are set + +$this->triggerAudit->triggerAudit( + AuditTrail::AUDIT_UPDATE, + $accompanyingPeriod, + description: new TranslatableMessage('audit.accompanying_period.set_requestor'), + metadata: ['t' => $accompanyingPeriod->getRequestorKind(), 'requestor' => $accompanyingPeriod->getRequestor()->getId()], +); +``` + +### Best practices + +- Audit every create, update, delete, view and list action on domain entities. +- Use only the `$action` when it is obvious and self-explanatory. +- Use the `$description` when the action alone is not enough. For example for a list, the main subject is the person (or the accompanying period) the list is attached to, and the description should summarize the list content (filters, ranges, counts, …). +- Add `$metadata` to provide extra, machine-friendly details (ids, filters, pagination, counts, etc.). +- Reuse the existing patterns found in current controllers and services. + +Examples: + +```php +// CREATE a person +($this->triggerAudit)(AuditTrail::AUDIT_CREATE, $person); + +// VIEW a person details +($this->triggerAudit)(AuditTrail::AUDIT_VIEW, $person); + +// LIST resources attached to an accompanying period with filters in metadata +($this->triggerAudit)( + AuditTrail::AUDIT_LIST, + $accompanyingPeriod, + description: 'Listing resources for the current period', + metadata: ['scope' => 'resources', 'filters' => ['category' => 'housing', 'page' => 1]] +); +``` + + +## Use new entities in Audits + +### Creating a new SubjectConverter + +Subjects are normalized into a serializable `Subject` model using `Chill\MainBundle\Audit\SubjectConverterInterface`. +Implement a converter when you introduce a new domain type you want to appear in audits. + +Interface: + +```php +interface SubjectConverterInterface +{ + /** + * @param object $subject + */ + public function convert(mixed $subject, bool $includeAssociated = false): SubjectBag; + + /** @phpstan-assert-if-true T $subject */ + public function supportsConvert(mixed $subject): bool; + + public static function getDefaultPriority(): int; +} +``` + +Typical implementation skeleton: + +```php +use Chill\MainBundle\Audit\{Subject, SubjectBag, SubjectConverterInterface, SubjectConverterManagerAwareInterface, SubjectConverterManagerAwareTrait}; + +final class MyEntitySubjectConverter implements SubjectConverterInterface, SubjectConverterManagerAwareInterface +{ + use SubjectConverterManagerAwareTrait; + + public function supportsConvert(mixed $subject): bool + { + return $subject instanceof MyEntity; + } + + public function convert(mixed $subject, bool $includeAssociated = false): SubjectBag + { + \assert($subject instanceof MyEntity); + + $main = new SubjectBag( + new Subject( + type: 'my_entity', + identifiers: ['id' => $subject->getId()], + ) + ); + + $associated = []; + if ($includeAssociated && null !== $subject->getOwner()) { + // we delegate the conversion of the owner to the manager + $main->append($this->subjectConverterManager->getSubjectsForEntity($subject->getOwner(), false)); + } + + return new SubjectBag($main, $associated); + } + + public static function getDefaultPriority(): int + { + return 0; // higher value = earlier pick when multiple converters support the same type + } +} +``` + +- `SubjectBag` groups the primary `Subject` and optional associated `Subject`s. +- Converters are discovered and composed by `SubjectConverterManagerInterface`. +- Implement `SubjectConverterManagerAwareInterface` (and use `SubjectConverterManagerAwareTrait`) if your converter needs to delegate the conversion of associated entities to the manager (useful if there is already a converter for that entity). + +### Creating a new SubjectDisplayer + +To control how a `Subject` is rendered in the UI (HTML or plain string), implement `Chill\MainBundle\Audit\SubjectDisplayerInterface`: + +```php +interface SubjectDisplayerInterface +{ + public function supportsDisplay(Subject $subject, array $options = []): bool; + + /** @param 'html'|'string' $format */ + public function display(Subject $subject, string $format = 'html', array $options = []): string; +} +``` + +Example: see `Chill\PersonBundle\Audit\Displayer\HouseholdCompositionDisplayer`. A simplified version: + +```php +use Chill\MainBundle\Audit\{Subject, SubjectDisplayerInterface}; + +final class MyEntityDisplayer implements SubjectDisplayerInterface +{ + public function supportsDisplay(Subject $subject, array $options = []): bool + { + return 'my_entity' === $subject->type; + } + + public function display(Subject $subject, string $format = 'html', array $options = []): string + { + $label = sprintf('#%d %s', $subject->identifiers['id'], $subject->labels['name'] ?? ''); + return 'html' === $format ? ''.$label.'' : $label; // <-- here, be aware that there is no xss risk. If any, use twig. + } +} +``` + +!!! warning + + Use twig to render html format if there is a risk for xss injection. + +## How it works internally + +1. Storage: every audit is persisted as an `AuditTrail` entity. +2. Subject conversion: the main subject and all additional subjects are converted to `Subject` models using `SubjectConverterInterface` implementations, orchestrated by `SubjectConverterManagerInterface`. +3. Display: UI rendering uses `SubjectDisplayerInterface` implementations to render `Subject`s as HTML or strings. +4. Persistence: `TriggerAuditInterface` builds an `AuditEvent`, converts it to an `AuditTrail` via `AuditEvent2TrailInterface`, and persists it through the audit persistence layer (`AuditTrailPersisterInterface`, i.e. the persistence interface behind the service). There is no need to call `EntityManager::flush()` for the audit itself, so it is fast; however you must trigger the audit when the subject ids are available (see warning above). + +See also: +- `Chill\MainBundle\Audit\TriggerAuditService` +- `Chill\MainBundle\Audit\AuditEvent2Trail` +- `Chill\MainBundle\Audit\AuditTrailPersister` +- `Chill\MainBundle\Audit\SubjectConverterManagerInterface` +- `Chill\PersonBundle\Audit\Displayer\HouseholdCompositionDisplayer`