Add audit documentation to developer guide

- Added `audit.md` to explain how to trigger audits, best practices, and internal architecture.
- Updated `mkdocs.yml` to include the new "Audit" section in the developer guide.
This commit is contained in:
2026-02-27 15:18:55 +01:00
parent 96b690b75b
commit 8d145e3b58
2 changed files with 213 additions and 0 deletions

View File

@@ -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

View File

@@ -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 ? '<span>'.$label.'</span>' : $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`