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..af16ce5e 100644 --- a/src/Surfnet/StepupMiddleware/ApiBundle/Identity/Projector/AuditLogProjector.php +++ b/src/Surfnet/StepupMiddleware/ApiBundle/Identity/Projector/AuditLogProjector.php @@ -62,7 +62,11 @@ 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 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; // Finally apply the auditable event, most events are auditable this so first handle the unique variants 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 fb3d8f2a..5b355e13 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,80 @@ 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 */ + + // 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( + m::on(function (array $entries) use ($existingEntry, &$newEntry): bool { + return $entries === [$existingEntry, $newEntry]; + }), + ); + $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); + + // 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( IdentityId $identityId, Institution $institution,