| | | 1 | | using System.ComponentModel.DataAnnotations; |
| | | 2 | | using ArturRios.Data.Relational.Core.Entities; |
| | | 3 | | |
| | | 4 | | namespace ArturRios.Heimdall.Domain.Entities; |
| | | 5 | | |
| | | 6 | | /// <summary> |
| | | 7 | | /// A registered identity. Has no direct scope attribute — its relationship to scopes is |
| | | 8 | | /// derived from its role: a <c>User</c> belongs to exactly one scope (via <c>SCOPE_USER</c>), |
| | | 9 | | /// a <c>ScopeAdmin</c> owns one or more scopes (via <c>SCOPE_OWNER</c>), and a |
| | | 10 | | /// <c>SystemAdmin</c> belongs to no scope. |
| | | 11 | | /// </summary> |
| | | 12 | | public class Person : Entity<long> |
| | | 13 | | { |
| | | 14 | | /// <summary> |
| | | 15 | | /// External identifier, generated on creation and used everywhere the person is addressed |
| | | 16 | | /// from outside the database (API paths, response bodies, token claims). The internal |
| | | 17 | | /// <see cref="Entity{TKey}.Id" /> is never exposed to callers. |
| | | 18 | | /// </summary> |
| | 12431 | 19 | | public Guid PublicId { get; set; } = Guid.NewGuid(); |
| | | 20 | | |
| | | 21 | | /// <summary>Full name. Required, max 200 characters.</summary> |
| | | 22 | | [MaxLength(200)] |
| | 4902 | 23 | | public string Name { get; set; } = string.Empty; |
| | | 24 | | |
| | | 25 | | /// <summary> |
| | | 26 | | /// Email address. Unique within the scope for <c>User</c>s (via <c>SCOPE_USER</c>); |
| | | 27 | | /// unique system-wide for <c>ScopeAdmin</c>s and <c>SystemAdmin</c>s. |
| | | 28 | | /// </summary> |
| | | 29 | | [MaxLength(256)] |
| | 5404 | 30 | | public string Email { get; set; } = string.Empty; |
| | | 31 | | |
| | | 32 | | /// <summary>Hash computed from the password and <see cref="Salt" />.</summary> |
| | 3740 | 33 | | public byte[] PasswordHash { get; set; } = []; |
| | | 34 | | |
| | | 35 | | /// <summary>Randomly generated per person, used to hash the password.</summary> |
| | 3721 | 36 | | public byte[] Salt { get; set; } = []; |
| | | 37 | | |
| | | 38 | | /// <summary>Logical deletion flag. Logically deleted persons are excluded from default queries.</summary> |
| | 2101 | 39 | | public bool IsDeleted { get; set; } |
| | | 40 | | |
| | | 41 | | /// <summary> |
| | | 42 | | /// When the record was logically deleted, or <c>null</c> while it is live. The retention |
| | | 43 | | /// window that ends in anonymisation is measured from here (NFR-20). |
| | | 44 | | /// </summary> |
| | | 45 | | /// <remarks> |
| | | 46 | | /// <see cref="UpdatedAt" /> cannot serve: it moves on any later write, so a record touched |
| | | 47 | | /// after its deletion would have its window silently restarted. This one is written once, by |
| | | 48 | | /// the logical deletion, and never again. |
| | | 49 | | /// </remarks> |
| | 361 | 50 | | public DateTime? DeletedAt { get; set; } |
| | | 51 | | |
| | | 52 | | /// <summary> |
| | | 53 | | /// Why the record was deleted (see <c>DeletionKinds</c>), or <c>null</c> while it is live. |
| | | 54 | | /// Decides which of the schedule's two deadlines applies. |
| | | 55 | | /// </summary> |
| | 298 | 56 | | public int? DeletionKind { get; set; } |
| | | 57 | | |
| | | 58 | | /// <summary> |
| | | 59 | | /// When the record was anonymised, or <c>null</c> if it has not been. Set by the scheduled |
| | | 60 | | /// pass, and the reason a second pass over the same row is a no-op rather than a repeated |
| | | 61 | | /// overwrite. |
| | | 62 | | /// </summary> |
| | 758 | 63 | | public DateTime? AnonymisedAt { get; set; } |
| | | 64 | | |
| | | 65 | | /// <summary> |
| | | 66 | | /// When the data subject asked to be erased (UC-42), or <c>null</c> if they have not. Set |
| | | 67 | | /// once, by the request, and never moved: the statutory deadline runs from the request, not |
| | | 68 | | /// from any later event. |
| | | 69 | | /// </summary> |
| | 372 | 70 | | public DateTime? ErasureRequestedAt { get; set; } |
| | | 71 | | |
| | | 72 | | /// <summary> |
| | | 73 | | /// The deadline the erasure must not outlive, computed when the request is made. |
| | | 74 | | /// </summary> |
| | | 75 | | /// <remarks> |
| | | 76 | | /// Stored rather than derived on read, and that is the point. GDPR Art. 12(3) attaches an |
| | | 77 | | /// obligation at the moment of the request; recomputing it from the current configuration |
| | | 78 | | /// would let a later change to the deadline move an obligation that had already attached — |
| | | 79 | | /// forward, which is unlawful, or backward, which would silently mark existing requests |
| | | 80 | | /// overdue. |
| | | 81 | | /// </remarks> |
| | 318 | 82 | | public DateTime? ErasureDueAt { get; set; } |
| | | 83 | | |
| | | 84 | | /// <summary> |
| | | 85 | | /// Why the erasure could not be carried out yet, or <c>null</c> if nothing blocks it. One of |
| | | 86 | | /// the application's own canonical messages, never caller input. |
| | | 87 | | /// </summary> |
| | | 88 | | /// <remarks> |
| | | 89 | | /// A blocked request is still a request: the deadline keeps running, and this is what makes |
| | | 90 | | /// the reason visible to the administrator who has to clear it, rather than the request |
| | | 91 | | /// failing and leaving no trace. |
| | | 92 | | /// </remarks> |
| | | 93 | | [MaxLength(500)] |
| | 307 | 94 | | public string? ErasureBlockedReason { get; set; } |
| | | 95 | | |
| | | 96 | | /// <summary>Email verification status.</summary> |
| | 1502 | 97 | | public bool EmailVerified { get; set; } |
| | | 98 | | |
| | | 99 | | /// <summary> |
| | | 100 | | /// Consecutive failed password attempts since the last successful login. Reset to zero on |
| | | 101 | | /// success, and the counter <see cref="LockedOutUntil" /> is derived from. |
| | | 102 | | /// </summary> |
| | 744 | 103 | | public int FailedLoginAttempts { get; set; } |
| | | 104 | | |
| | | 105 | | /// <summary> |
| | | 106 | | /// When set and still in the future, login is refused regardless of the password supplied — |
| | | 107 | | /// the per-account half of the brute-force defence, alongside the per-IP request limiter. The |
| | | 108 | | /// refusal is UC-11's ordinary <c>InvalidCredentials</c>, so a lockout is not observable to a |
| | | 109 | | /// caller who does not already know the password. |
| | | 110 | | /// </summary> |
| | 382 | 111 | | public DateTime? LockedOutUntil { get; set; } |
| | | 112 | | |
| | | 113 | | /// <summary> |
| | | 114 | | /// Failed second-factor verifications at sign-in (UC-38) since the last successful one, |
| | | 115 | | /// whatever challenge each was made against (FR-AU-12). A correct password does not reset it; |
| | | 116 | | /// only a second factor that checks out does. |
| | | 117 | | /// </summary> |
| | | 118 | | /// <remarks> |
| | | 119 | | /// This and the two columns after it are written only through <c>IAtomicWrites</c>, never by |
| | | 120 | | /// a whole-row save: the mapping ignores them after insert, so a save of a copy read before a |
| | | 121 | | /// failure was counted cannot write the count back down. The same holds for the three |
| | | 122 | | /// re-authentication columns below. |
| | | 123 | | /// </remarks> |
| | 681 | 124 | | public int FailedSecondFactorAttempts { get; set; } |
| | | 125 | | |
| | | 126 | | /// <summary> |
| | | 127 | | /// When set and still in the future, every second factor presented at sign-in is refused, |
| | | 128 | | /// right or wrong, with UC-38's ordinary refusal (FR-AU-12). |
| | | 129 | | /// </summary> |
| | 344 | 130 | | public DateTime? SecondFactorLockedOutUntil { get; set; } |
| | | 131 | | |
| | | 132 | | /// <summary> |
| | | 133 | | /// How many second-factor lockouts the account has had since its last successful second |
| | | 134 | | /// factor. Each lockout lasts twice as long as the one before it, up to a cap (FR-AU-12). |
| | | 135 | | /// </summary> |
| | 287 | 136 | | public int SecondFactorLockouts { get; set; } |
| | | 137 | | |
| | | 138 | | /// <summary> |
| | | 139 | | /// Failed re-authentications — a password or a second factor checked under a bearer token, such |
| | | 140 | | /// as disabling two-factor authentication or changing one's own email — in the window that |
| | | 141 | | /// began at <see cref="ReauthenticationWindowStartedAt" /> (FR-AU-13). Kept apart from |
| | | 142 | | /// <see cref="FailedLoginAttempts" />, so a stolen token cannot lock its owner out of signing in. |
| | | 143 | | /// </summary> |
| | 470 | 144 | | public int FailedReauthenticationAttempts { get; set; } |
| | | 145 | | |
| | | 146 | | /// <summary> |
| | | 147 | | /// When the first of the failures in <see cref="FailedReauthenticationAttempts" /> was counted, or |
| | | 148 | | /// <c>null</c> when none is. A failure after the window has run out starts a new one. |
| | | 149 | | /// </summary> |
| | 679 | 150 | | public DateTime? ReauthenticationWindowStartedAt { get; set; } |
| | | 151 | | |
| | | 152 | | /// <summary> |
| | | 153 | | /// When set and still in the future, every re-authentication is refused, right or wrong, with |
| | | 154 | | /// the same answer a wrong password or code gets (FR-AU-13). |
| | | 155 | | /// </summary> |
| | 452 | 156 | | public DateTime? ReauthenticationPausedUntil { get; set; } |
| | | 157 | | |
| | | 158 | | /// <summary> |
| | | 159 | | /// Account emails the person's own requests have had sent in the window ending at |
| | | 160 | | /// <see cref="AccountEmailWindowEndsAt" /> (FR-PE-16): verification links they asked for again, |
| | | 161 | | /// two-factor setup codes, and the notice an own email change sends to the previous address. |
| | | 162 | | /// Written only by the targeted write that reserves a send, like the factor budgets. |
| | | 163 | | /// </summary> |
| | 306 | 164 | | public int AccountEmailsSent { get; set; } |
| | | 165 | | |
| | | 166 | | /// <summary> |
| | | 167 | | /// When the current one-day window of <see cref="AccountEmailsSent" /> ends, or <c>null</c> |
| | | 168 | | /// before the first send. The first send after it ends opens a new window. |
| | | 169 | | /// </summary> |
| | 382 | 170 | | public DateTime? AccountEmailWindowEndsAt { get; set; } |
| | | 171 | | |
| | | 172 | | /// <summary> |
| | | 173 | | /// When the last of those sends was reserved, which the per-person cooldown is measured from |
| | | 174 | | /// (FR-PE-16), or <c>null</c> before the first. |
| | | 175 | | /// </summary> |
| | 382 | 176 | | public DateTime? AccountEmailLastSentAt { get; set; } |
| | | 177 | | |
| | | 178 | | /// <summary> |
| | | 179 | | /// A random value every token issued to this person carries, and must still carry when it is |
| | | 180 | | /// presented (FR-AU-11). Replaced whenever a credential changes — the password, the email, or |
| | | 181 | | /// the second factor — which revokes every token issued before the change. |
| | | 182 | | /// </summary> |
| | | 183 | | /// <remarks> |
| | | 184 | | /// Replaced only through <c>IAtomicWrites.RotateSecurityStampAsync</c>, and never written by a |
| | | 185 | | /// whole-row save: the mapping ignores it after insert. A save of a copy read before a |
| | | 186 | | /// rotation would otherwise write the old stamp back, and with it every token the rotation |
| | | 187 | | /// revoked. |
| | | 188 | | /// </remarks> |
| | 3230 | 189 | | public Guid SecurityStamp { get; set; } = Guid.NewGuid(); |
| | | 190 | | |
| | | 191 | | /// <summary>Foreign key to the associated <c>Role</c> (internal Id). Required.</summary> |
| | 3154 | 192 | | public long RoleId { get; set; } |
| | | 193 | | |
| | | 194 | | /// <summary> |
| | | 195 | | /// The scope a <c>User</c> belongs to (internal Id), or <c>null</c> for a <c>ScopeAdmin</c> |
| | | 196 | | /// or <c>SystemAdmin</c>. A copy of <see cref="ScopeMembership" />'s <c>ScopeId</c>, not a |
| | | 197 | | /// second source of truth: <c>SCOPE_USER</c> remains the relationship (§4.6). A read that only |
| | | 198 | | /// needs to know which scope a person is in may take this column directly — the email |
| | | 199 | | /// verification and password-reset services do, to find the scope's link bases — because it |
| | | 200 | | /// mirrors the membership at every place that writes either. |
| | | 201 | | /// </summary> |
| | | 202 | | /// <remarks> |
| | | 203 | | /// This exists for one reason — FR-PE-09's per-scope rule cannot otherwise be enforced. The |
| | | 204 | | /// scope lives in <c>SCOPE_USER</c> and the address in <c>PERSON</c>, and a PostgreSQL unique |
| | | 205 | | /// index covers one table, so the rule was left to a check-then-insert that two concurrent |
| | | 206 | | /// creates both pass. Carrying the scope here lets the index be written over columns this |
| | | 207 | | /// table already has — <c>role_id</c>, <c>is_deleted</c>, <c>email</c> — with a condition |
| | | 208 | | /// matching the application's check exactly, so nothing about the rule changes; only who |
| | | 209 | | /// enforces it does. |
| | | 210 | | /// |
| | | 211 | | /// Kept in step with <see cref="ScopeMembership" /> at the three places that write it: |
| | | 212 | | /// UC-06 path a sets both, UC-23 and UC-08's role change clear both. |
| | | 213 | | /// </remarks> |
| | 669 | 214 | | public long? ScopeId { get; set; } |
| | | 215 | | |
| | | 216 | | |
| | | 217 | | /// <summary> |
| | | 218 | | /// The lawful basis this identity's data is processed on (see <c>LegalBases</c>), recorded |
| | | 219 | | /// when the identity was created (GDPR Art. 5(2), LGPD Art. 8 §2). |
| | | 220 | | /// </summary> |
| | 269 | 221 | | public int LegalBasis { get; set; } |
| | | 222 | | |
| | | 223 | | /// <summary> |
| | | 224 | | /// The version of the privacy notice in force when the identity was created, or <c>null</c> |
| | | 225 | | /// for identities predating the mechanism. |
| | | 226 | | /// </summary> |
| | | 227 | | /// <remarks> |
| | | 228 | | /// Stored rather than looked up, for the same reason the erasure deadline is: what the |
| | | 229 | | /// person was told is a fact about a moment, and reading the current version later would |
| | | 230 | | /// answer a different question — what they would be told today. |
| | | 231 | | /// </remarks> |
| | | 232 | | [MaxLength(50)] |
| | 263 | 233 | | public string? PrivacyNoticeVersion { get; set; } |
| | | 234 | | |
| | | 235 | | /// <summary>When the basis and notice version were recorded.</summary> |
| | 264 | 236 | | public DateTime? BasisRecordedAt { get; set; } |
| | | 237 | | |
| | | 238 | | /// <summary> |
| | | 239 | | /// When processing of this identity was restricted (GDPR Art. 18, LGPD Art. 18 III–IV), or |
| | | 240 | | /// <c>null</c> if it is not restricted. |
| | | 241 | | /// </summary> |
| | | 242 | | /// <remarks> |
| | | 243 | | /// Independent of <see cref="IsDeleted" />, and necessarily so. Deletion means the identity |
| | | 244 | | /// is on its way out; restriction means it is disputed and must be preserved exactly as it |
| | | 245 | | /// is while that is resolved. Overloading the deletion flag would make a restricted record |
| | | 246 | | /// disappear from the administrator's view that has to resolve the dispute. |
| | | 247 | | /// </remarks> |
| | 1003 | 248 | | public DateTime? ProcessingRestrictedAt { get; set; } |
| | | 249 | | |
| | | 250 | | /// <summary> |
| | | 251 | | /// Which of Art. 18(1)'s four grounds the restriction rests on (see |
| | | 252 | | /// <c>RestrictionGrounds</c>), or <c>null</c> if not restricted. |
| | | 253 | | /// </summary> |
| | 319 | 254 | | public int? RestrictionGround { get; set; } |
| | | 255 | | |
| | | 256 | | /// <summary> |
| | | 257 | | /// When the subject was told the restriction was about to be lifted, or <c>null</c>. |
| | | 258 | | /// </summary> |
| | | 259 | | /// <remarks> |
| | | 260 | | /// GDPR Art. 18(3) requires the subject be informed <em>before</em> a restriction imposed at |
| | | 261 | | /// their request is lifted. This records that it happened, and the lift refuses to proceed |
| | | 262 | | /// without it. |
| | | 263 | | /// </remarks> |
| | 214 | 264 | | public DateTime? RestrictionLiftNotifiedAt { get; set; } |
| | | 265 | | |
| | | 266 | | |
| | | 267 | | /// <summary>Creation timestamp.</summary> |
| | 397 | 268 | | public DateTime CreatedAt { get; set; } |
| | | 269 | | |
| | | 270 | | /// <summary>Last update timestamp.</summary> |
| | 574 | 271 | | public DateTime UpdatedAt { get; set; } |
| | | 272 | | |
| | | 273 | | // Navigation properties |
| | | 274 | | |
| | | 275 | | /// <summary>The role that classifies this person.</summary> |
| | 192 | 276 | | public Role Role { get; set; } = null!; |
| | | 277 | | |
| | | 278 | | /// <summary> |
| | | 279 | | /// Scope-ownership join rows. Non-empty for a <c>ScopeAdmin</c> (one per owned scope); |
| | | 280 | | /// empty for a <c>User</c> or <c>SystemAdmin</c>. |
| | | 281 | | /// </summary> |
| | 3516 | 282 | | public ICollection<ScopeOwner> ScopeOwnerships { get; set; } = new List<ScopeOwner>(); |
| | | 283 | | |
| | | 284 | | /// <summary> |
| | | 285 | | /// Scope-membership join row. Present for a <c>User</c> (exactly one); <c>null</c> for a |
| | | 286 | | /// <c>ScopeAdmin</c> or <c>SystemAdmin</c>. |
| | | 287 | | /// </summary> |
| | 955 | 288 | | public ScopeUser? ScopeMembership { get; set; } |
| | | 289 | | |
| | | 290 | | /// <summary>Applications owned by this person.</summary> |
| | 2654 | 291 | | public ICollection<Application> OwnedApplications { get; set; } = new List<Application>(); |
| | | 292 | | |
| | | 293 | | /// <summary>Password reset tokens issued for this person.</summary> |
| | 2654 | 294 | | public ICollection<PasswordResetToken> PasswordResetTokens { get; set; } = new List<PasswordResetToken>(); |
| | | 295 | | |
| | | 296 | | /// <summary>Email verification tokens issued for this person.</summary> |
| | 2654 | 297 | | public ICollection<EmailVerificationToken> EmailVerificationTokens { get; set; } = new List<EmailVerificationToken>( |
| | | 298 | | |
| | | 299 | | /// <summary> |
| | | 300 | | /// This person's two-factor authentication configuration (UC-36 – UC-40). <c>null</c> until |
| | | 301 | | /// UC-36 initiates setup. |
| | | 302 | | /// </summary> |
| | 275 | 303 | | public TwoFactorAuth? TwoFactorAuth { get; set; } |
| | | 304 | | } |