< Summary - Heimdall API

Information
Class: ArturRios.Heimdall.Command.Services.IdentityAnonymiser
Assembly: ArturRios.Heimdall.Command
File(s): /home/ci/agent/workspace/heimdall-api-docs_main/app/src/Application/ArturRios.Heimdall.Command/Services/IdentityAnonymiser.cs
Line coverage
100%
Covered lines: 8
Uncovered lines: 0
Coverable lines: 8
Total lines: 135
Line coverage: 100%
Branch coverage
N/A
Covered branches: 0
Total branches: 0
Branch coverage: N/A
Method coverage
100%
Covered methods: 6
Fully covered methods: 6
Total methods: 6
Method coverage: 100%
Full method coverage: 100%

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
AnonymiseAsync(...)100%11100%
ReapplyAsync(...)100%11100%
AnonymiseAsync(...)100%11100%
ReapplyAsync(...)100%11100%
AnonymisedGoogleId(...)100%11100%
PlaceholderEmail(...)100%11100%

File(s)

/home/ci/agent/workspace/heimdall-api-docs_main/app/src/Application/ArturRios.Heimdall.Command/Services/IdentityAnonymiser.cs

#LineLine coverage
 1using ArturRios.Heimdall.Domain.Entities;
 2using ArturRios.Heimdall.Domain.Persistence;
 3
 4namespace ArturRios.Heimdall.Command.Services;
 5
 6/// <summary>
 7///     Overwrites every value on an identity that identifies a natural person, leaving the row and
 8///     its foreign keys intact (NFR-20). The operation both laws recognise as putting data outside
 9///     their scope: GDPR Recital 26 and LGPD Art. 12 exempt anonymised data, so an anonymised row is
 10///     no longer personal data being retained.
 11/// </summary>
 12/// <remarks>
 13///     <para>
 14///         <b>Why overwrite rather than delete the row.</b> NFR-07 requires every foreign key in the
 15///         schema to still resolve after a deletion, and removing the row breaks that — the audit
 16///         trail, the scope membership and ownership rows, and the applications an owner held all
 17///         point at it. Overwriting in place satisfies the erasure obligation without touching a
 18///         single relationship. Hard deletion remains available as UC-10 for callers that want the
 19///         row gone.
 20///     </para>
 21///     <para>
 22///         <b>Why the placeholders are unique per row.</b> `GOOGLE_USER` carries unconditional
 23///         unique indexes on <c>(scope_id, google_id)</c> and <c>(scope_id, LOWER(email))</c>, so a
 24///         constant placeholder would collide the moment a second Google User in the same scope was
 25///         anonymised — turning an erasure into a failed write. Deriving them from
 26///         <c>PublicId</c> keeps them unique by construction. `PERSON`'s equivalent indexes are
 27///         filtered on <c>is_deleted = false</c> and so never see these rows, but the same
 28///         derivation is used there rather than a constant, because a rule that holds only while an
 29///         index keeps its current filter is a rule waiting to break.
 30///     </para>
 31///     <para>
 32///         <b>What remains, and why that is still anonymisation.</b> The row keeps its
 33///         <c>PublicId</c> — a random GUID that the audit trail and the scope join rows reference.
 34///         Once the identifying columns are gone nothing in this system maps it back to a person, so
 35///         within Heimdall the row relates to no identifiable natural person. A client system that
 36///         stored the <c>PublicId</c> alongside its own copy of the name is a separate matter, and
 37///         one for the tenant that controls that copy.
 38///     </para>
 39/// </remarks>
 40public static class IdentityAnonymiser
 41{
 42    /// <summary>Display name left in place of the real one.</summary>
 43    public const string AnonymisedName = "Anonymised";
 44
 45    /// <summary>
 46    ///     Domain the placeholder addresses are built under. <c>.invalid</c> is reserved by RFC 2606
 47    ///     and guaranteed never to resolve, so a placeholder can never become a deliverable address
 48    ///     — an erased person must not be reachable by an email this system sends.
 49    /// </summary>
 50    public const string AnonymisedEmailDomain = "anonymised.invalid";
 51
 52    /// <summary>
 53    ///     Overwrites the identifying values on <paramref name="person" /> and stamps
 54    ///     <paramref name="at" />, through <see cref="IAtomicWrites.TryAnonymiseAsync" />: the name and
 55    ///     address are replaced by placeholders, and the password hash and salt, the login lockout, the
 56    ///     factor budgets, the account-email budget and the verification flag are cleared with them.
 57    ///     Does not touch <c>IsDeleted</c>, the role, the scope, or any foreign key.
 58    /// </summary>
 59    /// <returns>
 60    ///     <c>false</c> when the person is no longer deleted, is anonymised already, or has had
 61    ///     processing restricted (NFR-24) since they were selected; nothing is written then.
 62    /// </returns>
 63    /// <remarks>
 64    ///     <para>
 65    ///         One targeted write rather than a whole-row save (#237). Every column it clears is
 66    ///         ignored by a whole-row save once the row exists, so none of them could be cleared
 67    ///         otherwise — and a record is anonymised whole or not at all.
 68    ///     </para>
 69    ///     <para>
 70    ///         Why each goes. A hash and its salt are the material an offline attack works on, and
 71    ///         keeping them for a record nobody can log into serves nothing; both columns are NOT NULL,
 72    ///         so they are emptied rather than nulled. How often this person mistyped their password,
 73    ///         or a second factor, and whether they were locked out, describes them as surely as their
 74    ///         name does. An anonymised address is not a verified one, and leaving the flag set would
 75    ///         let a restored record skip verification for an address that no longer belongs to
 76    ///         anybody.
 77    ///     </para>
 78    /// </remarks>
 79    public static Task<bool> AnonymiseAsync(IAtomicWrites atomicWrites, Person person, DateTime at) =>
 7180        atomicWrites.TryAnonymiseAsync(person, AnonymisedName, PlaceholderEmail(person.PublicId), at);
 81
 82    /// <summary>
 83    ///     NFR-25: anonymises a person a restore brought back, as <see cref="AnonymiseAsync" /> does,
 84    ///     through <see cref="IAtomicWrites.TryReapplyErasureAsync" />, which also marks them a
 85    ///     subject-requested deletion whatever state the restore left them in.
 86    /// </summary>
 87    /// <returns><c>false</c> when they were anonymised already, possibly since they were read.</returns>
 88    public static Task<bool> ReapplyAsync(IAtomicWrites atomicWrites, Person person, DateTime at) =>
 989        atomicWrites.TryReapplyErasureAsync(person, AnonymisedName, PlaceholderEmail(person.PublicId), at);
 90
 91    /// <summary>
 92    ///     NFR-20: anonymises a logically deleted Google User by one targeted write
 93    ///     (<see cref="IAtomicWrites.TryAnonymiseAsync(GoogleUser, string, string, string, DateTime)" />):
 94    ///     the placeholder name, address and Google subject, the picture and the verification flag
 95    ///     cleared, and <paramref name="at" /> stamped.
 96    /// </summary>
 97    /// <returns>
 98    ///     <c>false</c> when the Google User is no longer deleted, is anonymised already, or has had
 99    ///     processing restricted (NFR-24) since they were selected; nothing is written then.
 100    /// </returns>
 101    /// <remarks>
 102    ///     <para>
 103    ///         Targeted rather than a whole-row save (#205), as a person's is (#237): a save of the
 104    ///         selected copy would write a restriction imposed since the selection back to none, and
 105    ///         erase a record that must be preserved as it stands.
 106    ///     </para>
 107    ///     <para>
 108    ///         Google's <c>sub</c> is the strongest identifier on this row: it is stable across Google's
 109    ///         whole estate, so leaving it would let anyone holding it re-identify the person from outside
 110    ///         this system entirely. It carries a unique index, hence the derived value rather than null. A
 111    ///         profile picture is a photograph of a person.
 112    ///     </para>
 113    /// </remarks>
 114    public static Task<bool> AnonymiseAsync(IAtomicWrites atomicWrites, GoogleUser googleUser, DateTime at) =>
 22115        atomicWrites.TryAnonymiseAsync(googleUser, AnonymisedName, PlaceholderEmail(googleUser.PublicId),
 22116            AnonymisedGoogleId(googleUser.PublicId), at);
 117
 118    /// <summary>
 119    ///     NFR-25: anonymises a Google User a restore brought back, as <see cref="AnonymiseAsync(IAtomicWrites,
 120    ///     GoogleUser, DateTime)" /> does, and marks them a subject-requested deletion whatever state the
 121    ///     restore left them in.
 122    /// </summary>
 123    /// <returns><c>false</c> when they were anonymised already, possibly since they were read.</returns>
 124    public static Task<bool> ReapplyAsync(IAtomicWrites atomicWrites, GoogleUser googleUser, DateTime at) =>
 1125        atomicWrites.TryReapplyErasureAsync(googleUser, AnonymisedName, PlaceholderEmail(googleUser.PublicId),
 1126            AnonymisedGoogleId(googleUser.PublicId), at);
 127
 128    /// <summary>The placeholder Google subject for a Google User, unique to it.</summary>
 23129    public static string AnonymisedGoogleId(Guid publicId) => $"anonymised-{publicId:N}";
 130
 131    /// <summary>
 132    ///     The placeholder address for an identity, unique to it and never deliverable.
 133    /// </summary>
 103134    public static string PlaceholderEmail(Guid publicId) => $"anonymised-{publicId:N}@{AnonymisedEmailDomain}";
 135}