| | | 1 | | using ArturRios.Heimdall.Domain.Entities; |
| | | 2 | | using ArturRios.Heimdall.Domain.Persistence; |
| | | 3 | | |
| | | 4 | | namespace 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> |
| | | 40 | | public 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) => |
| | 71 | 80 | | 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) => |
| | 9 | 89 | | 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) => |
| | 22 | 115 | | atomicWrites.TryAnonymiseAsync(googleUser, AnonymisedName, PlaceholderEmail(googleUser.PublicId), |
| | 22 | 116 | | 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) => |
| | 1 | 125 | | atomicWrites.TryReapplyErasureAsync(googleUser, AnonymisedName, PlaceholderEmail(googleUser.PublicId), |
| | 1 | 126 | | AnonymisedGoogleId(googleUser.PublicId), at); |
| | | 127 | | |
| | | 128 | | /// <summary>The placeholder Google subject for a Google User, unique to it.</summary> |
| | 23 | 129 | | 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> |
| | 103 | 134 | | public static string PlaceholderEmail(Guid publicId) => $"anonymised-{publicId:N}@{AnonymisedEmailDomain}"; |
| | | 135 | | } |