Domain model

The entities, their relationships, and the deletion cascade rules — as class diagrams.

The domain layer is intentionally anemic: entities are data holders with properties and navigation collections. Decisions live in the command and query handlers, so the diagrams below are a map of shape and relationship, not of behaviour.

The core model

The entities every request touches: a scope, the persons related to it through the two join tables, the applications and Google Users it contains, and the permissions it defines.

classDiagram
    direction LR

    class Scope {
        +Guid PublicId
        +string Name
        +string? Description
        +bool IsDeleted
        +bool GoogleSignInEnabled
    }
    class Person {
        +Guid PublicId
        +string Name
        +string Email
        +byte[] PasswordHash
        +byte[] Salt
        +bool IsDeleted
        +bool EmailVerified
        +long RoleId
    }
    class Role {
        +Guid PublicId
        +string Name
        +string? Description
    }
    class ScopeOwner {
        +long ScopeId
        +long PersonId
    }
    class ScopeUser {
        +long ScopeId
        +long PersonId
    }
    class Application {
        +Guid PublicId
        +string Name
        +bool IsDeleted
        +long ScopeId
        +long OwnerId
    }
    class GoogleUser {
        +Guid PublicId
        +string GoogleId
        +string Name
        +string Email
        +bool EmailVerified
        +string? ProfilePictureUrl
        +bool IsDeleted
        +long ScopeId
    }
    class ScopePermission {
        +Guid PublicId
        +string Name
        +string? Description
        +bool IncludeAsJwtClaim
        +bool IsDeleted
        +long ScopeId
    }

    Role "1" o-- "0..*" Person : classifies
    Scope "1" o-- "0..*" ScopeOwner : has
    Person "1" o-- "0..*" ScopeOwner : owns via
    Scope "1" o-- "0..*" ScopeUser : has
    Person "1" o-- "0..1" ScopeUser : belongs via
    Scope "1" o-- "0..*" Application : contains
    Person "1" o-- "0..*" Application : owns
    Scope "1" o-- "0..*" GoogleUser : contains
    Scope "1" o-- "0..*" ScopePermission : defines

Credentials, tokens and the audit trail

The entities hanging off a person: their two-factor configuration with its codes, the two kinds of single-use token, and the append-only audit log.

classDiagram
    direction LR

    class Person {
        +Guid PublicId
        +string Email
        +bool EmailVerified
        +int FailedLoginAttempts
        +DateTime? LockedOutUntil
        +int FailedSecondFactorAttempts
        +DateTime? SecondFactorLockedOutUntil
        +int SecondFactorLockouts
        +int FailedReauthenticationAttempts
        +DateTime? ReauthenticationWindowStartedAt
        +DateTime? ReauthenticationPausedUntil
        +int AccountEmailsSent
        +DateTime? AccountEmailWindowEndsAt
        +DateTime? AccountEmailLastSentAt
        +long? ScopeId
    }
    class TwoFactorAuth {
        +long PersonId
        +bool AppEnabled
        +bool EmailEnabled
        +byte[]? TotpSecretEncrypted
        +bool IsActive
        +long? LastTotpTimeStepUsed
        +int EmailCodeReissueCount
        +Guid? ChallengeId
        +int ChallengeAttempts
    }
    class TwoFactorEmailCode {
        +long TwoFactorAuthId
        +byte[] CodeHash
        +byte[] Salt
        +DateTime ExpiresAt
        +bool Used
        +int FailedAttempts
    }
    class TwoFactorRecoveryCode {
        +long TwoFactorAuthId
        +byte[] CodeHash
        +bool Used
        +DateTime? UsedAt
    }
    class EmailVerificationToken {
        +long PersonId
        +string Token
        +DateTime ExpiresAt
        +bool Used
    }
    class PasswordResetToken {
        +long PersonId
        +string Token
        +DateTime ExpiresAt
        +bool Used
    }
    class AuditLog {
        +Guid PublicId
        +Guid? ActorPersonId
        +int? ActorRole
        +string Action
        +Guid? TargetId
        +DateTime CreatedAt
    }

    Person "1" o-- "0..1" TwoFactorAuth : configures
    TwoFactorAuth "1" o-- "0..*" TwoFactorEmailCode : issues
    TwoFactorAuth "1" o-- "0..*" TwoFactorRecoveryCode : issues
    Person "1" o-- "0..*" EmailVerificationToken : holds
    Person "1" o-- "0..*" PasswordResetToken : holds

