Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -104,6 +105,7 @@ public function __construct(ManagerRegistry $registry)
RecoveryTokenRevokedEvent::class,
PhoneRecoveryTokenPossessionProvenEvent::class,
CompliedWithRecoveryCodeRevocationEvent::class,
IdentityForgottenEvent::class,
];

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down