From 2b4a5d7ac4f73bf50b2b9775b523ab77576eecb3 Mon Sep 17 00:00:00 2001 From: Kay Joosten Date: Wed, 5 Aug 2026 11:12:21 +0200 Subject: [PATCH 1/2] Show deprovisioning event and date in the audit log Why is this change needed? Prior to this change, deprovisioning an identity via the lifecycle API left no trace in the audit log. AuditLogProjector explicitly skipped writing an entry for IdentityForgottenEvent, so RA(A)s had no way to see that, or when, an identity had been deprovisioned. How does it address the issue? This change maps IdentityForgottenEvent to a new 'deprovisioned' audit log action and reorders AuditLogProjector::handle() so the deprovisioning entry is persisted before the identity's other audit log entries are anonymized; otherwise the newly created entry would be wiped immediately after. A projector test covers both the new entry and the existing anonymization behavior. Provide links to any relevant tickets, articles or other resources https://github.com/OpenConext/Stepup-RA/issues/423 --- .../Identity/Entity/AuditLogEntry.php | 2 + .../Identity/Projector/AuditLogProjector.php | 5 +- .../Projector/AuditLogProjectorTest.php | 62 +++++++++++++++++++ 3 files changed, 68 insertions(+), 1 deletion(-) diff --git a/src/Surfnet/StepupMiddleware/ApiBundle/Identity/Entity/AuditLogEntry.php b/src/Surfnet/StepupMiddleware/ApiBundle/Identity/Entity/AuditLogEntry.php index 2edfa0e0..1da3fe7e 100644 --- a/src/Surfnet/StepupMiddleware/ApiBundle/Identity/Entity/AuditLogEntry.php +++ b/src/Surfnet/StepupMiddleware/ApiBundle/Identity/Entity/AuditLogEntry.php @@ -38,6 +38,7 @@ use Surfnet\Stepup\Identity\Event\IdentityAccreditedAsRaForInstitutionEvent; use Surfnet\Stepup\Identity\Event\IdentityCreatedEvent; use Surfnet\Stepup\Identity\Event\IdentityEmailChangedEvent; +use Surfnet\Stepup\Identity\Event\IdentityForgottenEvent; use Surfnet\Stepup\Identity\Event\IdentityRenamedEvent; use Surfnet\Stepup\Identity\Event\PhonePossessionProvenAndVerifiedEvent; use Surfnet\Stepup\Identity\Event\PhonePossessionProvenEvent; @@ -87,6 +88,7 @@ class AuditLogEntry implements JsonSerializable GssfPossessionProvenAndVerifiedEvent::class => 'possession_proven', IdentityCreatedEvent::class => 'created', IdentityEmailChangedEvent::class => 'email_changed', + IdentityForgottenEvent::class => 'deprovisioned', IdentityRenamedEvent::class => 'renamed', PhonePossessionProvenEvent::class => 'possession_proven', PhonePossessionProvenAndVerifiedEvent::class => 'possession_proven', diff --git a/src/Surfnet/StepupMiddleware/ApiBundle/Identity/Projector/AuditLogProjector.php b/src/Surfnet/StepupMiddleware/ApiBundle/Identity/Projector/AuditLogProjector.php index eba995b3..3d94437e 100644 --- a/src/Surfnet/StepupMiddleware/ApiBundle/Identity/Projector/AuditLogProjector.php +++ b/src/Surfnet/StepupMiddleware/ApiBundle/Identity/Projector/AuditLogProjector.php @@ -62,7 +62,10 @@ public function handle(DomainMessage $domainMessage): void switch (true) { case $event instanceof IdentityForgottenEvent: - // Don't insert the IdentityForgottenEvent into the audit log, as we'd remove it immediately afterwards. + // Record the deprovisioning itself first, then anonymise the identity's other audit log + // entries. Anonymising first would immediately wipe the actor name off the entry we're + // about to create here. + $this->applyAuditableEvent($event, $domainMessage); $this->applyIdentityForgottenEvent($event); break; // Finally apply the auditable event, most events are auditable this so first handle the unique variants diff --git a/src/Surfnet/StepupMiddleware/ApiBundle/Tests/Identity/Projector/AuditLogProjectorTest.php b/src/Surfnet/StepupMiddleware/ApiBundle/Tests/Identity/Projector/AuditLogProjectorTest.php index fb3d8f2a..221f1e92 100644 --- a/src/Surfnet/StepupMiddleware/ApiBundle/Tests/Identity/Projector/AuditLogProjectorTest.php +++ b/src/Surfnet/StepupMiddleware/ApiBundle/Tests/Identity/Projector/AuditLogProjectorTest.php @@ -31,6 +31,7 @@ use PHPUnit\Framework\TestCase; use Surfnet\Stepup\DateTime\DateTime as StepupDateTime; use Surfnet\Stepup\Identity\AuditLog\Metadata; +use Surfnet\Stepup\Identity\Event\IdentityForgottenEvent; use Surfnet\Stepup\Identity\Value\CommonName; use Surfnet\Stepup\Identity\Value\IdentityId; use Surfnet\Stepup\Identity\Value\Institution; @@ -167,6 +168,67 @@ public function it_creates_entries_for_auditable_events(DomainMessage $message, $this->assertEquals($expectedEntry, $actualEntry); } + #[Test] + #[Group('api-projector')] + public function it_creates_a_deprovisioned_entry_and_anonymizes_the_identitys_other_entries(): void + { + $identityId = new IdentityId('abcd'); + $institution = new Institution('efgh'); + + $existingEntry = new AuditLogEntry(); + $existingEntry->id = 'existing-entry'; + $existingEntry->identityId = $identityId; + $existingEntry->identityInstitution = $institution; + $existingEntry->actorCommonName = new CommonName(self::$actorCommonName); + $existingEntry->event = 'SomeEarlierEvent'; + $existingEntry->recordedOn = new StepupDateTime(new CoreDateTime('1970-01-01H00:00:00.000')); + + $entryWhereIdentityIsActor = new AuditLogEntry(); + $entryWhereIdentityIsActor->id = 'actor-entry'; + $entryWhereIdentityIsActor->identityId = new IdentityId('some-other-identity'); + $entryWhereIdentityIsActor->identityInstitution = $institution; + $entryWhereIdentityIsActor->actorId = $identityId; + $entryWhereIdentityIsActor->actorCommonName = new CommonName(self::$actorCommonName); + $entryWhereIdentityIsActor->event = 'SomeEarlierEvent'; + $entryWhereIdentityIsActor->recordedOn = new StepupDateTime(new CoreDateTime('1970-01-01H00:00:00.000')); + + $repository = m::mock(AuditLogRepository::class); + + $newEntry = null; + $repository->shouldReceive('save')->once()->with($this->spy($newEntry)); + /** @var null|AuditLogEntry $newEntry */ + + $repository->shouldReceive('findByIdentityId')->once()->with($identityId)->andReturn([$existingEntry]); + $repository->shouldReceive('findEntriesWhereIdentityIsActorOnly')->once()->with($identityId) + ->andReturn([$entryWhereIdentityIsActor]); + $repository->shouldReceive('saveAll')->once()->with([$existingEntry]); + $repository->shouldReceive('saveAll')->once()->with([$entryWhereIdentityIsActor]); + + $identityRepository = m::mock(IdentityRepository::class); + + $projector = new AuditLogProjector($repository, $identityRepository); + + $message = new DomainMessage( + 'id', + 0, + new MessageMetadata(), + new IdentityForgottenEvent($identityId, $institution), + BroadwayDateTime::fromString('1970-01-01H00:00:00.000'), + ); + + $projector->handle($message); + + // A new "deprovisioned" audit log entry must have been created for the identity. + $this->assertNotNull($newEntry); + $this->assertSame((string)$identityId, $newEntry->identityId); + $this->assertSame($institution, $newEntry->identityInstitution); + $this->assertSame(IdentityForgottenEvent::class, $newEntry->event); + + // Pre-existing entries for the identity must still be anonymized, same as before this change. + $this->assertEquals(CommonName::unknown(), $existingEntry->actorCommonName); + $this->assertEquals(CommonName::unknown(), $entryWhereIdentityIsActor->actorCommonName); + } + private function createAuditLogMetadata( IdentityId $identityId, Institution $institution, From c60a592c65cd42f1c3a59303d593223111d4fd10 Mon Sep 17 00:00:00 2001 From: Kay Joosten Date: Mon, 10 Aug 2026 13:14:08 +0200 Subject: [PATCH 2/2] Include deprovisioned entries in the RA audit log query Why is this change needed? IdentityForgottenEvent was mapped to a new 'deprovisioned' audit log action and persisted, but AuditLogRepository::createSecondFactorSearchQuery() filters entries against an event allowlist that never included it, so the new entry was written but silently filtered out of the RA audit log page. Separately, the comment justifying the projector's insert-before-anonymise ordering had the mechanism backwards, and the projector test mocked findByIdentityId() to omit the just-inserted entry, so it couldn't have caught either issue. How does it address the issue? Adds IdentityForgottenEvent::class to the allowlist so the entry is actually returned to the RA UI. Corrects the ordering comment: inserting before anonymising means the new entry is included in the same anonymisation pass as the identity's other entries (its actor name gets wiped like everything else), not preserved as the old comment claimed. Updates the test to mock findByIdentityId() the way the real repository behaves (returning the freshly flushed entry alongside the pre-existing one), and asserts the new entry's actor name is anonymised too. Provide links to any relevant tickets, articles or other resources https://github.com/OpenConext/Stepup-RA/issues/423 --- .../Identity/Projector/AuditLogProjector.php | 7 ++++--- .../Identity/Repository/AuditLogRepository.php | 2 ++ .../Projector/AuditLogProjectorTest.php | 17 +++++++++++++++-- 3 files changed, 21 insertions(+), 5 deletions(-) diff --git a/src/Surfnet/StepupMiddleware/ApiBundle/Identity/Projector/AuditLogProjector.php b/src/Surfnet/StepupMiddleware/ApiBundle/Identity/Projector/AuditLogProjector.php index 3d94437e..af16ce5e 100644 --- a/src/Surfnet/StepupMiddleware/ApiBundle/Identity/Projector/AuditLogProjector.php +++ b/src/Surfnet/StepupMiddleware/ApiBundle/Identity/Projector/AuditLogProjector.php @@ -62,9 +62,10 @@ public function handle(DomainMessage $domainMessage): void switch (true) { case $event instanceof IdentityForgottenEvent: - // Record the deprovisioning itself first, then anonymise the identity's other audit log - // entries. Anonymising first would immediately wipe the actor name off the entry we're - // about to create here. + // Record the deprovisioning entry first so applyIdentityForgottenEvent's re-query of + // findByIdentityId() picks it up too, anonymising its actor name along with the + // identity's other entries. Anonymising first would query before this entry exists, + // leaving its actor name (typically the deprovisioning system/API actor) untouched. $this->applyAuditableEvent($event, $domainMessage); $this->applyIdentityForgottenEvent($event); break; diff --git a/src/Surfnet/StepupMiddleware/ApiBundle/Identity/Repository/AuditLogRepository.php b/src/Surfnet/StepupMiddleware/ApiBundle/Identity/Repository/AuditLogRepository.php index 2315844f..22ad8ace 100644 --- a/src/Surfnet/StepupMiddleware/ApiBundle/Identity/Repository/AuditLogRepository.php +++ b/src/Surfnet/StepupMiddleware/ApiBundle/Identity/Repository/AuditLogRepository.php @@ -36,6 +36,7 @@ use Surfnet\Stepup\Identity\Event\IdentityAccreditedAsRaaForInstitutionEvent; use Surfnet\Stepup\Identity\Event\IdentityAccreditedAsRaEvent; use Surfnet\Stepup\Identity\Event\IdentityAccreditedAsRaForInstitutionEvent; +use Surfnet\Stepup\Identity\Event\IdentityForgottenEvent; use Surfnet\Stepup\Identity\Event\PhonePossessionProvenAndVerifiedEvent; use Surfnet\Stepup\Identity\Event\PhonePossessionProvenEvent; use Surfnet\Stepup\Identity\Event\PhoneRecoveryTokenPossessionProvenEvent; @@ -104,6 +105,7 @@ public function __construct(ManagerRegistry $registry) RecoveryTokenRevokedEvent::class, PhoneRecoveryTokenPossessionProvenEvent::class, CompliedWithRecoveryCodeRevocationEvent::class, + IdentityForgottenEvent::class, ]; /** diff --git a/src/Surfnet/StepupMiddleware/ApiBundle/Tests/Identity/Projector/AuditLogProjectorTest.php b/src/Surfnet/StepupMiddleware/ApiBundle/Tests/Identity/Projector/AuditLogProjectorTest.php index 221f1e92..5b355e13 100644 --- a/src/Surfnet/StepupMiddleware/ApiBundle/Tests/Identity/Projector/AuditLogProjectorTest.php +++ b/src/Surfnet/StepupMiddleware/ApiBundle/Tests/Identity/Projector/AuditLogProjectorTest.php @@ -198,10 +198,19 @@ public function it_creates_a_deprovisioned_entry_and_anonymizes_the_identitys_ot $repository->shouldReceive('save')->once()->with($this->spy($newEntry)); /** @var null|AuditLogEntry $newEntry */ - $repository->shouldReceive('findByIdentityId')->once()->with($identityId)->andReturn([$existingEntry]); + // The new entry is flushed (AuditLogRepository::save() flushes immediately) before this is + // called, so a real findByIdentityId() re-query picks it up alongside the pre-existing entry. + $repository->shouldReceive('findByIdentityId')->once()->with($identityId) + ->andReturnUsing(function () use ($existingEntry, &$newEntry): array { + return [$existingEntry, $newEntry]; + }); $repository->shouldReceive('findEntriesWhereIdentityIsActorOnly')->once()->with($identityId) ->andReturn([$entryWhereIdentityIsActor]); - $repository->shouldReceive('saveAll')->once()->with([$existingEntry]); + $repository->shouldReceive('saveAll')->once()->with( + m::on(function (array $entries) use ($existingEntry, &$newEntry): bool { + return $entries === [$existingEntry, $newEntry]; + }), + ); $repository->shouldReceive('saveAll')->once()->with([$entryWhereIdentityIsActor]); $identityRepository = m::mock(IdentityRepository::class); @@ -227,6 +236,10 @@ public function it_creates_a_deprovisioned_entry_and_anonymizes_the_identitys_ot // Pre-existing entries for the identity must still be anonymized, same as before this change. $this->assertEquals(CommonName::unknown(), $existingEntry->actorCommonName); $this->assertEquals(CommonName::unknown(), $entryWhereIdentityIsActor->actorCommonName); + + // The new "deprovisioned" entry is swept up by the same anonymization pass, since it belongs + // to the identity being forgotten. + $this->assertEquals(CommonName::unknown(), $newEntry->actorCommonName); } private function createAuditLogMetadata(