AuditLog is deliberately unconnected. Its ActorPersonId is a bare PublicId, not a foreign key, so an entry survives the hard deletion of the person who made the write.

Common shape

Every entity above derives from Entity<long>, which contributes the internal key, and each one that is addressable from outside also carries timestamps:

classDiagram
    direction LR
    class Entity~long~ {
        <<abstract>>
        +long Id
    }
    class AddressableEntity {
        <<pattern>>
        +Guid PublicId
        +DateTime CreatedAt
        +DateTime UpdatedAt
    }
    Entity~long~ <|-- AddressableEntity

Scope, Role, Person, Application, GoogleUser, ScopePermission, AuditLog and PendingRegistration follow the addressable pattern. The join rows, token rows and two-factor rows carry only the internal Id — see the table below for why.

Which entities carry a PublicId

Has PublicIdInternal Id only
Scope, Role, Person, Application, GoogleUser, ScopePermission, AuditLog, PendingRegistrationScopeOwner, ScopeUser, TwoFactorAuth, TwoFactorEmailCode, TwoFactorRecoveryCode, EmailVerificationToken, PasswordResetToken

The right-hand column is never addressed by ID from outside:

  • Join rows (ScopeOwner, ScopeUser) are not independently addressable resources — they are reached through the scope and person they connect.
  • Token rows are reached by their random Token string, which is already the caller-facing reference.
  • Two-factor rows are reached implicitly through the authenticated person’s own identity — a person’s configuration is never named by ID in a path.

PendingRegistration carries a PublicId for administrators only: a scope’s owners list and discard registrations by it (UC-49, UC-50). The registrant never sees it — their reference is the mailed token.

Secrets at rest

ValueHow it is storedEver returned?
PasswordArgon2id hash + per-person random saltNever
TOTP secretEncrypted via ASP.NET Core Data Protection (ITotpSecretProtector)Once, at provisioning, so the authenticator app can be enrolled
Recovery codesSHA-256 of the canonical code, no per-code salt (80 random bits, FR-2F-20)Once, in the response that generates them
2FA email codeHash + per-code salt (only 10⁶ possible values, so it is treated as a user-grade secret)Never — it is mailed
Email verification / password reset tokenSHA-256 digest only (SingleUseTokenHash)Once, by email

That split is NFR-16. The email code gets a salt and the recovery code does not for a concrete reason: a six-digit code has a million possible values and would fall to a rainbow table without one, while a recovery code is 80 random bits, where a plain one-way hash already makes the plaintext unrecoverable: finding one from its digest takes about 2^79 SHA-256 evaluations, and even a search over a million stored codes at once leaves about 2^60 (Threat Model TH-16).

Deletion cascades

flowchart TD
    A[Delete request] --> B{Logical or hard?}

    B -->|Logical| C[Set IsDeleted = true]
    C --> C1{Entity}
    C1 -->|Scope| C2["Cascades to its Users,<br/>Google Users, applications<br/>— not to its owners,<br/>not to its permissions"]
    C1 -->|Person / Google User /<br/>Application / Permission| C3[Only its own flag]
    C2 & C3 --> D[Row stays, excluded from<br/>default queries]

    B -->|Hard| F{Entity}
    F -->|Scope| G["Removes SCOPE_OWNER/SCOPE_USER rows,<br/>its Users, Google Users,<br/>applications and permissions"]
    F -->|ScopeAdmin| H["Removes SCOPE_OWNER rows,<br/>owned applications,<br/>2FA config + recovery codes"]
    F -->|User| I["Removes SCOPE_USER row,<br/>2FA config + recovery codes"]
    F -->|Google User /<br/>Application / Permission| J[Leaf — nothing cascades]
    H --> K{Would any owned scope<br/>be left ownerless?}
    K -->|Yes| L["Rejected — NFR-12"]
    K -->|No| M[Row removed]
    G & I & J --> M

