Anonymization — developer guide¶
Chill anonymizes the file of a person once nothing has happened on it for a configurable delay. This page describes the pipeline, the four extension points every bundle is expected to implement for the data it owns, and the pitfalls.
Configuration¶
The delay is subtracted from "now" to obtain the pivot date. Events occurring after the pivot date keep a person out of the candidate list.
Managing the anonymization requires the role CHILL_PERSON_ANONYMISATION_MANAGER
(AnonymisationManagerVoter). It is not scoped, but the candidate list and the launch are
restricted to the centers where the user holds the role (AnonymizationCenterResolver).
The pipeline¶
┌─ sync-anonymization-list (cron, weekly) ─┐
│ AnonymizationListCandidateManager │
candidates ◄───┤ ∀ AnonymizationQueryCandidateProvider │
└──────────────────────────────────────────┘
│
a manager launches the anonymization (UI)
│
AnonymizationLauncher::launch()
│ dispatches one AnonymizationRequest per person
▼
AnonymizationRequestHandler
│
AnonymizationProcessorManager::anonymize()
┌──────────────┴───────────────┐
│ ONE transaction: │
│ ∀ AnonymizationProcessor │
└──────────────┬───────────────┘
COMMIT
│ (outside the transaction)
∀ AnonymizationPostProcessor
│ dispatches AnonymizationCascadeRequest,
│ only for the entities whose participants
│ are now all anonymized
▼
AnonymizationCascadeRequestHandler
│ back to the processor manager,
│ for the period / household / ticket
Both messages are routed to the async transport (in-memory in the test environment).
1. Building the list of candidates¶
SyncAnonymizationListCronJob (key sync-anonymization-list, runs on sundays) calls
AnonymizationListCandidateManager::syncAnonymizationList(). The manager assembles every
AnonymizationQueryCandidateProviderInterface into a single statement:
- a person is inserted into
chill_person.anonymization_candidatewhenNOT EXISTS (…)holds for every provider; - a candidate is deleted when
EXISTS (…)holds for any provider, or when the person is already anonymized.
The list is therefore rebuilt from scratch at every run: a person who becomes active again leaves the list on her own.
2. Launching¶
A manager triggers AnonymizationLauncher::launch() from the candidate list. It dispatches one
AnonymizationRequest per person that may be anonymized immediately — i.e. candidates with no
delay, or whose delay has passed. The anonymization manager (a user) may postpone a candidate by setting a
delay on it (AnonymizationCandidateDelayService).
The handler re-checks the conditions when it consumes the message: the state may have changed between the launch and the consumption.
3. Anonymizing¶
AnonymizationProcessorManager::anonymize() collects the SQL of every processor supporting the
entity and runs it in one transaction. It is all-or-nothing: if a single query fails, the
person is not anonymized at all and reappears in the list at the next synchronization.
4. Cascading¶
An accompanying period, a household or a ticket may only be anonymized once all of its
participants are anonymized — which cannot be decided inside the transaction anonymizing a
single person. Hence the post-processors: after the COMMIT they dispatch one
AnonymizationCascadeRequest per linked entity. Most of these messages are abandoned by their
handler because some participants are still active; the message dispatched after the last
participant is the one that triggers the anonymization.
The three extension points¶
All three are collected by autoconfiguration — implement the interface and put the class in a
directory loaded by the bundle's services.yaml; there is no tag to declare.
| Interface | Tag | Answers |
|---|---|---|
AnonymizationQueryCandidateProviderInterface |
chill_person.anonymization_query_candidate_provider |
"did something happen on this person's file since the pivot date?" |
AnonymizationProcessorInterface |
chill_person.anonymization_processor |
"which SQL anonymizes my data for this entity?" |
AnonymizationPostProcessorInterface |
chill_person.anonymization_post_processor |
"which linked entities may now be anonymized in cascade?" |
Each bundle implements them for the data it owns. ChillPersonBundle must not know about tickets, activities or budgets.
Candidate providers¶
The provider returns a SQL fragment, not a standalone query: it is embedded into
NOT EXISTS (…) / EXISTS (…) by the manager, and must reference the outer alias
person_candidate. Parameters are positional (? / setParameter(0, …)), because the
manager concatenates the parameters of all providers in order.
public function provideNotExistsQuery(\DateTimeImmutable $pivotDate): QueryBuilder
{
return $this->connection->createQueryBuilder()
->select('1')
->from('chill_ticket.person_history', 'person_history')
->where('person_history.person_id = person_candidate.id') // ← the outer alias
->andWhere('person_history.startdate > ?') // ← positional
->setParameter(0, $pivotDate, Types::DATETIME_IMMUTABLE);
}
Warning
Adding a provider makes the candidate list more restrictive, never less: a person is a candidate only when every provider agrees. A provider whose query is too broad silently empties the list.
Events happening inside an accompanying period are not read table by table: they come from
view_chill_person_accompanying_period_info, through
AccompanyingPeriodInfoQueryCandidateProvider. That view is the same source which marks a
period as inactive, so a period still considered active always keeps its participants out of
the list. Add a new kind of period event to the view, not to a provider of its own.
Processors¶
Implementations are named …Anonymizator and live in <bundle>/Service/Anonymization/. They
return QueryBuilder objects, executed by the manager — a processor never executes anything
itself. See the anonymization-processor agent skill for the full conventions, the SQL recipes
and the traps (embeddable column names, NOT NULL columns, database triggers).
The intent is anonymization, not deletion: free text is emptied, dates are truncated to the quarter, amounts are rounded — the rows stay, so statistics remain meaningful.
Detecting a cascade which never happened¶
The cascade is requested once, by the post-processor, right after the person's COMMIT. When
that dispatch fails, the person stays anonymized and nothing reports it: the message is
retried, AnonymizationRequestHandler finds the person already anonymized and abandons, so
nothing reaches the failed queue. The warning it logs on that branch is the only trace.
There is no cron sweeping for it. Run this by hand after a large anonymization — it lists the accompanying periods which should have been anonymized and were not:
SELECT p.id FROM chill_person_accompanying_period p
WHERE p.anonymizationdate IS NULL
AND EXISTS (SELECT 1 FROM chill_person_accompanying_period_participation pa
WHERE pa.accompanyingperiod_id = p.id)
AND NOT EXISTS (SELECT 1 FROM chill_person_accompanying_period_participation pa
JOIN chill_person_person person ON person.id = pa.person_id
WHERE pa.accompanyingperiod_id = p.id
AND person.anonymizationdate IS NULL);
The same shape works for chill_person_household (through chill_person_household_members)
and for chill_ticket.ticket (through person_history and caller_history). A cascade found
this way is replayed by anonymizing any of its participants again, or by dispatching an
AnonymizationCascadeRequest by hand.
Documents¶
Documents are not deleted by the processors. Their StoredObject is switched to the status
to_delete, and the content is removed asynchronously by the cron job
delete-stored-object-content. The row itself is kept so the document (without its stored object content) stays attached to the
person file.
Every processor holding documents needs the same UPDATE, and only differs by the way it
reaches the stored objects. Do not repeat it: inject
Chill\DocStoreBundle\Service\Anonymization\StoredObjectAnonymizationHelper and pass the
sub-query selecting their ids.
private function markDocumentsToDelete(?int $personId): QueryBuilder
{
return $this->storedObjectHelper
->anonymizeWhere('SELECT object_id FROM chill_doc.person_document WHERE person_id = :personId')
->setParameter('personId', $personId, ParameterType::INTEGER);
}
The sub-query is inlined in the SQL as-is: it must be written by a developer, never built from
a value coming from the outside, and every value it compares must go through a named parameter.
The helper binds emptyTitle and storedObjectToDelete itself, so a sub-query must not reuse
those two names.
Testing¶
Two abstract bases assert that the generated SQL is valid against the real schema — they do not assert on the result set:
Chill\PersonBundle\Test\Anonymization\AbstractAnonymizationQueryCandidateProviderTestCase— implementgetProvider(); the fragment is wrapped exactly as the manager does and executed.Chill\PersonBundle\Test\Anonymization\AbstractAnonymizationProcessorTestCase— implementgetProcessor()andgetSupportedEntities()(use thefindOneEntity()helper to pick entities from the fixtures); the queries run inside a transaction which is rolled back.
Each processor has its own test. Extend the base of the entity it supports — they already
implement getSupportedEntities() and getUnsupportedEntities(), so only getProcessor() is
left to write:
Chill\PersonBundle\Test\Anonymization\AbstractAnonymizationProcessorForPersonTestCase…ForAccompanyingPeriodTestCase,…ForHouseholdTestCaseChill\TicketBundle\Test\Anonymization\AbstractAnonymizationProcessorForTicketTestCase
Post-processors are tested against the in-memory Messenger transport
(self::getContainer()->get('messenger.transport.async')), asserting the dispatched
AnonymizationCascadeRequest messages.
Warning
A test which anonymizes for real must run inside a transaction it rolls back: the anonymization is irreversible and the fixtures are shared with the rest of the suite.
Relevant fixtures:
LoadAnonymizablePeople— persons with a dormant file, spread over two centers. They are the ones the synchronization is expected to find;LoadAnonymizationCandidate— one candidate per person above, a part of them delayed, so the list and its filters can be tested without running the cron. The synchronization keeps these rows: it only inserts the candidates it is missing, and deletes those whose file became active again.