| | | 1 | | using ArturRios.Heimdall.Domain.Entities; |
| | | 2 | | |
| | | 3 | | namespace 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> |
| | | 39 | | public 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 | | { |
| | 28 | 58 | | person.Name = AnonymisedName; |
| | 28 | 59 | | 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. |
| | 28 | 66 | | person.PasswordHash = []; |
| | 28 | 67 | | 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. |
| | 28 | 73 | | person.FailedLoginAttempts = 0; |
| | 28 | 74 | | 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. |
| | 28 | 78 | | person.EmailVerified = false; |
| | | 79 | | |
| | 28 | 80 | | person.AnonymisedAt = at; |
| | 28 | 81 | | person.UpdatedAt = at; |
| | 28 | 82 | | } |
| | | 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 | | { |
| | 8 | 90 | | googleUser.Name = AnonymisedName; |
| | 8 | 91 | | 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. |
| | 8 | 96 | | googleUser.GoogleId = $"anonymised-{googleUser.PublicId:N}"; |
| | | 97 | | |
| | | 98 | | // A profile picture is a photograph of a person. |
| | 8 | 99 | | googleUser.ProfilePictureUrl = null; |
| | | 100 | | |
| | 8 | 101 | | googleUser.EmailVerified = false; |
| | | 102 | | |
| | 8 | 103 | | googleUser.AnonymisedAt = at; |
| | 8 | 104 | | googleUser.UpdatedAt = at; |
| | 8 | 105 | | } |
| | | 106 | | |
| | | 107 | | /// <summary> |
| | | 108 | | /// The placeholder address for an identity, unique to it and never deliverable. |
| | | 109 | | /// </summary> |
| | 36 | 110 | | public static string PlaceholderEmail(Guid publicId) => $"anonymised-{publicId:N}@{AnonymisedEmailDomain}"; |
| | | 111 | | } |