< Summary - Heimdall API

Information
Class: ArturRios.Heimdall.Data.EntityMaps.PersonDbMap
Assembly: ArturRios.Heimdall.Data
File(s): /home/ci/agent/workspace/heimdall-api-docs_main/app/src/Infrastructure/ArturRios.Heimdall.Data/EntityMaps/PersonDbMap.cs
Line coverage
100%
Covered lines: 64
Uncovered lines: 0
Coverable lines: 64
Total lines: 153
Line coverage: 100%
Branch coverage
N/A
Covered branches: 0
Total branches: 0
Branch coverage: N/A
Method coverage
100%
Covered methods: 2
Fully covered methods: 2
Total methods: 2
Method coverage: 100%
Full method coverage: 100%

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
Configure(...)100%11100%
IgnoredAfterInsert(...)100%11100%

File(s)

/home/ci/agent/workspace/heimdall-api-docs_main/app/src/Infrastructure/ArturRios.Heimdall.Data/EntityMaps/PersonDbMap.cs

#LineLine coverage
 1using ArturRios.Heimdall.Domain.Entities;
 2using Microsoft.EntityFrameworkCore;
 3using Microsoft.EntityFrameworkCore.Metadata;
 4using Microsoft.EntityFrameworkCore.Metadata.Builders;
 5
 6namespace ArturRios.Heimdall.Data.EntityMaps;
 7
 8internal static class PersonDbMap
 9{
 10    public static void Configure(this EntityTypeBuilder<Person> person)
 11    {
 412        person.ToTable("person");
 13
 414        person.HasKey(x => x.Id);
 15
 416        person.Property(x => x.PublicId).IsRequired();
 417        person.HasIndex(x => x.PublicId).IsUnique();
 18
 419        person.Property(x => x.Name).IsRequired();
 20
 421        person.Property(x => x.Email).IsRequired();
 22
 23        // Email uniqueness is conditional (FR-PE-09), and its two halves differ in whether a unique
 24        // index can express them at all.
 25        //
 26        // The admin half — unique system-wide across live ScopeAdmins and SystemAdmins — reads only
 27        // columns of this table, so it is a partial unique index over LOWER(email), created in raw
 28        // SQL by the AddPersonEmailUniqueness migration (EF models neither an expression index nor
 29        // one filtered on a role set). It is the real enforcement: the application's check-then-
 30        // insert cannot be, since two concurrent creates both read "free" before either writes.
 31        //
 32        // The User half — unique per scope — is now a partial unique index too, created by the
 33        // AddPersonScopeId migration over (scope_id, LOWER(email)) WHERE role_id = 3 AND
 34        // is_deleted = false. It reads the scope from Person.ScopeId, the copy of SCOPE_USER's
 35        // ScopeId that exists for exactly this purpose: the relationship itself is still SCOPE_USER,
 36        // but an index has to be written over one table's columns, and every other column the rule
 37        // needs already lives here. The condition matches the application's check term for term, so
 38        // the rule did not change — only who is able to enforce it under concurrency.
 39        //
 40        // Lookup by address is served by ix_person_lower_email over LOWER(email), created by the
 41        // AddPersonLowerEmailIndex migration (#207). Login, password recovery, registration and Google
 42        // sign-in all compare LOWER(email) and deliberately leave out is_deleted, so neither a plain
 43        // index on the column nor the partial unique indexes above (they require is_deleted = false)
 44        // can serve them. Like those, it is raw SQL, which EF cannot express, so it is not mapped here.
 45
 446        person.Property(x => x.PasswordHash).IsRequired();
 447        person.Property(x => x.Salt).IsRequired();
 48
 449        person.Property(x => x.IsDeleted).HasDefaultValue(false);
 50
 51        // NFR-20's retention window is measured from DeletedAt, so the anonymisation pass reads it
 52        // on every run: indexed, and filtered to the rows that can possibly be due, which is a small
 53        // fraction of the table.
 454        person.Property(x => x.DeletedAt);
 455        person.Property(x => x.DeletionKind);
 456        person.Property(x => x.AnonymisedAt);
 57
 58        // NFR-23: the basis is required and defaults to Unrecorded, so a row can never be silent
 59        // about it — a null would be indistinguishable from "nobody has looked at this yet".
 460        person.Property(x => x.LegalBasis).HasDefaultValue(0);
 461        person.Property(x => x.PrivacyNoticeVersion);
 462        person.Property(x => x.BasisRecordedAt);
 63
 64        // NFR-24: indexed because every listing filters on it, and the restricted set is a small
 65        // fraction of the table.
 466        person.Property(x => x.ProcessingRestrictedAt);
 467        person.Property(x => x.RestrictionGround);
 468        person.Property(x => x.RestrictionLiftNotifiedAt);
 69
 470        person.HasIndex(x => x.ProcessingRestrictedAt)
 471            .HasFilter("processing_restricted_at IS NOT NULL");
 72
 473        person.HasIndex(x => x.DeletedAt)
 474            .HasFilter("is_deleted = true AND anonymised_at IS NULL");
 475        person.Property(x => x.EmailVerified).HasDefaultValue(false);
 76
 77        // FR-AU-11: the database fills the stamp for a row inserted without one, as the migration
 78        // filled the rows that predate it. After insert a whole-row save ignores it: only the
 79        // targeted write in AtomicWrites replaces it, so a save of a copy read before a rotation
 80        // cannot write the revoked stamp back.
 481        person.Property(x => x.SecurityStamp)
 482            .IsRequired()
 483            .HasDefaultValueSql("gen_random_uuid()")
 484            .Metadata.SetAfterSaveBehavior(PropertySaveBehavior.Ignore);
 85
 86        // FR-AU-12 and FR-AU-13: the two per-account factor budgets. Like the stamp, written only by
 87        // the targeted writes in AtomicWrites. A whole-row save of a copy read before a failure was
 88        // counted — a name change, say, sent alongside a guess — would otherwise put the count, the
 89        // lock or the pause back as that copy read it.
 490        IgnoredAfterInsert(person.Property(x => x.FailedSecondFactorAttempts));
 491        IgnoredAfterInsert(person.Property(x => x.SecondFactorLockedOutUntil));
 492        IgnoredAfterInsert(person.Property(x => x.SecondFactorLockouts));
 493        IgnoredAfterInsert(person.Property(x => x.FailedReauthenticationAttempts));
 494        IgnoredAfterInsert(person.Property(x => x.ReauthenticationWindowStartedAt));
 495        IgnoredAfterInsert(person.Property(x => x.ReauthenticationPausedUntil));
 96
 97        // FR-PR-03 and FR-PR-08: the password hash and salt, for the reason the stamp is. Set at
 98        // insert; after that only the targeted writes in AtomicWrites change them — a reset and
 99        // anonymisation — so a whole-row save of a copy read before a reset, an administrator's
 100        // edit racing the person's own reset for one, cannot put the old password back.
 4101        IgnoredAfterInsert(person.Property(x => x.PasswordHash));
 4102        IgnoredAfterInsert(person.Property(x => x.Salt));
 103
 104        // FR-PE-16: the account-email budget, for the same reason — a whole-row save of a copy read
 105        // before a send was reserved would hand the day's allowance back.
 4106        IgnoredAfterInsert(person.Property(x => x.AccountEmailsSent));
 4107        IgnoredAfterInsert(person.Property(x => x.AccountEmailWindowEndsAt));
 4108        IgnoredAfterInsert(person.Property(x => x.AccountEmailLastSentAt));
 109
 110        // The lifecycle columns, for the same reason again (#237): who the person is to the system —
 111        // their role and scope, whether their address is verified, locked out, restricted, deleted,
 112        // asked to be erased or anonymised — and the address itself. Each changes only through a
 113        // targeted, conditional write in AtomicWrites, so a whole-row save of a copy read before
 114        // the change — a name edit racing a deletion, say, or an ownership grant racing a
 115        // restriction — cannot put the old state back. What a whole-row save still writes is the
 116        // plain profile: the name, the recorded basis and notice version, and UpdatedAt.
 4117        IgnoredAfterInsert(person.Property(x => x.Email));
 4118        IgnoredAfterInsert(person.Property(x => x.EmailVerified));
 4119        IgnoredAfterInsert(person.Property(x => x.RoleId));
 4120        IgnoredAfterInsert(person.Property(x => x.ScopeId));
 4121        IgnoredAfterInsert(person.Property(x => x.FailedLoginAttempts));
 4122        IgnoredAfterInsert(person.Property(x => x.LockedOutUntil));
 4123        IgnoredAfterInsert(person.Property(x => x.IsDeleted));
 4124        IgnoredAfterInsert(person.Property(x => x.DeletedAt));
 4125        IgnoredAfterInsert(person.Property(x => x.DeletionKind));
 4126        IgnoredAfterInsert(person.Property(x => x.ErasureRequestedAt));
 4127        IgnoredAfterInsert(person.Property(x => x.ErasureDueAt));
 4128        IgnoredAfterInsert(person.Property(x => x.ErasureBlockedReason));
 4129        IgnoredAfterInsert(person.Property(x => x.ProcessingRestrictedAt));
 4130        IgnoredAfterInsert(person.Property(x => x.RestrictionGround));
 4131        IgnoredAfterInsert(person.Property(x => x.RestrictionLiftNotifiedAt));
 4132        IgnoredAfterInsert(person.Property(x => x.AnonymisedAt));
 133
 134        // Deliberately not a foreign key and not a navigation. It carries no relationship of its own
 135        // — SCOPE_USER is the relationship, and mapping this as a second one would give EF two ways
 136        // to describe the same association and two chances to disagree. It is a value the per-scope
 137        // uniqueness index is written over, kept in step by the handlers that write the membership.
 4138        person.Property(x => x.ScopeId);
 139
 4140        person.Property(x => x.CreatedAt).HasDefaultValueSql("now()");
 4141        person.Property(x => x.UpdatedAt).HasDefaultValueSql("now()");
 142
 143        // Role classifies Person (FR-RO-01). Roles are reference data, so deletion is restricted.
 4144        person.HasOne(x => x.Role)
 4145            .WithMany(r => r.Persons)
 4146            .HasForeignKey(x => x.RoleId)
 4147            .IsRequired()
 4148            .OnDelete(DeleteBehavior.Restrict);
 4149    }
 150
 151    private static void IgnoredAfterInsert<T>(PropertyBuilder<T> property) =>
 108152        property.Metadata.SetAfterSaveBehavior(PropertySaveBehavior.Ignore);
 153}