< Summary - Heimdall API

Information
Class: ArturRios.Heimdall.Command.Services.IdentityAnonymiser
Assembly: ArturRios.Heimdall.Command
File(s): /home/runner/work/heimdall-api/heimdall-api/src/Application/ArturRios.Heimdall.Command/Services/IdentityAnonymiser.cs
Line coverage
100%
Covered lines: 19
Uncovered lines: 0
Coverable lines: 19
Total lines: 111
Line coverage: 100%
Branch coverage
N/A
Covered branches: 0
Total branches: 0
Branch coverage: N/A
Method coverage
100%
Covered methods: 3
Fully covered methods: 3
Total methods: 3
Method coverage: 100%
Full method coverage: 100%

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
Anonymise(...)100%11100%
Anonymise(...)100%11100%
PlaceholderEmail(...)100%11100%

File(s)

/home/runner/work/heimdall-api/heimdall-api/src/Application/ArturRios.Heimdall.Command/Services/IdentityAnonymiser.cs

#LineLine coverage
 1using ArturRios.Heimdall.Domain.Entities;
 2
 3namespace ArturRios.Heimdall.Command.Services;
 4
 5/// <summary>
 6///     Overwrites every value on an identity that identifies a natural person, leaving the row and
 7///     its foreign keys intact (NFR-20). The operation both laws recognise as putting data outside
 8///     their scope: GDPR Recital 26 and LGPD Art. 12 exempt anonymised data, so an anonymised row is
 9///     no longer personal data being retained.
 10/// </summary>
 11/// <remarks>
 12///     <para>
 13///         <b>Why overwrite rather than delete the row.</b> NFR-07 requires every foreign key in the
 14///         schema to still resolve after a deletion, and removing the row breaks that — the audit
 15///         trail, the scope membership and ownership rows, and the applications an owner held all
 16///         point at it. Overwriting in place satisfies the erasure obligation without touching a
 17///         single relationship. Hard deletion remains available as UC-10 for callers that want the
 18///         row gone.
 19///     </para>
 20///     <para>
 21///         <b>Why the placeholders are unique per row.</b> `GOOGLE_USER` carries unconditional
 22///         unique indexes on <c>(scope_id, google_id)</c> and <c>(scope_id, LOWER(email))</c>, so a
 23///         constant placeholder would collide the moment a second Google User in the same scope was
 24///         anonymised — turning an erasure into a failed write. Deriving them from
 25///         <c>PublicId</c> keeps them unique by construction. `PERSON`'s equivalent indexes are
 26///         filtered on <c>is_deleted = false</c> and so never see these rows, but the same
 27///         derivation is used there rather than a constant, because a rule that holds only while an
 28///         index keeps its current filter is a rule waiting to break.
 29///     </para>
 30///     <para>
 31///         <b>What remains, and why that is still anonymisation.</b> The row keeps its
 32///         <c>PublicId</c> — a random GUID that the audit trail and the scope join rows reference.
 33///         Once the identifying columns are gone nothing in this system maps it back to a person, so
 34///         within Heimdall the row relates to no identifiable natural person. A client system that
 35///         stored the <c>PublicId</c> alongside its own copy of the name is a separate matter, and
 36///         one for the tenant that controls that copy.
 37///     </para>
 38/// </remarks>
 39public static class IdentityAnonymiser
 40{
 41    /// <summary>Display name left in place of the real one.</summary>
 42    public const string AnonymisedName = "Anonymised";
 43
 44    /// <summary>
 45    ///     Domain the placeholder addresses are built under. <c>.invalid</c> is reserved by RFC 2606
 46    ///     and guaranteed never to resolve, so a placeholder can never become a deliverable address
 47    ///     — an erased person must not be reachable by an email this system sends.
 48    /// </summary>
 49    public const string AnonymisedEmailDomain = "anonymised.invalid";
 50
 51    /// <summary>
 52    ///     Overwrites the identifying values on <paramref name="person" /> and stamps
 53    ///     <paramref name="at" />. Does not touch <c>IsDeleted</c>, the role, the scope, or any
 54    ///     foreign key.
 55    /// </summary>
 56    public static void Anonymise(Person person, DateTime at)
 57    {
 2858        person.Name = AnonymisedName;
 2859        person.Email = PlaceholderEmail(person.PublicId);
 60
 61        // Zeroed rather than left alone: a hash and its salt are the material an offline attack
 62        // works on, and keeping them for a record nobody can log into serves nothing. Both columns
 63        // are NOT NULL, so they are emptied rather than nulled. A whole-row save does not write
 64        // them once the row exists (FR-PR-08), so the callers also clear them through
 65        // IAtomicWrites.ClearPasswordAsync, as they clear the factor budgets.
 2866        person.PasswordHash = [];
 2867        person.Salt = [];
 68
 69        // Behavioural data: how often this person mistyped their password, and whether they were
 70        // locked out, describes them as surely as their name does.
 71        // The factor budgets (FR-AU-12, FR-AU-13) are behavioural data of the same kind, but only
 72        // targeted writes reach their columns, so the callers clear them through IAtomicWrites.
 2873        person.FailedLoginAttempts = 0;
 2874        person.LockedOutUntil = null;
 75
 76        // An anonymised address is not a verified one, and leaving the flag set would let a restored
 77        // record skip verification for an address that no longer belongs to anybody.
 2878        person.EmailVerified = false;
 79
 2880        person.AnonymisedAt = at;
 2881        person.UpdatedAt = at;
 2882    }
 83
 84    /// <summary>
 85    ///     Overwrites the identifying values on <paramref name="googleUser" /> and stamps
 86    ///     <paramref name="at" />.
 87    /// </summary>
 88    public static void Anonymise(GoogleUser googleUser, DateTime at)
 89    {
 890        googleUser.Name = AnonymisedName;
 891        googleUser.Email = PlaceholderEmail(googleUser.PublicId);
 92
 93        // Google's 'sub' is the strongest identifier on this row: it is stable across Google's whole
 94        // estate, so leaving it would let anyone holding it re-identify the person from outside this
 95        // system entirely. It carries a unique index, hence the derived value rather than null.
 896        googleUser.GoogleId = $"anonymised-{googleUser.PublicId:N}";
 97
 98        // A profile picture is a photograph of a person.
 899        googleUser.ProfilePictureUrl = null;
 100
 8101        googleUser.EmailVerified = false;
 102
 8103        googleUser.AnonymisedAt = at;
 8104        googleUser.UpdatedAt = at;
 8105    }
 106
 107    /// <summary>
 108    ///     The placeholder address for an identity, unique to it and never deliverable.
 109    /// </summary>
 36110    public static string PlaceholderEmail(Guid publicId) => $"anonymised-{publicId:N}@{AnonymisedEmailDomain}";
 111}