The rules that most often catch people out:

Logically deleting a scope does not touch its permissions. Applications cascade; permissions do not. They become unreachable anyway — the listing endpoint gates on the scope’s IsDeleted, and the JWT-claim fold at login reads only permissions of non-deleted scopes — so a restored scope recovers its permission set unchanged. A hard delete does purge them, through the foreign key’s ON DELETE CASCADE.

Logically deleting a scope does not touch its owners. A Scope Admin may own other, active scopes.

A scope must always retain at least one owner (NFR-12) — removing the last owner, or hard-deleting the last owning person, is rejected. NFR-12 does not guard the other direction: hard-deleting a scope can leave a Scope Admin owning nothing. That record is left in place on purpose (they may be about to be given another scope), and it grants no access in the meantime, because a ScopeAdmin with no live owned scope cannot authenticate (FR-AU-07).

Logical deletion never touches two-factor state. A restored person keeps whatever configuration they had. Hard deletion removes it, via two_factor_recovery_code → two_factor_auth → person.

The authoritative version of all of this — including the reasoning per rule — is §8 of the System Requirements Document .

Persistence mapping

Each entity has a map class under ArturRios.Heimdall.Data/EntityMaps/ (PersonDbMap, ScopeOwnerDbMap, …) applied by AppDbContext. Constraints that the C# class cannot express live there: the composite key of ScopeOwner, the uniqueness of ScopeUser.PersonId (which is what makes “a User belongs to exactly one scope” true rather than merely intended), the per-scope uniqueness of GoogleUser.GoogleId and GoogleUser.Email, and the cascade behaviour above.

Email uniqueness

FR-PE-09 and FR-GO-07 make an address unique in a namespace chosen by role, and the three halves differ in how far the database can enforce them.

RuleEnforced by
An administrator’s address is unique system-wide among live ScopeAdmins and SystemAdminsux_person_admin_email, a partial unique index on LOWER(email) WHERE role_id IN (1, 2) AND is_deleted = false
A Google User’s address is unique within their scopeix_google_user_scope_id_email, unique on (scope_id, LOWER(email))
A User’s address is unique within their scopeux_person_scope_user_email, unique on (scope_id, LOWER(email)) where role_id = 3 AND is_deleted = false
…jointly with that scope’s Google UsersThe application layer — CreateUserCommandHandler, UpdatePersonCommandHandler, GoogleSignInCommandHandler

All three indexes are over LOWER(email) because the handlers compare addresses case-insensitively; an index over the raw column would enforce a different rule than the one the code applies, and accept pairs a handler had already refused.

The scope a User belongs to, twice

PERSON.scope_id is a copy of the owning SCOPE_USER row’s scope, and exists for one reason: the third rule above could not otherwise be enforced. The scope lives in SCOPE_USER and the address in PERSON, a PostgreSQL unique index covers one table, and a trigger closes nothing — under READ COMMITTED two concurrent inserts see the same pre-write snapshot and both find the address free. Only a unique index serialises them, and an index needs its columns on one table.

SCOPE_USER remains the relationship (§4.6) and every read goes through it. The copy is written by the three handlers that write the membership — UC-06 path a sets both, UC-23 and UC-08’s role change clear both — and the index’s condition matches CreateUserCommandHandler’s check term for term, so the rule itself did not change. Only who enforces it under concurrency did.

A caller who loses that race is answered by AF-06a, the same conflict they would have got had the address already been taken when they asked, rather than a persistence failure: losing a race is not something the API makes visible.