Requirements
The specification: vision, system requirements, use cases, technology stack, data protection, testing, workflow, and operations.
These fourteen documents are the specification for Heimdall. Everything else on this site describes
what was built; these say what must be true.
They are also the working documents of the project, not a retrospective write-up: a feature is
implemented by picking a use case, following its main and alternative flows, and citing the
requirement identifiers in the code.
| Document | What it settles |
|---|
| Vision Document | Why the system exists, who it serves, and what success looks like. |
| System Requirements Document | The functional (FR-…) and non-functional (NFR-…) requirements, the data model, the endpoints, the authorization matrix, and the deletion strategy. |
| Use Case Specification Document | Every use case (UC-…) with its main flow and numbered alternative flows (AF-11a, AF-11b, …). |
| Technology Stack Document | The technologies, libraries, and pinned versions. The single source of truth for versions. |
| Data Retention Schedule Document | How long each category of personal data is kept and why — the record NFR-19 requires, and the source of truth the retention passes are configured against. |
| Data Protection Document | Who controls what, on what basis, shared with whom, and across which borders — the record LGPD Art. 37 and GDPR Art. 30 require. |
| Data Protection Impact Assessment | The risks this system creates for the people in it, rated for them rather than for the system. |
| Privacy Notice | What Heimdall does with personal data, told to the people it belongs to. Versioned, and the Encarregado’s published contact. |
| Data Processing Agreement | The terms on which Heimdall processes a tenant’s users’ data, and the sub-processor list. |
| Incident Response Document | What counts as a personal data breach, who declares one, the clocks that start, and who has to be told. |
| Testing Specification Document | What to test for each use case, the unit and functional standards, and the required coverage. |
| Development Workflow Document | How a use case goes from backlog to merged — branch, issue status, testing gate, pull request. |
| Operations & Infrastructure Document | The technical foundation and the health-check feature. |
Identifier conventions
| Prefix | Meaning | Example |
|---|
FR-XX-nn | Functional requirement, grouped by capability | FR-AU-04 — the claims an auth token carries |
NFR-nn | Non-functional requirement | NFR-15 — internal ids never reach a caller |
UC-nn | Use case | UC-11 — Login |
AF-nna | Alternative flow within a use case | AF-11g — the person has active 2FA |
The capability groups are SC (scope), PE (person), RO (role), AU (authentication), PR
(password recovery), EV (email verification), AP (application), GO (Google Sign-In), SP
(scope permission), 2F (two-factor), HC (health check).
Rendered in place, not copied
The pages in this section are the very files under
docs/requirements/
in the
repository, mounted into this site by Hugo. There is no second copy to keep in sync — editing the
Markdown updates both GitHub and this site.
1 - Vision Document
Why Heimdall exists, who it serves, its stakeholders, goals, and what success looks like.
Vision Document — Heimdall API
1. Introduction
1.1 Purpose
This document establishes the vision for the Heimdall API, a .NET Web API responsible for centralized management of user identities, authentication, and authorization across multiple independent systems.
1.2 Scope
The Heimdall API will serve as a single source of truth for person management, application management, authentication, role-based access control, and multi-tenant scoping. It is designed to be consumed by multiple client systems, each operating within its own isolated scope. Beyond human persons, the system also registers Applications — non-human identities representing other systems, each owned by a Scope Admin who owns the application’s scope.
1.3 Definitions and Acronyms
| Term | Definition |
|---|
| Scope | A logical tenant boundary that isolates owners, users, and applications belonging to different client systems |
| Person | A registered identity whose relationship to scopes depends on its role: a User belongs to exactly one scope, a Scope Admin owns one or more scopes, and a System Admin belongs to no scope |
| Application | A non-human registered identity within exactly one scope, representing another system rather than a person, and owned by exactly one Scope Admin who owns that scope |
| Scope Owner | A Scope Admin who owns a scope; a scope may have multiple owners and a Scope Admin may own multiple scopes |
| Scope User | A User belonging to exactly one scope |
| Role | A permission level assigned to a person (User, Scope Admin, System Admin), stored as a reference to a dedicated Role table |
| Salt | A random byte array unique to each person, combined with their password before hashing |
| Google User | A registered identity within exactly one scope, authenticated via Google Sign-In instead of a password; always equivalent to the User role and never a Scope Admin or System Admin |
| Internal Id | An auto-incrementing bigint primary key used only inside the database, for joins and foreign keys. Never exposed in API responses, URLs, or tokens |
| Public Id | A GUID assigned to every top-level entity at creation, used as its externally-facing identifier in API paths, response bodies, and authentication tokens, keeping internal record counts and ordering opaque to callers |
| Logical Deletion | Soft-deleting a record by flagging it as inactive without removing it from the database |
| Hard Deletion | Permanently removing a record from the database |
2. Problem Statement
Organizations integrating multiple systems face a recurring challenge: each system independently manages users, credentials, and permissions, leading to duplicated data, inconsistent security policies, and poor user experience. There is no unified mechanism for authentication, user lifecycle management, or cross-system identity governance.
3. Product Position Statement
| Attribute | Description |
|---|
| For | Development teams and organizations operating multiple systems |
| Who | Need centralized user identity and authentication management |
| The Heimdall API | Is a .NET Web API |
| That | Provides scoped person management, role-based access, authentication, password recovery, and email verification |
| Unlike | System-specific user management modules that create data silos |
| Our product | Offers a single, multi-tenant identity service with clear scope isolation and flexible deletion strategies |
4. Stakeholders
| Stakeholder | Role | Concern |
|---|
| System Admin | Global administrator, belongs to no scope | Full control over all scopes, persons, and applications |
| Scope Admin | Owner of one or more scopes | Management of the users and applications within the scopes they own |
| End User | Consumer of client systems, belongs to exactly one scope | Seamless authentication, password recovery, and email verification |
| Client Systems | External applications | Reliable API for authentication and person data retrieval |
5. High-Level Architecture
C4Context
title Heimdall API — Context Diagram
Person(sysAdmin, "System Admin", "Manages all scopes, persons, and applications; belongs to no scope")
Person(scopeAdmin, "Scope Admin", "Owns one or more scopes and manages their users and applications")
Person(user, "End User", "Belongs to exactly one scope; authenticates and manages own profile")
System(api, "Heimdall API", ".NET Web API for identity management")
System_Ext(clientA, "Client System A", "Consumes identity services under Scope A")
System_Ext(clientB, "Client System B", "Consumes identity services under Scope B")
System_Ext(emailSvc, "Email Service", "Sends verification and recovery emails")
Rel(sysAdmin, api, "Manages scopes, persons & applications")
Rel(scopeAdmin, api, "Manages users & applications in owned scopes")
Rel(user, api, "Authenticates, recovers password, verifies email")
Rel(clientA, api, "Authenticates users, queries persons")
Rel(clientB, api, "Authenticates users, queries persons")
Rel(api, emailSvc, "Sends emails")
6. Core Features
| ID | Feature | Description |
|---|
| F-01 | Person CRUD | Create, read, update, and delete person records |
| F-02 | Scope CRUD | Create, read, update, and delete scope records |
| F-03 | Logical Deletion | Soft-delete persons and scopes via a boolean flag |
| F-04 | Hard Deletion | Permanently remove persons and scopes from storage |
| F-05 | Scope Isolation | Every User belongs to exactly one scope; every scope may have one or more owners (Scope Admins) and one or more Users |
| F-06 | Role Assignment | Each person is assigned a role: User, Scope Admin, or System Admin, each with a distinct relationship to scopes |
| F-07 | Authentication | Verify credentials and issue authentication tokens |
| F-08 | Password Recovery | Allow users to reset their password via email |
| F-09 | Email Verification | Confirm person email addresses through a verification flow |
| F-10 | Application Management | Create, read, update, and delete Application records representing non-person systems, each owned by a Scope Admin who owns the application’s scope |
| F-11 | Google Sign-In | Allow a Scope Admin to enable Google-based sign-up/sign-in for their scope, letting Users register and authenticate with a Google account instead of a password |
7. Domain Model Overview
erDiagram
SCOPE || --o{ APPLICATION : contains
ROLE || --o{ PERSON : classifies
PERSON || --o{ APPLICATION : owns
SCOPE || --o{ SCOPE_OWNER : has
PERSON || --o{ SCOPE_OWNER : owns
SCOPE || --o{ SCOPE_USER : has
PERSON || --o| SCOPE_USER : "belongs to"
SCOPE || --o{ GOOGLE_USER : contains
PERSON {
bigint Id PK
guid PublicId
string Name
string Email
bytes PasswordHash
bytes Salt
bigint RoleId FK
bool IsDeleted
}
SCOPE {
bigint Id PK
guid PublicId
string Name
bool IsDeleted
bool GoogleSignInEnabled
}
GOOGLE_USER {
bigint Id PK
guid PublicId
string GoogleId
string Name
string Email
bool EmailVerified
string ProfilePictureUrl
bool IsDeleted
bigint ScopeId FK
}
ROLE {
bigint Id PK
guid PublicId
string Name
string Description
}
APPLICATION {
bigint Id PK
guid PublicId
string Name
bool IsDeleted
bigint ScopeId FK
bigint OwnerId FK
}
SCOPE_OWNER {
bigint ScopeId FK
bigint PersonId FK
}
SCOPE_USER {
bigint ScopeId FK
bigint PersonId FK
}Note: PERSON has no ScopeId attribute. A User’s scope comes from its single SCOPE_USER row; a ScopeAdmin’s scopes come from its one-or-more SCOPE_OWNER rows; a SystemAdmin has neither. GOOGLE_USER is a separate table from PERSON — it has its own ScopeId FK directly, since a Google User always represents the User role and never needs the owner/multi-scope semantics that justify SCOPE_OWNER/SCOPE_USER for PERSON. Its fields mirror PERSON as closely as the Google identity claims allow (Id, Name, Email, IsDeleted), replacing PasswordHash/Salt with GoogleId, EmailVerified, and ProfilePictureUrl sourced from Google’s ID token.
Every top-level entity (SCOPE, ROLE, PERSON, APPLICATION, GOOGLE_USER) has two identifiers: an internal Id (bigint, auto-increment) used only for storage and foreign keys, and a PublicId (GUID) used everywhere the entity is addressed from outside the database — API paths, response bodies, and authentication token claims. Foreign keys (ScopeId, RoleId, OwnerId, and the columns in SCOPE_OWNER/SCOPE_USER) reference the internal bigint Id, not the PublicId. Join tables (SCOPE_OWNER, SCOPE_USER) are not directly addressable resources, so they carry no PublicId of their own.
8. Roles Hierarchy
graph TD
SA["System Admin<br/>(belongs to no scope)"]
SCA["Scope Admin<br/>(owns 1+ scopes)"]
U["User<br/>(belongs to 1 scope)"]
SA -->|manages all scopes| SCA
SA -->|manages all persons & applications| U
SCA -->|manages users & applications in owned scopes| U
SCA -->|"promotes / creates as co-owner"| U| Role | Scope Relationship | Permissions |
|---|
| System Admin | Belongs to no scope | Full access: manage all scopes, all persons, and all applications across the entire system |
| Scope Admin | Owns one or more scopes | Manage the users and applications within the scopes they own; add co-owners to their own scope either by creating a new Scope Admin or by promoting an existing User of that scope; add/remove existing Scope Admins as co-owners |
| User | Belongs to exactly one scope | Authenticate, view own profile, recover password, verify email |
9. Constraints
- The API must be built with .NET (ASP.NET Core Web API). The full technology stack and versions are defined in the Technology Stack Document
.
- A person has no direct scope attribute; its scope relationship is determined by its role.
- A
User person must belong to exactly one scope. - A
ScopeAdmin person must own at least one scope, and does not belong to any scope as a user. - A
SystemAdmin person must not belong to any scope, either as an owner or as a user, and has all permissions across the system. - A scope must have one or more owners, each a
ScopeAdmin, and may have one or more User persons. - A Scope Admin may add a co-owner to a scope they own either by creating a brand-new Scope Admin person, or by promoting an existing User of that scope (which removes that person’s User scope membership).
- Logical deletion must not remove data; it must set a flag.
- Hard deletion must permanently erase data.
- Email verification and password recovery require an external email delivery mechanism.
- Passwords must be stored as a
PasswordHash byte array together with a per-person Salt byte array. - Roles must be stored in a dedicated Role table (
Id, Name, Description) and referenced by persons, not stored as a raw string or enum on the person record. - Every application must belong to exactly one scope and have exactly one owner, which must be a Person associated with that scope (a User who belongs to it, or a Scope Admin who owns it).
- A scope has a
GoogleSignInEnabled flag, off by default, that only its owners (or a System Admin) may turn on or off. - Only accounts equivalent to the
User role may sign up or sign in via Google; Google authentication must never create or authenticate a Scope Admin or System Admin. - A Google User belongs to exactly one scope and is subject to the same
IsDeleted lifecycle (logical and hard deletion) as a Person. - A Google User’s email must be unique within its scope, considered jointly with the emails of
User persons in that scope — the same email cannot be registered twice via Google, nor shared with a password-based User account in the same scope. - Every top-level entity has an internal auto-increment
bigint Id (never exposed) and a PublicId GUID (used in all API paths, responses, and tokens), to keep resource enumeration and record counts opaque to callers.
10. Success Criteria
- Client systems can register, authenticate, and manage users within their own scope without affecting other scopes.
- System admins can govern all scopes, persons, and applications from a single API, without belonging to any scope themselves.
- A scope can be co-owned by multiple Scope Admins, and a Scope Admin can own multiple scopes.
- Password recovery and email verification flows complete end-to-end.
- Both logical and hard deletion strategies are available and operate correctly.
- Persons can register and manage Applications representing other systems within their own scope.
- A Scope Admin can turn on Google Sign-In for a scope they own, letting Users of that scope sign up and sign in with a Google account.
2 - System Requirements Document
Functional and non-functional requirements, the data model, endpoints, authorization matrix, and deletion strategy.
System Requirements Document — Heimdall API
1. Introduction
1.1 Purpose
This document specifies the functional and non-functional requirements for the Heimdall API, a .NET Web API that provides centralized person management, application management, authentication, and authorization for multiple client systems through scope-based tenancy.
The concrete technology stack — framework and language versions, first-party libraries, database, data-access, and tooling — is defined in the Technology Stack Document
. This document states requirements and refers to that one for specific technologies and versions rather than restating them.
1.2 Scope
The system encompasses person CRUD, application CRUD, scope CRUD, scope-specific permission CRUD (permissions that may be folded into the JWT at issue), role-based access control, authentication (including Google Sign-In and optional two-factor authentication), password recovery, email verification, and both logical and hard deletion strategies.
1.3 Definitions
| Term | Definition |
|---|
| Person | A registered identity containing id, name, email, role, and deletion status. Has no direct scope attribute — its relationship to scopes depends on its role |
| Application | A registered non-human identity representing another system, containing id, name, and deletion status, associated with exactly one scope and owned by exactly one ScopeAdmin person who owns that scope |
| Scope | A logical boundary that groups owners, persons, and applications belonging to a specific client system |
| Scope Owner | A Person with the ScopeAdmin role who owns a scope; a scope may have one or more owners, and a Scope Admin may own one or more scopes (many-to-many relationship) |
| Scope User | A Person with the User role who belongs to exactly one scope (many-to-one relationship) |
| Scope Permission | A scope-specific permission associated with exactly one scope, carrying a Name, an optional Description, and an IncludeAsJwtClaim flag. When the flag is set the permission’s Name is added as a claim on the JWT issued to identities acting within that scope. A scope permission has no separate owner of its own — owning the scope is the whole of the authorization to manage it |
| Google User | A registered identity authenticated via Google Sign-In rather than a password, stored in its own GOOGLE_USER table, belonging to exactly one scope, and always equivalent to the User role |
| Two-Factor Authentication (2FA) | An opt-in second authentication step for a Person (User, ScopeAdmin, or SystemAdmin; not available to Google Users, who are already subject to Google’s own account security). Configured with an authenticator-app method, an email method, or both, and backed by a set of single-use recovery codes |
| TOTP Secret | A randomly generated shared secret used to compute time-based one-time codes (RFC 6238) for the authenticator-app 2FA method; stored encrypted at rest and never re-exposed once provisioning is confirmed |
| Recovery Code | One of ten single-use codes issued when 2FA is confirmed (or regenerated); stored hashed, shown to the person exactly once, and consumable in place of a normal second-factor code to complete a login |
| Challenge Token | A short-lived, narrowly-scoped token issued in place of a full authentication token when login succeeds for a person with active 2FA; valid only against the second-factor verification endpoint and rejected everywhere else |
| Logical Deletion | Setting the IsDeleted flag to true without removing the record |
| Hard Deletion | Permanently removing the record from the database |
| Role | A named permission level (User, ScopeAdmin, or SystemAdmin) stored in the Role table and referenced by a person via RoleId |
| Salt | A randomly generated byte array combined with a person’s password prior to hashing, stored alongside the password hash |
| Google ID Token | A signed JSON Web Token issued by Google after a successful Google sign-in, containing identity claims (sub, email, email_verified, name, picture) that the API verifies and uses to provision or authenticate a Google User |
| Internal Id | An auto-incrementing bigint primary key, generated by the database, used only for storage and foreign keys. Never returned in API responses, accepted in API paths, or embedded in tokens |
| Public Id | A GUID generated when a top-level entity (Scope, Role, Person, Application, Google User, Scope Permission) is created, used as its externally-facing identifier everywhere outside the database — API paths, response bodies, and authentication token claims |
2. System Overview
graph LR
subgraph Clients
C1[Client System A]
C2[Client System B]
C3[Client System N]
end
subgraph Heimdall API
AUTH[Authentication Module]
PM[Person Management]
AM[Application Management]
SM[Scope Management]
PR[Password Recovery]
EV[Email Verification]
GS[Google Sign-In Module]
SPM[Scope Permission Management]
TFA[Two-Factor Authentication Module]
end
subgraph Infrastructure
DB[(Database)]
EMAIL[Email Service]
GOOGLE[Google Identity Platform]
end
C1 & C2 & C3 --> AUTH
C1 & C2 & C3 --> PM
C1 & C2 & C3 --> AM
C1 & C2 & C3 --> SM
C1 & C2 & C3 --> GS
AUTH --> DB
PM --> DB
AM --> DB
SM --> DB
PR --> EMAIL
EV --> EMAIL
PR --> DB
EV --> DB
GS --> DB
GS --> GOOGLE
SPM --> DB
TFA --> DB
TFA --> EMAIL
AUTH --> TFA
3. Functional Requirements
3.1 Scope Management
| ID | Requirement | Priority |
|---|
| FR-SC-01 | The system shall allow System Admins to create a new scope with a unique name and at least one initial owner (a person with the ScopeAdmin or SystemAdmin role — the System Admin creating it may own it) | High |
| FR-SC-02 | The system shall allow authorized users to read scope details by ID: a System Admin any scope, a Scope Admin the scopes they own, a User the scope they belong to | High |
| FR-SC-03 | The system shall allow System Admins to list all scopes, and Scope Admins to list the scopes they own, with pagination and an optional case-insensitive name filter. A Scope Admin’s listing is filtered by ownership as recorded in the database — not by the token’s owned-scope claim — before pagination, so its total counts their own scopes and nothing about other tenants. The collection endpoint is not exposed to Users, who reach their one scope through FR-SC-02 | High |
| FR-SC-04 | The system shall allow System Admins to update scope information | High |
| FR-SC-05 | The system shall allow System Admins to logically delete a scope by setting IsDeleted = true | High |
| FR-SC-06 | The system shall allow System Admins to hard delete a scope, permanently removing it and its associated users and applications | High |
| FR-SC-07 | Logically deleted scopes shall not appear in default query results unless explicitly requested | Medium |
| FR-SC-08 | A scope shall be able to have one or more owners, each of which must be a person with the ScopeAdmin or SystemAdmin role. A System Admin governs every scope whether or not they own it; owning one makes them a named owner of it, which FR-AP-03 and NFR-12 then count | High |
| FR-SC-09 | The system shall allow System Admins and existing owners of a scope to add an existing Scope Admin or System Admin as an additional owner of that scope | High |
| FR-SC-10 | The system shall allow System Admins and existing owners of a scope to remove an owner from a scope, provided at least one owner remains | High |
| FR-SC-11 | A scope shall be able to contain one or more Persons with the User role | High |
| FR-SC-12 | The system shall allow System Admins and existing owners of a scope to add a new co-owner by creating a brand-new ScopeAdmin person, directly made an owner of that scope | High |
| FR-SC-13 | The system shall allow System Admins and existing owners of a scope to add a new co-owner by promoting an existing User of that scope to ScopeAdmin, making them a co-owner and removing their User scope membership | High |
3.2 Person Management
| ID | Requirement | Priority |
|---|
| FR-PE-01 | The system shall allow creation of a person with: Id, Name, Email, PasswordHash, Salt, RoleId, IsDeleted. A person has no direct ScopeId attribute | High |
| FR-PE-02 | A person with the User role must be associated with exactly one scope, via scope membership, at creation time | High |
| FR-PE-03 | The system shall allow reading a person’s details by ID | High |
| FR-PE-04 | The system shall allow listing persons (Users) within a scope, and listing owners (Scope Admins) of a scope, with pagination and optional case-insensitive name and email filters. The person projection reports whether the person has an active two-factor configuration, but never which methods are configured (see FR-2F-15) | High |
| FR-PE-05 | The system shall allow updating person information (name, email, role) | High |
| FR-PE-06 | The system shall allow logical deletion of a person by setting IsDeleted = true | High |
| FR-PE-07 | The system shall allow hard deletion of a person, permanently removing the record | High |
| FR-PE-08 | Logically deleted persons shall not appear in default query results unless explicitly requested | Medium |
| FR-PE-09 | A User person’s email must be unique among the Users of their scope; a ScopeAdmin or SystemAdmin person’s email must be unique among all ScopeAdmin and SystemAdmin persons system-wide. The two namespaces are independent: an admin address and a scoped User address never collide with each other, because login resolves them by different lookups (FR-AU-01/02). Comparison is case-insensitive | High |
| FR-PE-10 | A person with the SystemAdmin role must not be associated with any scope, either as an owner or as a user | High |
| FR-PE-11 | A person with the ScopeAdmin role must own at least one scope, and must not belong to any scope as a User. This is an invariant the scope-assignment operations maintain (UC-01, UC-06 path c, UC-21, UC-23); removing the scope itself (UC-05) or the last ownership row (UC-22) can still leave a ScopeAdmin owning none — see §8 | High |
| FR-PE-12 | The system shall allow listing the persons who may own a scope — the ScopeAdmin persons of the system, and for a SystemAdmin caller the SystemAdmin persons too — with pagination and optional case-insensitive name and email filters, and optional exclusion of the current owners of a named scope. Readable by a SystemAdmin or a ScopeAdmin. The projection is the person’s identifier, name, and email only — no role, owned scopes, verification, two-factor state, or timestamps. When the exclusion is requested, the caller must be entitled to manage the named scope, since comparing the filtered and unfiltered results would otherwise enumerate that scope’s owners | High |
| FR-PE-13 | The system shall allow listing the SystemAdmin persons of the system, with pagination and optional case-insensitive name and email filters, ordered by name. Readable by a SystemAdmin only. Logically deleted persons and persons whose processing is restricted (NFR-24) are not listed. The projection is the person’s identifier, name, and email only, as in FR-PE-12 | Medium |
| FR-PE-14 | A person changing their own email shall prove they are the account holder: the request shall carry their current password (currentPassword) and, when their two-factor authentication is active, a current second factor (twoFactorCode: an authenticator-app or email code, or an unused recovery code, which is spent; since no endpoint sends an email code to a signed-in caller, a person whose only method is Email uses a recovery code). A missing or wrong password, or a missing or wrong second factor when one is required, shall be refused with 401 Unauthorized and “The password or code is incorrect.” (FR-AU-13), the refusal UC-39 gives for the same failures, before the new address is checked for uniqueness, and nothing shall change. The password is verified through the password-hash gate (TH-03), and the endpoint is under the credential endpoints’ rate limit (NFR-18). An administrator changing another person’s email presents no credential of that person, and a change that leaves the email as it is asks for none. The email counts as changed unless the trimmed values are equal ordinally, so a change of case is a change, and a changed address is stored trimmed | High |
| FR-PE-15 | On every change of a person’s email, whoever made it, the system shall send a notice to the previous address saying that the account’s email was changed and that, if the person did not make the change, they should contact an administrator. The notice shall contain neither the new address nor any link. A failed delivery shall be logged and shall not fail the update | High |
| FR-PE-16 | The account emails a person’s own requests can have sent — a verification link asked for again (FR-EV-04), a two-factor setup code (FR-2F-03) and the notice an own email change sends to the previous address (FR-PE-15) — shall share one per-person budget: at most one within the cooldown of FR-PR-06 and at most five in each day-long window, which the first send after the previous window ends opens (so up to ten can fall within any 24 hours). Each send shall be reserved by one conditional write before anything is issued, changed or mailed, so that simultaneous requests cannot exceed it (NFR-27), and a refused request shall change and send nothing: 429 Too Many Requests with “An email of this kind was sent to you recently. Try again in a few minutes.” inside the cooldown, and “You have reached the limit for account emails. Try again later.” once the window’s five are spent; each with a Retry-After header giving the seconds until a send would be accepted — the end of the cooldown, or for the window’s limit the later of the window’s end and the cooldown’s. Administrator-initiated mail (FR-EV-06, FR-PR-05) is not charged to it. The budget shall be returned in the data export and cleared by anonymisation | Medium |
| FR-PE-17 | On every change of a person’s email, whoever made it, every live password-reset link (FR-PR-02) and email-verification link (FR-EV-02) the person holds shall be retired, so nothing mailed to the previous address can act on the account afterwards. They shall be retired before the new address is written and again immediately after it is saved, before anything slower such as the notice of FR-PE-15, for any mailed to the previous address in between. A reset or verification that read the person before the change shall write only while the person still holds the address it read (FR-PR-08, FR-EV-08), so it cannot write after the change, and a whole-row save of a copy read before a reset shall not write the password columns (FR-PR-08) | High |
| FR-PE-18 | A person shall not change their own email while their email two-factor method is enabled, active or pending: the change shall be refused with 409 Conflict and “Turn off the email sign-in method before changing your email address.”, after the credentials of FR-PE-14 are accepted and before anything is charged or changed and before a recovery or email code is spent (an accepted authenticator-app code’s time step is recorded, as FR-2F-14 records any accepted one’s). The method sends sign-in codes to the person’s registered address; to move it, the person turns it off, changes the address and sets it up again at the new one, which mails a code there that must be confirmed. A change made by an administrator is not affected | Medium |
| FR-PE-19 | A person’s scope ownerships, wherever a projection of the person reports them (ownedScopeIds: reading a person by ID, listing a scope’s Users or owners, the result of updating a person), shall be reported in full to a SystemAdmin and to the person themselves, and to anyone else only as far as the reader owns the same scopes: a ScopeAdmin reading a co-owner learns the scopes the two share, and nothing of the scopes the co-owner owns in other tenants | High |
3.3 Role Assignment
| ID | Requirement | Priority |
|---|
| FR-RO-01 | Every person must reference exactly one role via RoleId, pointing to a row in the Role table (User, ScopeAdmin, or SystemAdmin) | High |
| FR-RO-02 | Only System Admins shall be able to assign or change a person’s role to SystemAdmin | High |
| FR-RO-03 | System Admins may assign or change a person’s role to ScopeAdmin for any scope. Scope Admins (owners) may also grant the ScopeAdmin role, but only as a co-owner of a scope they themselves own — either by creating a new ScopeAdmin person (FR-SC-12) or by promoting an existing User of that scope (FR-SC-13) | High |
| FR-RO-04 | The system shall store passwords as a PasswordHash byte array together with a Salt byte array used to compute the hash | High |
| FR-RO-05 | Scope Admins (owners of a scope) shall be able to assign the User role to persons within that scope | High |
| FR-RO-06 | System Admins have all permissions across the entire system and are exempt from scope-based authorization checks | High |
3.4 Authentication
| ID | Requirement | Priority |
|---|
| FR-AU-01 | The system shall authenticate a User using email, password, and scope ID (email is only unique within a scope for Users) | High |
| FR-AU-02 | The system shall authenticate a ScopeAdmin or SystemAdmin using email and password only, without a scope ID (email is unique system-wide for these roles) | High |
| FR-AU-03 | Upon successful authentication, the system shall return an authentication token (e.g., JWT) | High |
| FR-AU-04 | The authentication token shall contain the person’s PublicId, display name and role, plus: the scope’s PublicId for a User; the list of owned scopes’ PublicIds for a ScopeAdmin; no scope claim for a SystemAdmin. Internal bigint Ids are never placed in a token | High |
| FR-AU-05 | The system shall reject authentication attempts for logically deleted persons | High |
| FR-AU-06 | The system shall reject authentication attempts for a User whose scope is logically deleted | High |
| FR-AU-07 | The system shall reject authentication attempts for a ScopeAdmin if all scopes they own are logically deleted | High |
| FR-AU-08 | Upon successful authentication, the system shall fold into the token the distinct Names of the non-logically-deleted ScopePermissions of the acting scope(s) whose IncludeAsJwtClaim flag is set: the scope a User belongs to for a User; the union over the scopes a ScopeAdmin owns for a ScopeAdmin; none for a SystemAdmin, who carries no scope. Logically deleted scopes and permissions are excluded, so a deleted resource is never claimed | Medium |
| FR-AU-09 | The system shall count consecutive failed password attempts per person and, on reaching ten, refuse authentication for that person for fifteen minutes regardless of the password supplied. A successful authentication shall reset the count. The refusal shall be indistinguishable from any other authentication failure — the same message as FR-AU-05 through FR-AU-07, and the same cost, so a lockout is not detectable by response time | High |
| FR-AU-10 | The system shall verify a submitted password against a hash on every authentication attempt, including one for an address that belongs to nobody, so that the uniform rejection of FR-AU-05 through FR-AU-07 and FR-AU-09 is uniform in elapsed time as well as in message | High |
| FR-AU-11 | Every token issued to a person — the full authentication token, the two-factor challenge token and the subject-rights-only token — shall carry the person’s current security stamp, a random value stored on the person, as the sst claim. The stamp shall be replaced when the person’s password is reset (UC-13), their email is changed by anyone (UC-08), two-factor authentication is activated (UC-37) or disabled (UC-39), or their recovery codes are regenerated (UC-40), and the replacement shall be a targeted write — of that column alone, or, for a reset, of that column together with the new password (FR-PR-08) — so no concurrent save can restore the old value. A person token whose stamp is missing or is not the person’s current one shall be refused on every endpoint with 401 Unauthorized and “This token has been revoked. Sign in again.”; a challenge token so outdated shall be refused by UC-38 as AF-38a and ignored by UC-46 as AF-46a. The token that made the change is revoked with the rest. A Google User’s token carries no stamp and is not checked for one | High |
| FR-AU-12 | The system shall count failed second-factor verifications at sign-in (UC-38) per person — authenticator-app codes, email codes and recovery codes alike — across every challenge, so that a fresh challenge does not reset the count. A correct password shall not reset it; only a successful second-factor verification shall, together with the lock and the lockout count below. On the tenth failure the second factor shall be locked: for fifteen minutes the first time, and twice as long each time after, up to twenty-four hours, the lockout count being reset only by a successful second factor. While locked, every second factor shall be refused, the right one included, with UC-38’s ordinary refusal (AF-38b), and the challenge’s own budgets (FR-2F-13, FR-2F-17) shall run out as they otherwise would, so the lock is not observable. The count shall be charged before the factor is compared, in one write with the lock (NFR-27) | High |
| FR-AU-13 | The system shall count failed re-authentications per person — every check of a password or a second factor made under a bearer token: enabling (UC-36), confirming (UC-37) and disabling (UC-39) two-factor authentication, regenerating recovery codes (UC-40), changing one’s own email (UC-08), and a person requesting erasure (UC-42). Five failures within fifteen minutes of the first shall pause these checks for that person for fifteen minutes. A wrong or missing password, a wrong, missing or already-used code, and a paused budget shall all be refused alike: 401 Unauthorized and “The password or code is incorrect.”, so the answer says neither which credential was wrong nor whether any was compared. The count shall be charged before the comparison, in one write with the pause (NFR-27), and a success shall clear it. It shall be kept apart from FR-AU-09 and FR-AU-12, which it shall never charge, so a bearer token cannot be used to lock its owner out of signing in | High |
3.5 Password Recovery
| ID | Requirement | Priority |
|---|
| FR-PR-01 | The system shall allow a person to request a password reset by providing their email | High |
| FR-PR-02 | The system shall generate a time-limited password reset token and send it via email. The request shall be answered before the person is looked up and the token issued and sent, so the response is the same in content and in time whether or not the address is registered (AF-12a) | High |
| FR-PR-03 | The system shall allow the person to set a new password using a valid reset token | High |
| FR-PR-04 | Expired or already-used reset tokens shall be rejected | High |
| FR-PR-05 | The system shall allow a SystemAdmin for any person, and a ScopeAdmin for a User of a scope they own, to have a password-reset link sent to the person’s own stored address. Eligibility shall be FR-PR-02’s: a person whose processing is restricted, who is logically deleted, or whose account could not sign in (FR-AU-06, FR-AU-07) shall be refused, with the reason. No token or password shall be returned, logged or accepted | Medium |
| FR-PR-06 | At most one password-reset email per person shall be sent through FR-PR-05 within a configurable cooldown (5 minutes by default, HEIMDALL_ADMIN_EMAIL_COOLDOWN_IN_SECONDS), counting every reset token issued for the person in that window, whoever caused it and whether or not it was used. A request inside the window shall be refused and nothing sent | Medium |
| FR-PR-07 | At most one password-reset email per person shall be sent through FR-PR-01 within the cooldown of FR-PR-06, held to at most the reset token’s lifetime so that the owner always holds a working link, counting every reset token issued for the person in that window, whoever caused it and whether or not it was used. A request inside the window shall send nothing and be answered exactly as any other (FR-PR-02). There shall be no daily cap, so that nobody but the owner can exhaust the owner’s recovery | Medium |
| FR-PR-08 | When a reset token is presented, the system shall re-check the person by FR-PR-02’s eligibility rule, as it stands at that moment: a person whose processing is restricted, who is logically deleted or anonymised, or whose account could not sign in (FR-AU-06, FR-AU-07) shall be refused exactly as an unknown token is (AF-13c) — same status, same message — and nothing shall be written, the token included. The new password shall be written by one conditional write of the password hash, the salt and a fresh security stamp (FR-AU-11) alone, which repeats those checks and requires the address the person was read with; when it is refused, the token stays spent and nothing about the person changes, the stamp included. Once a person row exists, its password hash and salt shall be written only by targeted writes — this one, and the clearing anonymisation makes (NFR-20) — so a whole-row save of a copy read before a reset cannot restore the old password | High |
3.6 Email Verification
| ID | Requirement | Priority |
|---|
| FR-EV-01 | Upon person creation, the system shall send a verification email to the person’s email address | High |
| FR-EV-02 | The verification email shall contain a time-limited verification token | High |
| FR-EV-03 | The system shall mark the person’s email as verified upon receiving a valid verification token | High |
| FR-EV-04 | The system shall allow re-sending the verification email | Medium |
| FR-EV-05 | On successful authentication, the system shall report whether the authenticated person’s email address is verified | Medium |
| FR-EV-06 | The system shall allow a SystemAdmin for any person, and a ScopeAdmin for a User of a scope they own, to have a fresh verification link sent to the person’s own stored address, retiring their live links as FR-EV-04 does. A person whose processing is restricted, who is logically deleted, or whose address is already verified shall be refused. No token shall be returned, logged or accepted | Medium |
| FR-EV-07 | At most one verification email per person shall be sent through FR-EV-06 within the cooldown of FR-PR-06, counting every verification token issued for the person in that window, whoever caused it and whether or not it was used. A request inside the window shall be refused and nothing sent. A person’s own request for the link again (FR-EV-04) is bounded by FR-PE-16 instead | Medium |
| FR-EV-08 | When a verification token is presented, the system shall re-check the person by FR-PR-02’s eligibility rule, as FR-PR-08 does, refusing an ineligible person exactly as an unknown token is (AF-14c) and writing nothing. The address shall be marked verified by one conditional write that repeats those checks and requires the address the person was read with | High |
3.7 Application Management
| ID | Requirement | Priority |
|---|
| FR-AP-01 | The system shall allow creation of an application with: Id, Name, ScopeId, OwnerId, IsDeleted | High |
| FR-AP-02 | Every application must be associated with exactly one scope at creation time | High |
| FR-AP-03 | Every application must have exactly one owner, which must be an existing, non-logically-deleted ScopeAdmin or SystemAdmin person who owns the application’s scope. A User may never own an application | High |
| FR-AP-04 | The system shall allow reading an application’s details by ID | High |
| FR-AP-05 | The system shall allow listing applications within a scope (with pagination and filtering) | High |
| FR-AP-06 | The system shall allow updating an application’s name and owner | High |
| FR-AP-07 | The system shall allow logical deletion of an application by setting IsDeleted = true | High |
| FR-AP-08 | The system shall allow hard deletion of an application, permanently removing the record | High |
| FR-AP-09 | Logically deleted applications shall not appear in default query results unless explicitly requested | Medium |
| FR-AP-10 | The system shall let a System Admin, or a Scope Admin who owns an application and its scope, issue the application a credential: a random secret shown once and stored only as its SHA-256, optionally with an expiry | High |
| FR-AP-11 | An application shall hold at most two live credentials (not revoked, not expired), so a new secret can be deployed before the old one is revoked | High |
| FR-AP-12 | The same actors shall be able to list an application’s credentials — never the secret or its hash — and revoke any one of them | High |
| FR-AP-13 | The system shall exchange a live credential for an application token: HMAC-SHA256 with the person tokens’ keys, a distinct audience, a lifetime of 10 minutes by default, and the claims tokenKind = application, id, scopeId, credentialId and name, with no role | High |
| FR-AP-14 | Every refused exchange shall answer the same 401 with the same message, revealing nothing about which applications, scopes or credentials exist | High |
| FR-AP-15 | The system shall let a consumer check an application token live, confirming the application, its scope and the issuing credential are still live | High |
| FR-AP-16 | Revoking or expiring a credential, or deleting the application or its scope, shall make every token issued from it fail the live check at once | High |
3.8 Google Sign-In
| ID | Requirement | Priority |
|---|
| FR-GO-01 | A scope shall have a boolean GoogleSignInEnabled flag, default false, controlling whether Google sign-up/sign-in is available for that scope | High |
| FR-GO-02 | Only System Admins and existing owners of a scope shall be able to enable or disable GoogleSignInEnabled for that scope | High |
| FR-GO-03 | The system shall allow Google sign-up/sign-in only for scopes where GoogleSignInEnabled = true | High |
| FR-GO-04 | Only accounts equivalent to the User role may sign up or sign in via Google; Google authentication shall never create or authenticate a ScopeAdmin or SystemAdmin | High |
| FR-GO-05 | The system shall register a Google User with: Id, GoogleId, Name, Email, EmailVerified, ProfilePictureUrl, IsDeleted, ScopeId | High |
| FR-GO-06 | A Google User must be associated with exactly one scope, matching the scope in which the sign-up/sign-in was initiated | High |
| FR-GO-07 | A Google User’s Email must be unique within its scope, considered jointly with the emails of User persons in that same scope (see FR-PE-09). User persons is meant literally: an admin holds no scope membership, so an admin’s address does not make one taken in any scope, and the same address can exist as both. Accepted and tested — see Threat Model TH-21. Only an address Google vouches for may take a place in a scope: a sign-up (FR-GO-09) whose verified token does not carry email_verified equal to true — false, or absent — shall create nothing and be refused with 403 Forbidden and “Google sign-up requires a verified email address.”, before the address is compared with the scope’s, so such a token learns nothing about which addresses a scope holds. Sign-in of an existing Google User (FR-GO-10) is not gated on the claim (see TH-40) | High |
| FR-GO-08 | A Google User’s GoogleId (Google’s sub claim) must be unique within its scope | High |
| FR-GO-09 | On first sign-in with a given Google account for a scope with Google sign-in enabled, the system shall create (sign up) a new Google User record populated from the verified Google ID token claims | High |
| FR-GO-10 | On subsequent sign-ins with the same Google account and scope, the system shall authenticate the existing Google User by GoogleId, without creating a duplicate record | High |
| FR-GO-11 | The system shall verify the Google ID token’s signature, issuer, audience, and expiration before trusting its claims | High |
| FR-GO-12 | The system shall reject Google sign-in attempts for logically deleted Google Users | High |
| FR-GO-13 | The system shall reject Google sign-in attempts for a scope that is logically deleted or has GoogleSignInEnabled = false | High |
| FR-GO-14 | The system shall allow reading a Google User’s details by ID, and listing Google Users within a scope (with pagination and filtering) | High |
| FR-GO-15 | The system shall allow logical deletion of a Google User by setting IsDeleted = true | High |
| FR-GO-16 | The system shall allow hard deletion of a Google User, permanently removing the record | High |
| FR-GO-17 | Logically deleted Google Users shall not appear in default query results unless explicitly requested | Medium |
| FR-GO-18 | The system shall allow a signed-in Google User to sign out. Heimdall keeps no Google session and no record of issued tokens, so signing out ends the client’s session: the client discards its token, which otherwise stays valid until it expires (UC-26) | High |
| FR-GO-19 | On each sign-in with an existing Google User, the system shall refresh the stored EmailVerified from the verified Google ID token claims; when the token carries no email_verified claim, the stored value shall be left unchanged | Medium |
| FR-GO-20 | A Google sign-in request may carry an optional nonce, the value the client sent Google when it asked for the ID token. When it is present and not empty, the verified token’s nonce claim shall equal it exactly (ordinal comparison), and a token with another nonce or none shall be refused as AF-25a: 401 Unauthorized, “Google authentication failed.”. When it is absent or empty, nothing is compared. The nonce binds the token to the client’s own sign-in attempt, so a client can refuse a token that was not issued for its request; whether a token is accepted rests on its signature, issuer, audience and expiry (NFR-13, Threat Model TH-19) | Medium |
3.8a Self-Registration
| ID | Requirement | Priority |
|---|
| FR-SR-01 | A scope shall have a boolean SelfRegistrationEnabled flag, default false, controlling whether anonymous callers may register password Users in it | High |
| FR-SR-02 | Only System Admins and existing owners of a scope shall be able to enable or disable SelfRegistrationEnabled for that scope | High |
| FR-SR-03 | The system shall let an anonymous caller register a password User in an open scope, applying the same name, email and password rules as administrator-created Users | High |
| FR-SR-04 | A self-registered address shall be unique in the scope among live persons and Google Users, compared case-insensitively | High |
| FR-SR-05 | When the address is already held, the system shall create nothing and shall email the holder that a registration was attempted, unless their processing is restricted or, for a Google User, the holder is logically deleted | High |
| FR-SR-06 | The system shall answer a registration for a free and a taken address identically, in content and in time | High |
| FR-SR-07 | No account shall exist for a self-registered address until the link mailed to it is followed; the account is then created with its address verified | High |
| FR-SR-08 | Pending registrations shall expire with the email-verification token lifetime and be purged with the other single-use tokens | High |
| FR-SR-09 | Confirming a self-registration shall require the password chosen at sign-up; a missing or wrong password shall not consume the pending registration, and after 10 wrong passwords the pending registration is discarded; each guess is charged before it is compared (NFR-27), so parallel guesses cannot exceed the 10 | High |
| FR-SR-10 | A scope may override the verification and password-reset link bases for its Users: when EmailVerificationUrl or PasswordResetUrl is set, the sign-up confirmation, verification and password-reset emails sent to that scope’s Users link there instead of to HEIMDALL_EMAIL_VERIFICATION_URL / HEIMDALL_PASSWORD_RESET_URL; an unset one, and every email to an administrator (who has no scope), uses the global setting. Only System Admins and existing owners of the scope may set them, through their own endpoint rather than the scope update, which would otherwise erase them; each must be an absolute http or https URL of at most 2048 characters | Medium |
| FR-SR-11 | The system shall allow System Admins and the scope’s owners to list a scope’s pending self-registrations, paginated and newest first, each with its identifier, name, address, creation and expiry times, the wrong confirmation passwords it may still take (never below zero), and whether it has expired — whether or not the scope is still open to self-registration. The password hash, salt and token hash shall never be returned | Medium |
| FR-SR-12 | The system shall allow System Admins and the scope’s owners to discard one of a scope’s pending self-registrations, removing it so that its link stops working. A registration of another scope shall be answered exactly as a missing one. The discard shall be audited with the registration as target | Medium |
| FR-SR-13 | An address shall have at most three live (unexpired) pending registrations in one scope at a time, compared case-insensitively. A registration beyond that shall store nothing and mail nothing, and shall be answered exactly as any other (FR-SR-06) | Medium |
| FR-SR-14 | An address shall be sent at most three registration-attempt notices (FR-SR-05) in a day, whichever scopes the attempts named: the first notice opens a one-day window, and the next notice after it ends opens another. A notice beyond that shall not be sent, and the registration shall be answered exactly as any other (FR-SR-06). The count shall be kept under a SHA-256 digest of the address, never the address itself, charged before the notice is sent so that simultaneous attempts cannot exceed it (NFR-27), and purged with the single-use tokens once its window has ended (NFR-19) | Medium |
3.9 Scope Permission Management
| ID | Requirement | Priority |
|---|
| FR-SP-01 | The system shall allow creation of a scope permission with: Id, PublicId, Name, Description (optional), IncludeAsJwtClaim, ScopeId, IsDeleted | High |
| FR-SP-02 | Every scope permission must be associated with exactly one scope at creation time, and only System Admins and owners of that scope may create it. A scope permission has no separate owner of its own — owning the scope is the whole of the authorization | High |
| FR-SP-03 | A scope permission shall carry an IncludeAsJwtClaim boolean flag (default false) controlling whether its Name is added as a claim on the JWT issued to identities acting within the owning scope (see FR-AU-08) | High |
| FR-SP-04 | The system shall allow reading a scope permission’s details by ID within its scope: a System Admin sees any permission, a Scope Admin only one whose scope they own | High |
| FR-SP-05 | The system shall allow listing the permissions of a scope, with pagination and an optional case-insensitive name filter. A System Admin sees every permission in the scope; a Scope Admin who owns the scope sees every permission in it — there is no per-owner narrowing, since a scope permission has no owner of its own | High |
| FR-SP-06 | The system shall allow updating a scope permission’s Name, Description, and IncludeAsJwtClaim flag | High |
| FR-SP-07 | The system shall allow logical deletion of a scope permission by setting IsDeleted = true. Nothing cascades: a scope permission owns no dependent row | High |
| FR-SP-08 | The system shall allow hard deletion of a scope permission, permanently removing the record. Restricted to System Admins; nothing cascades | High |
| FR-SP-09 | Logically deleted scope permissions shall not appear in default query results unless explicitly requested | Medium |
3.10 Two-Factor Authentication
| ID | Requirement | Priority |
|---|
| FR-2F-01 | The system shall allow an authenticated User, ScopeAdmin, or SystemAdmin to opt in to two-factor authentication by initiating setup with an authenticator-app method, an email method, or both. Google Users are not eligible — Google already governs their account security | High |
| FR-2F-02 | The authenticator-app method shall use standard TOTP (RFC 6238): a per-person secret, 6-digit codes, 30-second time step, provisioned via an otpauth:// URI for QR scanning. The secret is stored encrypted at rest and is shown to the person only at initiation | High |
| FR-2F-03 | The email method shall deliver a random 6-digit numeric code through the existing email service (see FR-PR-02, FR-EV-02 for the established pattern), single-use and expiring 10 minutes after issue. The ten minutes is the tolerance this specification allows for mail delivery, which is the one part of the flow outside the system’s control; no other lifetime in the second-factor flow may cut it short (NFR-17) | High |
| FR-2F-04 | Setup initiated under FR-2F-01 shall remain inactive until confirmed: the caller must submit a currently valid code for every method they selected (both, if both were selected) in one confirmation request | High |
| FR-2F-05 | Confirming setup shall generate exactly ten recovery codes, returned in the confirmation response in plaintext exactly once, and stored thereafter only as hashes. Their shape and hashing are FR-2F-20’s | High |
| FR-2F-06 | Each recovery code shall be usable at most once. Consuming a recovery code to complete a login marks it used; a used or unknown recovery code shall be rejected | High |
| FR-2F-07 | When a person with active two-factor authentication completes FR-AU-01/FR-AU-02’s password check, the system shall not issue a full authentication token. Instead it shall issue a short-lived challenge token, scoped only to the second-factor verification endpoint, naming the methods available to that person | High |
| FR-2F-08 | If the email method is enabled for the person, issuing a challenge token shall also send a fresh email code, per FR-2F-03 | High |
| FR-2F-09 | The second-factor verification endpoint shall accept a valid, unexpired challenge token together with either a current app/email code or an unused recovery code, and on success issue the full authentication token exactly as FR-AU-03/FR-AU-04 describe | High |
| FR-2F-10 | The system shall reject a challenge token at every endpoint other than second-factor verification, and shall reject an expired or already-redeemed challenge token at that endpoint too | High |
| FR-2F-11 | Disabling two-factor authentication shall require both the person’s current password and a valid second factor (an app/email code or a recovery code). On success the system shall remove the two-factor configuration and all of its recovery codes | High |
| FR-2F-12 | Regenerating recovery codes shall require a valid second factor, shall invalidate every previously issued recovery code for the person, and shall return ten new codes in plaintext exactly once, per FR-2F-05 | High |
| FR-2F-13 | The system shall count failed verification attempts against an issued email code and retire the code on the fifth, so a six-digit code cannot be exhausted by guessing. A further code may be obtained against an outstanding challenge without re-authenticating (FR-2F-16), at most three times per challenge — so one authentication attempt admits at most twenty guesses, a bound this specification states rather than one the rate limiter happens to impose. A fourth reissue shall require a fresh authentication attempt, as shall any reissue once the challenge itself has expired | High |
| FR-2F-14 | The system shall accept an authenticator-app code at most once: the time step of an accepted code shall be recorded, and a code from that step or any earlier one shall be refused, so a code observed in transit cannot be replayed within the verification window | High |
| FR-2F-15 | The system shall allow an authenticated person to read their own two-factor authentication state: whether it is active, which methods are configured, and how many issued recovery codes remain unused. A person with no configuration shall be answered successfully with every flag false and a zero count, since never having enabled it is an ordinary state rather than a fault. A Google User shall be refused, as under FR-2F-01. No secret is returned — never the TOTP secret, never a recovery code | High |
| FR-2F-16 | The system shall provide an operation, authorized by a challenge token alone, that reissues the email code for an outstanding challenge: it shall retire the person’s outstanding codes and send a fresh one per FR-2F-03, inside the existing challenge’s lifetime — never extending it, and never issuing a new challenge token — and at most three times per challenge (FR-2F-13). It shall answer identically whether the challenge is valid, unknown, forged, expired, exhausted, or names a person without the email method enabled, so the operation cannot be used to discover whether an address is registered or has email two-factor authentication enabled | High |
| FR-2F-17 | The system shall count the guesses made at an outstanding challenge with an authenticator-app code or a recovery code, and on the fifth retire the challenge, so that further guessing requires a fresh authentication attempt. The count shall be reset whenever a new challenge is issued. A configuration with only the email method is bounded by FR-2F-13 instead | High |
| FR-2F-18 | Initiating two-factor setup (UC-36) shall require the caller’s current password (currentPassword). A missing or wrong password shall be refused with 401 Unauthorized and “The password or code is incorrect.” (FR-AU-13), and nothing shall be created or sent. The password is verified through the password-hash gate, and the endpoint is under the credential endpoints’ rate limit. Confirming setup (UC-37) requires no password, only the codes of the methods selected, which are charged to the re-authentication budget of FR-AU-13 | High |
| FR-2F-19 | A setup request that selects the email method shall be charged to the person’s account-email budget (FR-PE-16). The budget shall be read before the password is checked, so a request it would refuse is refused first, with nothing changed or sent, and charged only once the password checks out, so a wrong password never spends it; a request selecting only the authenticator app is not charged | Medium |
| FR-2F-20 | A recovery code shall carry 80 bits from a cryptographically secure generator: ten random bytes written as sixteen characters of Crockford’s base32 (the digits and the uppercase letters without I, L, O and U), shown as XXXX-XXXX-XXXX-XXXX, and stored as the SHA-256 digest of the sixteen characters without hyphens. A presented code shall be matched ignoring hyphens, whitespace and case, with Crockford’s aliases applied (O read as 0, I and L as 1). Codes of the shape issued before this requirement (XXXX-XXXX from [A-Z0-9], stored as the digest of that exact uppercase text) shall keep matching, without aliases, until used or regenerated; any other length matches nothing. Unsalted SHA-256 is sufficient at this entropy: see Threat Model TH-16 | High |
3.11 Audit Trail
| ID | Requirement | Priority |
|---|
| FR-AT-01 | The system shall allow a SystemAdmin to list the audit trail, paginated and newest first (ties broken by write order), optionally filtered by an inclusive range of when entries were recorded (UTC), by the actor’s identifier, by the exact action name, by the affected entity’s identifier, and by whether the operation succeeded. Filters combine with AND. No other role may read the trail, and reading it is not itself audited | High |
| FR-AT-02 | The audit-trail listing shall show each entry as stored — its identifier, when it was recorded, actor and role, action, target, outcome and canonical failure reason. An actor or target that is absent or has been cleared (NFR-21) shall be reported as null and never reconstructed from other data | High |
4. Data Model
4.0 Identifier Strategy
Every top-level entity (Scope, Role, Person, Application, Google User, Scope Permission) has two identifiers:
Id — an auto-incrementing bigint, the physical primary key used for storage and all foreign keys (ScopeId, RoleId, OwnerId, PersonId, and the columns of SCOPE_OWNER/SCOPE_USER). Never exposed outside the database.PublicId — a GUID generated at creation time, used as the entity’s identifier everywhere it is addressed from outside the database: API path segments (e.g. /api/scopes/{id}), response bodies, and authentication token claims (e.g. the personId/scopeId claims in FR-AU-04). It is immutable, enforced by trigger, on every table listed above and on APPLICATION_CREDENTIAL and PENDING_REGISTRATION, no new row of those tables may take a PublicId that another of them holds, and a new PERSON or GOOGLE_USER row may not take one that an audit entry names (#199): the audit trail’s retention rules read it as proof that an id names a person (NFR-21).
PASSWORD_RESET_TOKEN and EMAIL_VERIFICATION_TOKEN use only an internal bigint Id — they are never addressed by ID at all; the caller-facing reference is the random Token string already required by FR-PR-02/FR-EV-02. PENDING_REGISTRATION is reached the same way by the registrant — through the mailed token, of which only the hash is stored — and also carries a PublicId, the administrative reference by which a scope’s owners list and discard it (FR-SR-11, FR-SR-12). SCOPE_OWNER and SCOPE_USER are join rows, not independently addressable resources, so they carry no PublicId either — their ScopeId/PersonId columns are internal bigint foreign keys. TWO_FACTOR_AUTH and TWO_FACTOR_RECOVERY_CODE follow the same no-PublicId pattern as the token tables: a person’s two-factor configuration is reached implicitly through their own authenticated identity, never by ID in a path.
This split keeps sequential record counts and creation order — which a raw auto-increment Id would reveal — out of anything a caller can see, while still giving the database compact integer keys for joins and indexes.
4.1 Entity Relationship Diagram
erDiagram
SCOPE ||--o{ APPLICATION : "contains"
ROLE ||--o{ PERSON : "classifies"
PERSON ||--o{ APPLICATION : "owns"
SCOPE ||--o{ SCOPE_OWNER : "has"
PERSON ||--o{ SCOPE_OWNER : "owns"
SCOPE ||--o{ SCOPE_USER : "has"
PERSON ||--o| SCOPE_USER : "belongs to"
SCOPE ||--o{ GOOGLE_USER : "contains"
SCOPE ||--o{ SCOPE_PERMISSION : "defines"
SCOPE ||--o{ PENDING_REGISTRATION : "awaits confirmation of"
PERSON ||--o| TWO_FACTOR_AUTH : "configures"
TWO_FACTOR_AUTH ||--o{ TWO_FACTOR_RECOVERY_CODE : "issues"
SCOPE {
bigint Id PK "Internal auto-increment identifier"
guid PublicId "External identifier, used in API paths/responses"
string Name "Scope display name"
string Description "Optional description"
bool IsDeleted "Logical deletion flag"
bool GoogleSignInEnabled "Whether Google sign-up/sign-in is enabled for this scope"
bool SelfRegistrationEnabled "Whether anonymous callers may register password Users in this scope"
string EmailVerificationUrl "Optional base link of this scope's verification emails, max 2048 characters; null uses the global one (FR-SR-10)"
string PasswordResetUrl "Optional base link of this scope's password-reset emails, max 2048 characters; null uses the global one (FR-SR-10)"
datetime CreatedAt "Creation timestamp"
datetime UpdatedAt "Last update timestamp"
}
GOOGLE_USER {
bigint Id PK "Internal auto-increment identifier"
guid PublicId "External identifier, used in API paths/responses"
string GoogleId "Google's stable 'sub' claim, unique within the scope"
string Name "Full name, from Google's 'name' claim"
string Email "Email address, from Google's 'email' claim (unique per scope, jointly with User persons)"
bool EmailVerified "From Google's 'email_verified' claim"
string ProfilePictureUrl "From Google's 'picture' claim, optional"
bool IsDeleted "Logical deletion flag"
bigint ScopeId FK "Associated scope (internal Id)"
datetime CreatedAt "Creation timestamp"
datetime UpdatedAt "Last update timestamp"
}
ROLE {
bigint Id PK "Internal auto-increment identifier"
guid PublicId "External identifier, used in API paths/responses"
string Name "Role name: User | ScopeAdmin | SystemAdmin"
string Description "Optional explanation of the role's purpose and permissions"
}
PERSON {
bigint Id PK "Internal auto-increment identifier"
guid PublicId "External identifier, used in API paths/responses and token claims"
string Name "Full name"
string Email "Email address (unique among a scope's Users; unique among Admins system-wide)"
bytes PasswordHash "Hashed password, stored as a byte array"
bytes Salt "Random salt used to hash the password, stored as a byte array"
guid SecurityStamp "Carried by every token issued to the person; replaced on a credential change"
bool IsDeleted "Logical deletion flag"
bool EmailVerified "Email verification status"
bigint RoleId FK "Associated role (internal Id)"
datetime CreatedAt "Creation timestamp"
datetime UpdatedAt "Last update timestamp"
}
SCOPE_OWNER {
bigint ScopeId FK "Owned scope (internal Id)"
bigint PersonId FK "Owning person (internal Id), must have the ScopeAdmin role"
}
SCOPE_USER {
bigint ScopeId FK "Scope the person belongs to (internal Id)"
bigint PersonId FK "Person (internal Id), must have the User role, unique across this table"
}
PASSWORD_RESET_TOKEN {
bigint Id PK "Internal auto-increment identifier, never exposed"
bigint PersonId FK "Associated person (internal Id)"
string Token "Reset token value — the caller-facing reference"
datetime ExpiresAt "Expiration timestamp"
bool Used "Whether the token has been consumed"
}
EMAIL_VERIFICATION_TOKEN {
bigint Id PK "Internal auto-increment identifier, never exposed"
bigint PersonId FK "Associated person (internal Id)"
string Token "Verification token value — the caller-facing reference"
datetime ExpiresAt "Expiration timestamp"
bool Used "Whether the token has been consumed"
}
PENDING_REGISTRATION {
bigint Id PK "Internal auto-increment identifier, never exposed"
guid PublicId "Administrative reference for listing and discarding (FR-SR-11, FR-SR-12); the registrant's reference is the token"
bigint ScopeId FK "Scope registered into (internal Id)"
string Name "Name the registrant gave"
string Email "Address awaiting confirmation"
bytes PasswordHash "Password hash computed on the request path"
bytes Salt "Salt of the password hash, carried to the User on confirmation"
string TokenHash "SHA-256 of the mailed token — the caller-facing reference is the token itself"
datetime ExpiresAt "Expiration timestamp"
datetime CreatedAt "When the registration was stored"
int FailedAttempts "Wrong confirmation passwords so far; the row is discarded at 10"
}
ATTEMPT_NOTICE_BUDGET {
bigint Id PK "Internal auto-increment identifier, never exposed"
string AddressHash "SHA-256 of the trimmed, lower-cased address; unique (FR-SR-14)"
int NoticesSent "Registration-attempt notices sent in the current window"
datetime WindowEndsAt "End of the current one-day window"
}
APPLICATION {
bigint Id PK "Internal auto-increment identifier"
guid PublicId "External identifier, used in API paths/responses"
string Name "Application display name"
bool IsDeleted "Logical deletion flag"
bigint ScopeId FK "Associated scope (internal Id)"
bigint OwnerId FK "Owning person (internal Id)"
datetime CreatedAt "Creation timestamp"
datetime UpdatedAt "Last update timestamp"
}
SCOPE_PERMISSION {
bigint Id PK "Internal auto-increment identifier"
guid PublicId "External identifier, used in API paths/responses"
string Name "Permission display name, max 200 characters"
string Description "Optional description, max 500 characters"
bool IncludeAsJwtClaim "When true, the Name is added as a JWT claim at issue"
bool IsDeleted "Logical deletion flag"
bigint ScopeId FK "Associated scope (internal Id)"
datetime CreatedAt "Creation timestamp"
datetime UpdatedAt "Last update timestamp"
}
PERSON ||--o{ PASSWORD_RESET_TOKEN : "requests"
PERSON ||--o{ EMAIL_VERIFICATION_TOKEN : "receives"
TWO_FACTOR_AUTH {
bigint Id PK "Internal auto-increment identifier, never exposed"
bigint PersonId FK "Owning person (internal Id), unique — one configuration per person"
bool AppEnabled "Whether the authenticator-app method is configured"
bool EmailEnabled "Whether the email method is configured"
bytes TotpSecretEncrypted "Encrypted TOTP secret, present only when AppEnabled"
bool IsActive "True once every selected method has been confirmed (FR-2F-04)"
datetime CreatedAt "Creation timestamp"
datetime UpdatedAt "Last update timestamp"
}
TWO_FACTOR_RECOVERY_CODE {
bigint Id PK "Internal auto-increment identifier, never exposed"
bigint TwoFactorAuthId FK "Owning two-factor configuration (internal Id)"
bytes CodeHash "Hashed recovery code — the plaintext is never stored"
bool Used "Whether the code has been consumed"
datetime UsedAt "Timestamp the code was consumed, null until then"
datetime CreatedAt "Creation timestamp"
}4.2 Person Fields
| Field | Type | Constraints |
|---|
| Id | BigInt | Primary key, auto-increment, internal only |
| PublicId | GUID | Required, unique, auto-generated on creation; external identifier; immutable, enforced by trigger, and never the PublicId of another entity’s row, or one an audit entry names, when inserted (#199) |
| Name | String | Required, max 200 characters |
| Email | String | Required, valid email format. Unique among the Users of the same scope (via SCOPE_USER); unique among all ScopeAdmin/SystemAdmin persons system-wide. See FR-PE-09 — the two namespaces are independent |
| PasswordHash | Byte array | Required, stores the hash computed from the password and Salt |
| Salt | Byte array | Required, randomly generated per person, used to hash the password |
| SecurityStamp | GUID | Required, random, generated on creation (gen_random_uuid() for rows inserted without one). Carried by every token issued to the person and replaced, by a write of this column alone, on a password reset, an email change, a two-factor activation or removal, or a recovery-code regeneration, which revokes every earlier token (FR-AU-11). Never written by a whole-row save |
| FailedSecondFactorAttempts | Integer | Default: 0; second-factor failures at sign-in since the last successful one, across challenges (FR-AU-12). Written only by its targeted write, never by a whole-row save, as are the five fields below |
| SecondFactorLockedOutUntil | DateTime | Null unless the second factor is or was locked; while in the future, every second factor at sign-in is refused (FR-AU-12) |
| SecondFactorLockouts | Integer | Default: 0; second-factor locks since the last successful second factor, which set how long the next one lasts (FR-AU-12) |
| FailedReauthenticationAttempts | Integer | Default: 0; failed re-authentications in the current window (FR-AU-13) |
| ReauthenticationWindowStartedAt | DateTime | Null unless a window is open; when its first failure was counted (FR-AU-13) |
| ReauthenticationPausedUntil | DateTime | Null unless re-authentication is or was paused; while in the future, every re-authentication is refused (FR-AU-13) |
| AccountEmailsSent | Integer | Default: 0; account emails the person’s own requests had sent in the current window (FR-PE-16); written only by the conditional write that reserves a send |
| AccountEmailWindowEndsAt | DateTime | Null before the first such send; when the current one-day window ends (FR-PE-16) |
| AccountEmailLastSentAt | DateTime | Null before the first such send; when the last was reserved, which the cooldown counts from (FR-PE-16) |
| IsDeleted | Boolean | Default: false |
| EmailVerified | Boolean | Default: false |
| RoleId | BigInt | Foreign key to Role.Id (internal), required |
| CreatedAt | DateTime | Auto-set on creation |
| UpdatedAt | DateTime | Auto-set on update |
A person has no ScopeId column. Its relationship to scopes is derived from its role:
User: exactly one row in SCOPE_USER.ScopeAdmin: one or more rows in SCOPE_OWNER.SystemAdmin: no rows in either SCOPE_OWNER or SCOPE_USER.
4.3 Role Fields
| Field | Type | Constraints |
|---|
| Id | BigInt | Primary key, auto-increment, internal only |
| PublicId | GUID | Required, unique, auto-generated on creation; external identifier; immutable, enforced by trigger, and never the PublicId of another entity’s row when inserted (#199) |
| Name | String | Required, unique — User, ScopeAdmin, or SystemAdmin |
| Description | String | Optional, human-readable explanation of the role’s purpose and permissions |
4.4 Application Fields
| Field | Type | Constraints |
|---|
| Id | BigInt | Primary key, auto-increment, internal only |
| PublicId | GUID | Required, unique, auto-generated on creation; external identifier; immutable, enforced by trigger, and never the PublicId of another entity’s row when inserted (#199) |
| Name | String | Required, max 200 characters |
| IsDeleted | Boolean | Default: false |
| ScopeId | BigInt | Foreign key to Scope.Id (internal), required |
| OwnerId | BigInt | Foreign key to Person.Id (internal), required |
| CreatedAt | DateTime | Auto-set on creation |
| UpdatedAt | DateTime | Auto-set on update |
4.5 Scope Owner Fields
| Field | Type | Constraints |
|---|
| ScopeId | BigInt | Foreign key to Scope.Id (internal), part of composite primary key |
| PersonId | BigInt | Foreign key to Person.Id (internal), part of composite primary key; the referenced person must have the ScopeAdmin or SystemAdmin role (FR-SC-08) |
A scope may have many rows (one or more owners); a person may have many rows (a Scope Admin may own several scopes). Composite key (ScopeId, PersonId) must be unique. This join table has no PublicId — it is not an independently addressable API resource.
4.6 Scope User Fields
| Field | Type | Constraints |
|---|
| ScopeId | BigInt | Foreign key to Scope.Id (internal), required |
| PersonId | BigInt | Foreign key to Person.Id (internal), required and unique; the referenced person must have the User role |
PersonId is unique across this table, enforcing that a User belongs to exactly one scope. A scope may have many rows (one or more users). This join table has no PublicId.
4.7 Google User Fields
| Field | Type | Constraints |
|---|
| Id | BigInt | Primary key, auto-increment, internal only |
| PublicId | GUID | Required, unique, auto-generated on creation; external identifier; immutable, enforced by trigger, and never the PublicId of another entity’s row, or one an audit entry names, when inserted (#199) |
| GoogleId | String | Required, from Google’s sub claim; unique within the scope |
| Name | String | From Google’s name claim, max 200 characters |
| Email | String | Required, from Google’s email claim; unique within the scope, considered jointly with User persons’ emails in that scope |
| EmailVerified | Boolean | From Google’s email_verified claim |
| ProfilePictureUrl | String | Optional, from Google’s picture claim |
| IsDeleted | Boolean | Default: false |
| ScopeId | BigInt | Foreign key to Scope.Id (internal), required |
| CreatedAt | DateTime | Auto-set on creation |
| UpdatedAt | DateTime | Auto-set on update |
GOOGLE_USER is a separate table from PERSON. Unlike PERSON, it has a direct ScopeId column rather than going through SCOPE_USER, because a Google User always represents the User role and single-scope membership — it never needs the ownership/multi-scope semantics that SCOPE_OWNER exists for. It has no PasswordHash, Salt, or RoleId: authentication is delegated to Google, and the role is implicitly User.
4.8 Scope Permission Fields
| Field | Type | Constraints |
|---|
| Id | BigInt | Primary key, auto-increment, internal only |
| PublicId | GUID | Required, unique, auto-generated on creation; external identifier; immutable, enforced by trigger, and never the PublicId of another entity’s row when inserted (#199) |
| Name | String | Required, max 200 characters |
| Description | String | Optional, max 500 characters |
| IncludeAsJwtClaim | Boolean | Default: false; controls whether Name is added as a JWT claim at issue (FR-SP-03, FR-AU-08) |
| IsDeleted | Boolean | Default: false |
| ScopeId | BigInt | Foreign key to Scope.Id (internal), required; cascade-deleted when the scope is hard-deleted |
| CreatedAt | DateTime | Auto-set on creation |
| UpdatedAt | DateTime | Auto-set on update |
A scope permission belongs to exactly one scope, and unlike APPLICATION it has no separate owner — owning the scope is the whole of the authorization to manage it. The IncludeAsJwtClaim flag has no enforcement role of its own; it is read at login time by FR-AU-08, which folds every flagged, non-deleted permission name of the acting scope into the issued token as a scopePermissions claim.
4.9 Two-Factor Auth Fields
| Field | Type | Constraints |
|---|
| Id | BigInt | Primary key, auto-increment, internal only |
| PersonId | BigInt | Foreign key to Person.Id (internal), required and unique — one row per person |
| AppEnabled | Boolean | Default: false |
| EmailEnabled | Boolean | Default: false; at least one of AppEnabled/EmailEnabled must be true |
| TotpSecretEncrypted | Byte array | Required when AppEnabled = true, otherwise null; encrypted at rest, never returned once FR-2F-04 confirmation succeeds |
| IsActive | Boolean | Default: false; set true only once every selected method is confirmed (FR-2F-04) |
| LastTotpTimeStepUsed | BigInt | Null until an app code is accepted; the time step of the last accepted one, so no code from that step or earlier is accepted again (FR-2F-14) |
| EmailCodeReissueCount | Integer | Default: 0; reissues the outstanding challenge has spent, reset when a login issues a new challenge (FR-2F-13, FR-2F-16) |
| ChallengeId | GUID | Null unless a challenge is outstanding; set when a login issues a challenge token, which carries the same value, and cleared when it is redeemed, so a challenge is redeemable once (FR-2F-10) |
| ChallengeAttempts | Integer | Default: 0; guesses made at the outstanding challenge with an app code or a recovery code, reset when a login issues a new challenge; at five the challenge is cleared (FR-2F-17) |
| CreatedAt | DateTime | Auto-set on creation |
| UpdatedAt | DateTime | Auto-set on update |
A person has at most one TWO_FACTOR_AUTH row. Its absence means the person has never initiated setup; IsActive = false with a row present means setup was initiated but not yet confirmed.
4.10 Two-Factor Recovery Code Fields
| Field | Type | Constraints |
|---|
| Id | BigInt | Primary key, auto-increment, internal only |
| TwoFactorAuthId | BigInt | Foreign key to TwoFactorAuth.Id (internal), required; cascade-deleted when the two-factor configuration is removed |
| CodeHash | Byte array | Required, the SHA-256 digest of the code’s canonical form (FR-2F-20) — the plaintext code exists only in the FR-2F-05 response |
| Used | Boolean | Default: false |
| UsedAt | DateTime | Set when Used transitions to true; null until then |
| CreatedAt | DateTime | Auto-set on creation |
Exactly ten rows are created together at confirmation (FR-2F-05) or regeneration (FR-2F-12); regeneration deletes the previous ten and inserts ten new ones in the same operation.
5. API Endpoints Overview
Every {id}, {scopeId}, {personId} (etc.) path segment below refers to the entity’s PublicId (GUID), never its internal bigint Id (see §4.0).
5.1 Scope Endpoints
| Method | Endpoint | Description | Auth Required |
|---|
| POST | /api/scopes | Create a new scope with at least one initial owner | SystemAdmin |
| GET | /api/scopes | List scopes — all of them for a SystemAdmin, the ones they own for a ScopeAdmin | SystemAdmin, ScopeAdmin |
| GET | /api/scopes/{id} | Get scope by ID | Authenticated |
| PUT | /api/scopes/{id} | Update a scope | SystemAdmin |
| DELETE | /api/scopes/{id} | Logically delete a scope | SystemAdmin |
| DELETE | /api/scopes/{id}/hard | Hard delete a scope | SystemAdmin |
| POST | /api/scopes/{id}/owners/{personId} | Add an existing Scope Admin as an owner of the scope | SystemAdmin, existing Owner |
| POST | /api/scopes/{id}/owners | Create a brand-new ScopeAdmin person, directly made an owner of the scope | SystemAdmin, existing Owner |
| POST | /api/scopes/{id}/users/{personId}/promote | Promote an existing User of the scope to ScopeAdmin, making them a co-owner | SystemAdmin, existing Owner |
| GET | /api/scopes/{id}/owners | List the owners of a scope | SystemAdmin, existing Owner |
| DELETE | /api/scopes/{id}/owners/{personId} | Remove an owner from the scope (at least one must remain) | SystemAdmin, existing Owner |
| PUT | /api/scopes/{id}/google-signin | Enable or disable Google Sign-In for the scope ({ enabled: bool }) | SystemAdmin, existing Owner |
| PUT | /api/scopes/{id}/self-registration | Open or close self-registration for a scope (FR-SR-01, FR-SR-02) | SystemAdmin, existing Owner |
| PUT | /api/scopes/{id}/email-links | Set, or with null or empty clear, the scope’s verification and password-reset link bases (FR-SR-10) | SystemAdmin, existing Owner |
| GET | /api/scopes/{id}/pending-registrations | List the scope’s self-registrations awaiting confirmation (FR-SR-11) | SystemAdmin, existing Owner |
| DELETE | /api/scopes/{id}/pending-registrations/{registrationId} | Discard a pending self-registration; its link stops working (FR-SR-12) | SystemAdmin, existing Owner |
5.2 Person Endpoints
| Method | Endpoint | Description | Auth Required |
|---|
| POST | /api/persons | Create a SystemAdmin or ScopeAdmin person (no scope) | SystemAdmin |
| POST | /api/scopes/{scopeId}/persons | Create a User person within a scope | ScopeAdmin (owner)+ |
| GET | /api/scopes/{scopeId}/persons | List Users within a scope | ScopeAdmin (owner)+ |
| GET | /api/persons/{id} | Get person by ID | Authenticated |
| GET | /api/persons/scope-admins | List the persons who may own a scope (FR-PE-12) | SystemAdmin, ScopeAdmin |
| GET | /api/persons/system-admins | List the SystemAdmin persons (FR-PE-13) | SystemAdmin |
| PUT | /api/persons/{id} | Update a person; an own email change also needs the current password, and the second factor when active (FR-PE-14) | ScopeAdmin (owner)+ / self |
| DELETE | /api/persons/{id} | Logically delete a person | ScopeAdmin (owner)+ |
| DELETE | /api/persons/{id}/hard | Hard delete a person | SystemAdmin |
| POST | /api/persons/{id}/verification-email | Send a fresh verification link to the person’s own address (FR-EV-06, FR-EV-07) | SystemAdmin, ScopeAdmin (Users of an owned scope) |
| POST | /api/persons/{id}/password-reset | Send a password-reset link to the person’s own address (FR-PR-05, FR-PR-06) | SystemAdmin, ScopeAdmin (Users of an owned scope) |
5.3 Application Endpoints
| Method | Endpoint | Description | Auth Required |
|---|
| POST | /api/scopes/{scopeId}/applications | Create an application in a scope | SystemAdmin / ScopeAdmin (owner of the scope, self as owner) |
| GET | /api/scopes/{scopeId}/applications | List applications in a scope — every one for a SystemAdmin, only the caller’s own for a ScopeAdmin | SystemAdmin / ScopeAdmin (owner of the scope) |
| GET | /api/scopes/{scopeId}/applications/{id} | Get application by ID | SystemAdmin / ScopeAdmin (owner of the application) |
| PUT | /api/scopes/{scopeId}/applications/{id} | Update an application | SystemAdmin / ScopeAdmin (owner of the application) |
| DELETE | /api/scopes/{scopeId}/applications/{id} | Logically delete an application | SystemAdmin / ScopeAdmin (owner of the application) |
| DELETE | /api/scopes/{scopeId}/applications/{id}/hard | Hard delete an application | SystemAdmin |
| POST | /api/scopes/{scopeId}/applications/{id}/credentials | Issue a credential; the secret is returned once | SystemAdmin / ScopeAdmin (owner of the application) |
| GET | /api/scopes/{scopeId}/applications/{id}/credentials | List the application’s credentials | SystemAdmin / ScopeAdmin (owner of the application) |
| DELETE | /api/scopes/{scopeId}/applications/{id}/credentials/{credentialId} | Revoke a credential | SystemAdmin / ScopeAdmin (owner of the application) |
| POST | /api/applications/token | Exchange a credential for an application token | No (the secret authenticates) |
| GET | /api/applications/self | Check an application token live | Application token (validated by the endpoint) |
5.4 Authentication Endpoints
| Method | Endpoint | Description | Auth Required |
|---|
| POST | /api/auth/login | Authenticate by email + password | No |
| POST | /api/auth/password-recovery | Request password reset email | No |
| POST | /api/auth/password-reset | Reset password with token | No |
| POST | /api/auth/verify-email | Verify email with token; a token that names a pending self-registration confirms it and also takes the optional password chosen at sign-up (FR-SR-09) | No |
| POST | /api/auth/register | Register a password User in an open scope (FR-SR-03 … FR-SR-09) | No (anonymous) |
| POST | /api/auth/resend-verification | Resend verification email | Authenticated |
| POST | /api/auth/2fa/enable | Initiate two-factor setup, selecting App, Email, or both; needs the current password (FR-2F-18) | Authenticated |
| POST | /api/auth/2fa/confirm | Confirm setup with a code per selected method; activates 2FA and returns the ten recovery codes | Authenticated |
| POST | /api/auth/2fa/verify | Exchange a challenge token plus a code or recovery code for a full authentication token | No (holds a challenge token) |
| POST | /api/auth/2fa/challenge/resend | Reissue the email code for an outstanding challenge, inside its existing window | No (holds a challenge token) |
| POST | /api/auth/2fa/disable | Disable two-factor authentication (requires password + a valid second factor) | Authenticated |
| POST | /api/auth/2fa/recovery-codes/regenerate | Invalidate the current recovery codes and issue ten new ones | Authenticated |
5.5 Google Sign-In Endpoints
| Method | Endpoint | Description | Auth Required |
|---|
| POST | /api/auth/google | Sign up or sign in with Google ({ scopeId, idToken }); creates a Google User on first use, authenticates on subsequent uses | No |
| POST | /api/auth/google/sign-out | Sign out a Google User, invalidating/discarding their token | Authenticated (Google User) |
| GET | /api/scopes/{scopeId}/google-users | List Google Users within a scope | ScopeAdmin (owner)+ |
| GET | /api/scopes/{scopeId}/google-users/{id} | Get a Google User by ID | Authenticated |
| DELETE | /api/scopes/{scopeId}/google-users/{id} | Logically delete a Google User | ScopeAdmin (owner)+ |
| DELETE | /api/scopes/{scopeId}/google-users/{id}/hard | Hard delete a Google User | SystemAdmin |
5.6 Scope Permission Endpoints
| Method | Endpoint | Description | Auth Required |
|---|
| POST | /api/scopes/{scopeId}/permissions | Create a scope permission within a scope | SystemAdmin / ScopeAdmin (owner of the scope) |
| GET | /api/scopes/{scopeId}/permissions | List the permissions of a scope | SystemAdmin / ScopeAdmin (owner of the scope) |
| GET | /api/scopes/{scopeId}/permissions/{id} | Get a scope permission by ID within its scope | SystemAdmin / ScopeAdmin (owner of the scope) |
| PUT | /api/scopes/{scopeId}/permissions/{id} | Update a scope permission | SystemAdmin / ScopeAdmin (owner of the scope) |
| DELETE | /api/scopes/{scopeId}/permissions/{id} | Logically delete a scope permission | SystemAdmin / ScopeAdmin (owner of the scope) |
| DELETE | /api/scopes/{scopeId}/permissions/{id}/hard | Hard delete a scope permission | SystemAdmin |
5.7 Audit Trail Endpoints
| Method | Endpoint | Description | Auth Required |
|---|
| GET | /api/audit-log | List the audit trail, newest first, filtered by from/to, actorId, action, targetId, succeeded (FR-AT-01, FR-AT-02) | SystemAdmin |
6. Non-Functional Requirements
The security requirements below state what this API does. What they defend against, and what is left
after them, is in the Threat Model Document
— including two cases
where a control is narrower than the threat it appears to cover.
| ID | Category | Requirement |
|---|
| NFR-01 | Technology | The API shall be built using ASP.NET Core on the .NET platform (specific framework/language versions and libraries: see the Technology Stack Document
) |
| NFR-02 | Security | Passwords shall be hashed using a strong algorithm (e.g., bcrypt, Argon2) combined with a per-person random Salt, both stored as byte arrays |
| NFR-03 | Security | Authentication tokens shall be signed and have configurable expiration |
| NFR-04 | Security | Every endpoint shall require a valid authentication token, except those a caller cannot yet hold one for or is not expected to: the authentication endpoints themselves (UC-11 login, UC-12/UC-13 password recovery and reset, UC-14 email verification, UC-25 Google sign-in, UC-38 second-factor verification, UC-47 self-registration) and the public liveness check (FR-HC-01). Each is marked AllowAnonymous; no other endpoint may be |
| NFR-05 | Performance | Every endpoint except those that verify a password shall complete within 100 ms at the median and 250 ms at the tail — the slowest of a single caller’s samples (§6.1), the 99th percentile under load (§6.3) — and shall hold that while 128 callers are in flight at once, answering all of them without a failed or 5xx response. Both conditions are measured on the reference configuration. The write path meets the load condition with the least margin and is sensitive to how many rows the table already holds (§6.3.2). Password-verifying endpoints are governed by NFR-18 |
| NFR-06 | Availability | The API shall hold no state in a process or on a local filesystem that a second instance would need: authentication is validated from token claims with no server-side session, and the Data Protection key ring that TOTP secrets are encrypted with (NFR-16) is kept in the database. The per-IP rate limiter’s window is the one deliberate exception, and is per instance by design (§6.2) |
| NFR-07 | Data Integrity | After any logical deletion, including UC-04’s cascade across a scope’s Users, Google Users and applications, every foreign key in the schema shall still resolve to an existing row. Logical deletion removes no rows, so the rows it leaves behind must remain reachable |
| NFR-08 | Data Integrity | Hard deletion of a scope must cascade to its SCOPE_USER/SCOPE_OWNER rows, its Users, and its Applications |
| NFR-09 | Logging | Every attempted write shall produce one audit entry, whether it succeeded or was refused, recording the acting identity, the operation, the affected entity where one was resolved, the outcome, and — on a refusal — the reason. The reason is drawn from the application’s own messages, never from caller-supplied input |
| NFR-10 | Validation | All inputs shall be validated before processing. An input that cannot be bound — a body that is not JSON, or a value of the wrong type in the body or query string — is refused with 400 Bad Request and the message “The request is malformed.”, before the action runs. Authentication and role checks come first, so an unauthenticated caller still receives 401 and a caller of the wrong role 403; refusals that depend on data, made in a handler, come after |
| NFR-11 | Data Integrity | Hard deletion of a person must cascade to the applications they own |
| NFR-12 | Data Integrity | A scope must always retain at least one owner; removing the last owner or hard-deleting the last owning person must be rejected |
| NFR-13 | Security | Google ID tokens must be verified (signature, issuer accounts.google.com/https://accounts.google.com, audience matching the configured OAuth client ID, and expiration) before their claims are trusted |
| NFR-14 | Data Integrity | Hard deletion of a scope must also cascade to its Google Users |
| NFR-15 | Security | Internal bigint primary/foreign keys must never appear in API responses, API paths, or token claims; only PublicId (GUID) values may be exposed to callers |
| NFR-16 | Security | A TOTP secret shall be stored encrypted at rest, and a recovery code shall be stored only as a hash; neither is ever returned to a caller after the response that first generates it |
| NFR-17 | Security | A two-factor challenge token shall carry a distinct claim marking it as MFA-pending, expire 10 minutes after issue — a fixed lifetime, not a configurable one, and the same ten minutes FR-2F-03 gives the email code issued alongside it, so a login attempt has one deadline rather than two — and be rejected by every endpoint except second-factor verification |
| NFR-18 | Performance | An endpoint that verifies a password is bounded by the cost of the password hash, not by NFR-05, and that cost is deliberate: it is what makes an offline attack on a stolen hash expensive. Every outcome shall pay it, including a rejection for an address that belongs to nobody (FR-AU-10). On the reference configuration this measures 347–491 ms at the median and 534–582 ms at the slowest, the spread being idle versus contended (§6.1). Concurrency moves it much further: the ten logins a minute the rate limiter admits from one IP are released together and measured 2.8 seconds each (§6.3.1), because each is a 600 MB / 16-thread derivation competing with nine others. The figure moves with the hashing parameters, the hardware and the load, and is republished rather than held constant |
| NFR-19 | Data Protection | Personal data shall be kept in identifiable form only as long as its purpose requires (GDPR Art. 5(1)(e), LGPD Art. 15). Every table holding personal data shall carry a stated retention period, published in the Data Retention Schedule Document
together with the reason for it, and each period the API enforces itself shall be enforced by a scheduled pass rather than by hand. A single-use token — password reset, email verification, two-factor email code — shall be removed once it is past its expiry by the configured grace period, and never before: the grace period is what keeps UC-13’s TokenExpired and TokenAlreadyUsed answers truthful. A period the schedule states but nothing enforces shall be marked as such, so the document never claims a guarantee the code does not make |
| NFR-20 | Data Protection | A logically deleted identity shall reach a terminal state. Once the retention window its deletion kind carries has elapsed (Data Retention Schedule
§4), the record shall retain no value identifying a natural person — name, address, credential material and behavioural counters, and for a Google User the Google subject identifier and profile picture — and the material its dependents hold, the single-use tokens and the two-factor configuration, shall be removed with it. Every foreign key shall still resolve (NFR-07) and no SCOPE_OWNER row shall change, so NFR-12 is undisturbed. The record is anonymised in place rather than removed, because removing it is what breaks NFR-07; hard deletion (UC-10) remains available for callers that want the row gone |
| NFR-21 | Data Protection | An audit entry shall stop naming the identity that produced it once its attribution period has elapsed, or immediately once that identity has been anonymised (NFR-20), whichever comes first. An entry whose target is a person or Google User shall stop naming that target on the same two grounds — the period has elapsed, or the target has been anonymised — and an entry whose target was hard-deleted shall stop naming it on the next pass, at any age: each hard delete shall first record the removed id as a tombstone, and a target whose id is tombstoned and resolves to no person or Google User row any more is due. A tombstone shall hold only the id, its kind and when it was written, and shall be removed once it is older than one day and no audit entry names it. Clearing the attribution and clearing a person target are the only changes AUDIT_LOG shall ever permit: every other column stays immutable, the attribution and the target may be set to null but never to another value, a target naming anything other than a person or Google User — on record, or tombstoned and gone — may not be cleared, no row may be deleted or truncated, and the rule shall be enforced by the database rather than by the application. The resulting guarantee is that an action can never be denied, and who took it, or which person it was taken on, can never be reassigned — only forgotten |
| NFR-22 | Data Protection | Application log files shall be removed once they are older than the configured retention period, and no log statement shall write an email address. Where a line needs to refer to an address so an operator can correlate, it shall write a stable non-reversible reference to it instead. The two halves are ordered: the redaction lands no later than the retention limit, since bounding the files without redacting them would give identifiable data a longer life than it had before |
| NFR-23 | Data Protection | Every identity shall record, at creation, the lawful basis its data is processed on and the version of the privacy notice then in force (GDPR Art. 5(2) and 30(1)(c), LGPD Art. 8 §2). A scope may declare the basis for the identities within it, since the tenant is usually their controller; where it declares none, contract performance applies. Consent shall not be selectable while no withdrawal path exists, and identities predating this requirement shall be marked as unrecorded rather than backfilled with a guess |
| NFR-24 | Data Protection | A data subject may have processing of their identity restricted (GDPR Art. 18, LGPD Art. 18 III–IV) — suspended without anything being deleted — on one of Art. 18(1)’s four grounds. Restriction and logical deletion shall be independent states: neither implies the other, and a restricted record shall be preserved exactly as it stands rather than entering NFR-20’s anonymisation window. While restricted, the identity shall not authenticate for anything but its subject’s own rights, shall receive no email but a second-factor code its subject asks for when signing in, and shall be withheld from tenant-facing listings, while remaining reachable by its own subject’s export and by a System Admin. Signing in (UC-11, UC-25, UC-38) shall issue it a subject-rights-only token instead of a full one, so the subject can always reach their rights, however long ago their last token expired. That token, and any token the identity already holds, shall be refused everywhere except where the subject exercises their own rights — export (UC-41), erasure (UC-42), restriction (UC-44) and lifting their own restriction (UC-45); a subject-rights-only token shall stay so confined after the restriction is lifted, and a restricted System Admin, or one holding such a token, shall not lift anybody else’s. A restriction imposed at the subject’s request shall not be lifted by anyone else until the subject has been informed (Art. 18(3)). The subject’s own erasure request (UC-42) shall lift their restriction as their own lift would; a restriction imposed after the request shall hold it |
| NFR-25 | Security | The database and its backups shall be encrypted at rest, and the API’s connection to the database shall require TLS (GDPR Art. 32, LGPD Art. 46). Backups shall have a stated schedule, retention period, location and restore-testing cadence, published in the Operations & Infrastructure Document
. Because a backup is a full copy that is never edited, a restore shall be followed by re-applying every erasure completed since it was taken, from a ledger kept outside the database |
| NFR-26 | Security | The signals the system already records — refused writes, account lockouts, credential-verification shedding — shall be read on a schedule and the ones exceeding their thresholds written to the log at warning level, with a stable marker and kind an alerting rule can match. Each threshold shall be measured over a window rather than as a total, because the same count means different things at different rates. Detection shall not declare a breach: a signal is a reason to look, and the Incident Response Document
is what turns looking into a declaration. The signals shall additionally be collected and delivered to a person on a schedule, reporting each signal once and failing loudly when delivery does not succeed — writing a signal nobody receives satisfies the letter of detection and none of its purpose, since both notification clocks run from awareness rather than from recording |
| NFR-27 | Security | Single-use credentials — recovery codes, email codes, password reset and email verification tokens, the two-factor challenge, and an authenticator-app time step — shall be spent at most once, and the bounded budgets of FR-AU-09, FR-AU-12, FR-AU-13, FR-PE-16, FR-SR-09, FR-SR-14, FR-2F-13, FR-2F-16 and FR-2F-17 shall never admit more attempts than they state, including when requests arrive concurrently. Each check shall be made by the same database write that spends or charges, and every budget shall be charged before the comparison it guards rather than after it fails |
| NFR-28 | Security | An instance that serves a public host — PUBLIC_HOST, UI_HOST, the host of the verification or password-reset URL, or a CORS origin naming anything but a loopback name or address — shall run with every diagnostic that exposes data off: EF Core’s sensitive data logging and detailed errors, and the developer exception page, decided once at start-up for all three. It shall still start, and log one warning naming the hosts and the way to keep them on. They shall stay on only with HEIMDALL_ALLOW_SENSITIVE_DIAGNOSTICS=true, the operator’s statement that the instance holds made-up people only (Operations & Infrastructure Document
§0.2), and such a start shall log a warning too |
| NFR-29 | Security | The API shall run as a database role that owns no schema object. Migrations shall be applicable with a different connection — the container entrypoint uses HEIMDALL_MIGRATIONS_CONNECTIONSTRING for its migration bundle when it is set, and the API’s own connection string otherwise — and the operations guide shall give the grants that let the API’s role do everything it does at runtime and nothing else: no DELETE on the audit trail, no TRUNCATE, no CREATE, and no ownership of a table, a trigger or a trigger function (Threat Model TH-18). The roles are created and the grants applied by the operator |
| NFR-30 | Security | Every token the API reads — a bearer token, a two-factor challenge token, an application token at UC-57 — shall be accepted only when, besides its signature and lifetime, it names the configured HEIMDALL_AUTH_TOKEN_ISSUER as its issuer (no issuer when none is configured) and exactly one audience, one the reader accepts: the person, challenge and subject-rights-only audiences on a bearer token, the challenge audience on a challenge token, the application audience on an application token. Any other token shall be refused as an invalid token, 401 Unauthorized, however correct its signature (Threat Model TH-43) |
| NFR-31 | Security | The token signing secret HEIMDALL_AUTH_TOKEN_SECRET, and HEIMDALL_AUTH_TOKEN_SECRET_PREVIOUS when it is set, shall each be at least 32 bytes and ASCII only, or start-up shall fail with a message that names the variable and never its value. A previous secret that is unset, blank or two double quotes ("") shall mean no previous key. Tokens shall be signed and accepted with HS256 only (Threat Model TH-22) |
6.1 The reference configuration
The figures in NFR-05 and NFR-18 are measurements, not estimates. They were taken by
ResponseTimeMeasurementTests, which drives the running API against the suite’s PostgreSQL
container and writes TestResults/response-times.md. Reproducing them is a test run, not an
exercise in trusting this document.
Two runs are quoted, because the difference between them is itself a finding. Idle is the
measurement suite running alone. Loaded is the same suite as part of a full solution test run, with
the rest of the functional tests and their container competing for the same machine.
| Endpoint | Class | Median (idle) | Slowest (idle) | Median (loaded) | Slowest (loaded) |
|---|
GET /HealthCheck | unauthenticated | 0.3 ms | 0.5 ms | 0.4 ms | 1.0 ms |
GET /api/persons/{id} | authenticated read | 3.0 ms | 3.3 ms | 3.4 ms | 8.3 ms |
POST /api/scopes/{id}/permissions | authenticated write | 5.5 ms | 8.2 ms | 16.8 ms | 38.2 ms |
GET /api/scopes/{id}/persons | list, 50 rows, page of 25 | 5.7 ms | 17.2 ms | 14.4 ms | 18.8 ms |
POST /api/auth/login | password verification | 346.6 ms | 533.6 ms | 491.4 ms | 581.9 ms |
Reference configuration: 32 logical processors, PostgreSQL in a local container, server-side time
only — no network between caller and API. Median and slowest of 12 samples after 3 discarded
warm-up requests, which pay for JIT, the EF model and the first query plan: costs a process meets
once rather than costs a caller meets per request.
The loaded column is why NFR-05’s budget is set at 100 ms rather than at the idle figures. Contention
moves a write from 5.5 ms to 16.8 ms and its slowest sample from 8.2 ms to 38.2 ms — a factor of
three to five, on a machine with thirty-two cores and no other tenant. A budget written to the idle
numbers would be a budget that fails whenever the machine is busy, which is precisely when a
response-time requirement is supposed to hold.
Two things follow from the table, and both were invisible while NFR-05 was a single unmeasured
number. Everything that is not a password check runs one to two orders of magnitude inside the old
500 ms budget, so that budget constrained nothing. And the one endpoint it should have constrained
already exceeded it — 534 ms at the slowest when idle, 582 ms under load, on hardware more generous
than most production deployments — because Argon2id is expensive on purpose. A single figure could
describe neither.
The automated ceilings in the measurement tests are set well above these numbers. They exist to
catch an order-of-magnitude regression — an accidental N+1 in a listing, a password check running
twice — not to certify conformance on a shared runner, where a scheduling hiccup would otherwise
fail a build for no defect.
6.2 What horizontal scaling depends on
NFR-06 is a statement about shared state, and verifying it found a place where the claim did not
hold. ASP.NET Core’s Data Protection stores its key ring on the local filesystem by default; the
container image does not persist that directory and two instances do not share it. The TOTP secrets
of UC-36 are encrypted with those keys, so a second instance — or the same instance after being
recreated — could not decrypt them. Nothing announced it: the protector throws, the verifier reports
a wrong code, and every caller whose second factor is an authenticator app is quietly locked out of
it. The key ring is now kept in the database, and DataProtectionKeyRingTests builds two providers
the way two instances would and requires one to read the other’s payload.
What remains per-instance is the rate limiter’s fixed window (§ Operations). That is deliberate and
documented there: it is a defence-in-depth throttle, and the per-account budgets of FR-AU-09,
FR-AU-12, FR-AU-13, FR-PE-16 and FR-2F-13, the per-address notice budget of FR-SR-14 and the
token-derived cooldowns of FR-EV-07, FR-PR-06 and FR-PR-07 — which are in the database — are what hold
across instances.
6.3 Under load
NFR-05 used to promise a response time “under normal load” while nothing in the repository generated
any, so the clause could be neither met nor missed and the phrase was removed. It is back, with a
number behind it: scripts/load_test.py drives a deployment through
tools/ArturRios.Heimdall.LoadTest, and the figures below are what that produced.
Two runs, at 32 and at 128 callers in flight, 20 seconds per scenario, against the container image
served over loopback with PostgreSQL in a second container, each from a freshly migrated database
and each after the harness warmed the scenario at the same concurrency.
Throughput and latency describe the requests the API answered. Attempts counts everything sent,
which for login is overwhelmingly what the rate limiter refused — see below.
| Callers | Scenario | Attempts | 2xx | 2xx/s | p50 | p95 | p99 | Max | 429 | Failed |
|---|
| 32 | GET /HealthCheck | 347,535 | 347,535 | 17,377 | 1.6 ms | 3.7 ms | 5.0 ms | 18.2 ms | 0 | 0 |
| 32 | GET /api/scopes | 82,465 | 82,465 | 4,123 | 7.5 ms | 10.4 ms | 12.6 ms | 32.3 ms | 0 | 0 |
| 32 | POST /api/scopes | 24,969 | 24,969 | 1,249 | 25.3 ms | 33.9 ms | 38.1 ms | 47.3 ms | 0 | 0 |
| 32 | POST /api/auth/login | 581,363 | 10 | 0.5 | 2,787 ms | — | — | 2,880 ms | 581,353 | 0 |
| 128 | GET /HealthCheck | 438,858 | 438,858 | 21,943 | 5.5 ms | 9.9 ms | 12.6 ms | 29.7 ms | 0 | 0 |
| 128 | GET /api/scopes | 93,364 | 93,364 | 4,668 | 26.9 ms | 38.1 ms | 44.4 ms | 73.6 ms | 0 | 0 |
| 128 | POST /api/scopes | 26,577 | 26,577 | 1,329 | 94.7 ms | 133.9 ms | 154.5 ms | 229.9 ms | 0 | 0 |
| 128 | POST /api/auth/login | 962,514 | 10 | 0.5 | 2,890 ms | — | — | 2,980 ms | 962,504 | 0 |
Nothing failed, timed out or answered 5xx in either run, across 2.5 million requests.
NFR-05’s load condition is set at 128 because every non-password endpoint still meets it there — but
the write meets it by 5%, not by an order of magnitude. POST /api/scopes runs at 94.7 ms against a
100 ms median budget, where the reads sit ten to sixty times inside theirs. The write is the endpoint
that will breach NFR-05 first, and §6.3.2 is why that matters sooner than the margin suggests.
6.3.1 Login is limited to ten requests a minute, and that is the design
Both runs sent login well over half a million times and were answered ten times. That is not a
defect: the per-IP fixed window in §Operations permits 10 requests a minute, and it refused
everything else in under a millisecond. The limiter is doing exactly what it was written to do.
What the run adds is the cost of the ten that got through: 2.8 seconds each, against the 534–582
ms NFR-18 documents for a single caller. The window releases its whole budget at once, so ten
Argon2id verifications start together, each configured for 600 MB and 16 threads. Ten of them is
6 GB of working set and 160 threads of demand on a 32-core machine, and they queue behind each other.
The consequence was not what a reader of NFR-18 would expect: the rate limiter was not only
anti-brute-force, it was the only thing bounding login’s memory demand. One IP could ask for 6 GB
of Argon2id working set every minute and be within policy, and because the limiter is per-instance
and per-IP (§6.2), callers spread across addresses multiplied that budget rather than sharing it.
There is now a second bound that does not depend on where the callers come from. PasswordHashGate
permits four derivations at once across the whole process — about 2.4 GB — and refuses the rest with
503 after ten seconds rather than queueing them. It covers every derivation on a request path, not
only login: an authenticated caller creating persons in a loop reaches the same memory, and a bound
with a way around it is not a bound. HEIMDALL_AUTH_MAX_CONCURRENT_PASSWORD_HASHES raises or lowers
it (Threat Model TH-03).
Measuring it settled a question the change raised. Bounding concurrency looked like it should make
login slower, since the ten the limiter admits now queue behind four permits. It did not: the same
128-caller run measured 2,314 ms at the median against 2,890 ms without the gate. Ten concurrent
600 MB derivations contend for memory bandwidth badly enough that doing four at a time finishes the
same work no slower. The reading is “the bound cost nothing measurable”, not “the bound made login
faster” — the two runs were taken on a machine in different states, and the figure moves with both.
What a load run from one address cannot show is the gate shedding, because the rate limiter admits
only ten a minute from one IP and the gate permits four at once. The 503 path is verified by test
instead — a functional test holds the only permit and requires login to answer 503 with a
Retry-After rather than 500.
6.3.2 The write degrades with the size of the table
The 128-caller write figures above are from an empty database. Repeating the run against the same
deployment after roughly 45,000 scopes had accumulated from earlier runs gave 168 ms at the median
and 264 ms at the 99th — breaching NFR-05 on both counts, on the same hardware at the same
concurrency, with nothing changed but the row count.
The indexes are not the explanation: scope carries a unique index on name and another on
public_id, so neither the uniqueness check nor the lookup degrades into a scan. What grows is the
insert-side work — index maintenance across the row’s own indexes, the scope_owner insert, and the
audit entry NFR-09 requires on every write — under sustained insert pressure with autovacuum trying
to keep up.
So NFR-05’s write budget is met at a row count the load run creates in twenty seconds and missed at a
row count it creates in a few minutes. That is a real limit rather than a measurement artefact, and
it is the strongest argument in this document for treating these figures as a baseline to compare
against rather than a service level to quote. It also names the next performance question worth
asking, which none of these runs answers: how the write path behaves at a production row count,
against a database that has been vacuumed and analysed rather than filled in one burst.
These numbers are not comparable to §6.1’s and must not be read against them. §6.1 measures
server-side handler time for one caller; this measures wall-clock at the client and therefore
includes every queue between the two, which is the whole reason to run it.
Warm-up dominates a cold deployment badly enough to invalidate a run, which is why the harness
discards an opening phase at the same concurrency before it starts sampling. Measured before that
existed, a run against a freshly started container reported the authenticated read and the health
check at two to four times their warm medians — the JIT, the EF model and the first query plans,
all charged to the first few seconds of traffic.
The harness is a closed loop — each caller waits for its own response before sending the next — so
the offered rate falls as the API slows, and the latencies are optimistic against a fixed-rate
arrival. Correcting for that would mean building a benchmark harness, which is not what NFR-05 needs.
Compare these runs against each other across releases; do not publish them as a service level.
The run is deliberately not part of dotnet test. It needs a deployment, takes minutes, and its
figures depend on the machine it is driven from — none of which belongs in a suite that gates a
merge.
6.4 Accepted exception to NFR-15
Person.RoleId. RoleId is an internal bigint foreign key into
the Role table, and is exposed as Role in PersonOutput/CreatePersonCommandOutput/
UpdatePersonCommandOutput and as a roleId JWT claim, rather than Role.PublicId or a symbolic
name. This is accepted rather than fixed: Role is not an addressable resource — there is no
endpoint that looks a role up by ID, nothing to enumerate, and no per-tenant data behind it — it is a
closed, fixed set of three rows (SystemAdmin=1, ScopeAdmin=2, User=3) that already correspond
1:1 to the public Roles enum every caller of this API already knows. NFR-15 exists to keep an
attacker from inferring the existence or count of other tenants’ rows (scopes, persons,
applications) from a sequential ID; a fixed three-value role lookup carries none of that
information, so threading a second identifier through every response and claim for it would add
surface area without closing a real gap.
7. Authorization Matrix
block-beta
columns 5
block:header:5
H1["Action"] H2["SystemAdmin"] H3["ScopeAdmin"] H4["User"] H5["Anonymous"]
end
block:row1:5
A1["Create Scope"] A2["✅"] A3["❌"] A4["❌"] A5["❌"]
end
block:row2:5
B1["Delete Scope"] B2["✅"] B3["❌"] B4["❌"] B5["❌"]
end
block:row3:5
C1["Create User"] C2["✅"] C3["✅ (owned scope)"] C4["❌"] C5["❌"]
end
block:row4:5
D1["Update Person"] D2["✅"] D3["✅ (owned scope)"] D4["✅ (self)"] D5["❌"]
end
block:row5:5
E1["Hard Delete"] E2["✅"] E3["❌"] E4["❌"] E5["❌"]
end
block:row6:5
F1["Login"] F2["✅"] F3["✅"] F4["✅"] F5["✅"]
end
block:row7:5
G1["Password Recovery"] G2["✅"] G3["✅"] G4["✅"] G5["✅"]
end
block:row8:5
H1["Create Application"] H2["✅"] H3["✅ (own scope, self as owner)"] H4["❌"] H5["❌"]
end
block:row9:5
I1["Update/Delete Application"] I2["✅"] I3["✅ (owned application)"] I4["❌"] I5["❌"]
end
block:row10:5
J1["Add/Remove Scope Owner"] J2["✅"] J3["✅ (owned scope)"] J4["❌"] J5["❌"]
end
block:row11:5
K1["Assign SystemAdmin Role"] K2["✅"] K3["❌"] K4["❌"] K5["❌"]
end
block:row12:5
L1["Create/Promote Co-Owner (own scope)"] L2["✅"] L3["✅ (owned scope)"] L4["❌"] L5["❌"]
end
block:row13:5
M1["Enable/Disable Google Sign-In"] M2["✅"] M3["✅ (owned scope)"] M4["❌"] M5["❌"]
end
block:row14:5
N1["Google Sign-Up / Sign-In"] N2["N/A"] N3["N/A"] N4["N/A"] N5["✅ (unauthenticated call)"]
end
block:row15:5
O1["Read/Delete Google User"] O2["✅"] O3["✅ (owned scope)"] O4["✅ (self)"] O5["❌"]
end
block:row16:5
P1["Create/Read/Update/L-Delete Scope Permission"] P2["✅"] P3["✅ (owned scope)"] P4["❌"] P5["❌"]
end
block:row17:5
Q1["Hard Delete Scope Permission"] Q2["✅"] Q3["❌"] Q4["❌"] Q5["❌"]
end
block:row18:5
R1["Enable/Confirm/Disable/Regenerate 2FA"] R2["✅ (self)"] R3["✅ (self)"] R4["✅ (self)"] R5["❌"]
end
block:row19:5
S1["Verify 2FA Challenge"] S2["N/A"] S3["N/A"] S4["N/A"] S5["✅ (holds challenge token)"]
end
block:row20:5
T1["Enable/Disable Self-Registration"] T2["✅"] T3["✅ (owned scope)"] T4["❌"] T5["❌"]
end
block:row21:5
U1["Register a Password User"] U2["✅"] U3["✅"] U4["✅"] U5["✅"]
end
block:row22:5
V1["Set Scope Email Links"] V2["✅"] V3["✅ (owned scope)"] V4["❌"] V5["❌"]
end| Action | SystemAdmin | ScopeAdmin | User | Anonymous |
|---|
| Create Scope | ✅ | ❌ | ❌ | ❌ |
| Read Scope by ID | ✅ | ✅ (owned) | ✅ (belongs to) | ❌ |
| List Scopes | ✅ | ✅ (owned scopes only) | ❌ | ❌ |
| Update Scope | ✅ | ❌ | ❌ | ❌ |
| Delete Scope (logical) | ✅ | ❌ | ❌ | ❌ |
| Delete Scope (hard) | ✅ | ❌ | ❌ | ❌ |
| Add/Remove Scope Owner (existing ScopeAdmin) | ✅ | ✅ (owned scope) | ❌ | ❌ |
| Create User (in scope) | ✅ | ✅ (owned scope) | ❌ | ❌ |
| Create new ScopeAdmin as co-owner (own scope) | ✅ | ✅ (owned scope) | ❌ | ❌ |
| Promote existing User to co-owner (own scope) | ✅ | ✅ (owned scope) | ❌ | ❌ |
| Create ScopeAdmin (no scope, or SystemAdmin) | ✅ | ❌ | ❌ | ❌ |
| Read Person | ✅ | ✅ (owned scope) | ✅ (self) | ❌ |
| Update Person | ✅ | ✅ (owned scope) | ✅ (self) | ❌ |
| Delete Person (logical) | ✅ | ✅ (owned scope, Users only) | ❌ | ❌ |
| Delete Person (hard) | ✅ | ❌ | ❌ | ❌ |
| Login | ✅ | ✅ | ✅ | ✅ |
| Password Recovery | ✅ | ✅ | ✅ | ✅ |
| Email Verification | ✅ | ✅ | ✅ | ❌ |
| Send a Verification Email on a Person’s Behalf | ✅ | ✅ (Users of owned scope) | ❌ | ❌ |
| Send a Password Reset on a Person’s Behalf | ✅ | ✅ (Users of owned scope) | ❌ | ❌ |
| Create Application | ✅ | ✅ (owned scope, self as owner) | ❌ | ❌ |
| Read Application | ✅ (any scope) | ✅ (owned application) | ❌ | ❌ |
| Update Application | ✅ | ✅ (owned application) | ❌ | ❌ |
| Delete Application (logical) | ✅ | ✅ (owned application) | ❌ | ❌ |
| Delete Application (hard) | ✅ | ❌ | ❌ | ❌ |
| Issue/List/Revoke Application Credentials | ✅ | ✅ (owned application) | ❌ | ❌ |
| Obtain an Application Token | — | — | — | ✅ (with a live secret) |
| Check an Application Token | — | — | — | ✅ (with an application token) |
| Enable/Disable Google Sign-In | ✅ | ✅ (owned scope) | ❌ | ❌ |
| Enable/Disable Self-Registration | ✅ | ✅ (owned scope) | ❌ | ❌ |
| Set Scope Email Links | ✅ | ✅ (owned scope) | ❌ | ❌ |
| Register a Password User | ✅ | ✅ | ✅ | ✅ |
| List Pending Registrations | ✅ | ✅ (owned scope) | ❌ | ❌ |
| Discard a Pending Registration | ✅ | ✅ (owned scope) | ❌ | ❌ |
| Google Sign-Up / Sign-In | N/A | N/A | N/A | ✅ (unauthenticated call) |
| Google Sign-Out | N/A | N/A | ✅ (self, as a Google User) | ❌ |
| Read Google User | ✅ | ✅ (owned scope) | ✅ (self) | ❌ |
| Delete Google User (logical) | ✅ | ✅ (owned scope) | ❌ | ❌ |
| Delete Google User (hard) | ✅ | ❌ | ❌ | ❌ |
| Create Scope Permission | ✅ | ✅ (owned scope) | ❌ | ❌ |
| Read Scope Permission | ✅ | ✅ (owned scope) | ❌ | ❌ |
| Update Scope Permission | ✅ | ✅ (owned scope) | ❌ | ❌ |
| Delete Scope Permission (logical) | ✅ | ✅ (owned scope) | ❌ | ❌ |
| Delete Scope Permission (hard) | ✅ | ❌ | ❌ | ❌ |
| Enable / Confirm / Disable / Regenerate 2FA | ✅ (self) | ✅ (self) | ✅ (self) | ❌ |
| Verify 2FA Challenge | N/A | N/A | N/A | ✅ (holds a valid challenge token) |
| Export Own Personal Data | ✅ (self) | ✅ (self) | ✅ (self, person or Google User) | ❌ |
| Request Erasure of Own Identity | ✅ (self) | ✅ (self) | ✅ (self, person or Google User) | ❌ |
| Restrict Processing of Own Identity | ✅ (self) | ✅ (self) | ✅ (self, person or Google User) | ❌ |
| Lift a Restriction | ✅ (self, or anyone) | ✅ (self only) | ✅ (self only) | ❌ |
| List Outstanding Erasure Requests | ✅ | ❌ | ❌ | ❌ |
| List the Audit Trail | ✅ | ❌ | ❌ | ❌ |
8. Deletion Strategy
flowchart TD
A[Delete Request] --> B{Deletion Type?}
B -->|Logical| C[Set IsDeleted = true]
C --> C1{Entity Type?}
C1 -->|Scope| C2[Cascade: logically delete SCOPE_USER persons, Google Users, and applications in scope]
C1 -->|Person, Google User, or Application| C3[Record remains in database]
C2 --> D[Record remains in database]
C3 --> D
D --> E[Excluded from default queries]
B -->|Hard| F{Entity Type?}
F -->|Person owned by no other scope| G[Permanently remove person record]
G --> K[Cascade: permanently remove applications owned by person, their SCOPE_OWNER/SCOPE_USER rows, and their two-factor configuration and recovery codes]
F -->|Google User| M[Permanently remove Google User record]
F -->|Application| L[Permanently remove application record]
F -->|Scope Permission| SP[Permanently remove scope permission record]
F -->|Scope| H2[Permanently remove scope]
H2 --> I[Cascade: remove SCOPE_OWNER/SCOPE_USER rows; permanently remove SCOPE_USER persons, Google Users, applications, and scope permissions in scope]
G --> J[Record removed from database]
K --> J
M --> J
L --> J
I --> J8.1 What each strategy means under GDPR and LGPD
The two strategies are not merely different mechanics; they discharge different obligations, and §8
described only the mechanics.
Logical deletion is a restriction of processing, not an erasure. It removes the identity from
default queries and stops it authenticating, which is what GDPR Art. 18 and LGPD Art. 18 IV
describe. It is a legitimate state and the system needs it — an administrator must be able to undo a
mistake, and a contested record must be suspendable without being destroyed. What it is not is an
answer to GDPR Art. 17 or LGPD Art. 16: the name, the address and the credential material are all
still there.
Anonymisation is the terminal state, and the one that discharges erasure. After the retention
window (NFR-20), the record retains no value identifying a natural person. GDPR Recital 26 and LGPD
Art. 12 both place anonymised data outside the law’s scope, so an anonymised row is no longer
personal data being retained. It is reached automatically by a scheduled pass, not by a caller.
Anonymisation overwrites rather than removes, and that is a consequence of NFR-07 rather than a
preference: the audit trail, the scope membership and ownership rows, and the applications an owner
held all point at the record, and deleting it breaks every one of those references.
Hard deletion removes the row, along with the cascades §8 lists. It remains available (UC-05,
UC-10, UC-20, UC-29, UC-35) for callers that want the record gone rather than emptied, and it is the
right answer when nothing needs the reference to keep resolving.
Notes on cascading behavior:
Logically deleting a scope logically deletes its SCOPE_USER persons (Users), its Google Users, and its applications, but does not affect Scope Admins who own it — they may own other, active scopes.
Hard deleting a scope permanently removes its SCOPE_OWNER and SCOPE_USER rows, its Users, its Google Users, its applications, and its scope permissions (the latter via the scope_permission → scope foreign key’s ON DELETE CASCADE). It does not remove Scope Admin person records themselves, since they may still own other scopes. NFR-12 does not guard this direction: it protects a scope from losing its owners, and a scope that no longer exists has nothing to protect. A ScopeAdmin whose only scope was hard-deleted is therefore left owning none — see the note below.
Hard deleting a User person removes their SCOPE_USER row. A User cannot own an application (FR-AP-03), so there is nothing further to cascade.
Hard deleting a ScopeAdmin person removes all of their SCOPE_OWNER rows and any applications they own; per NFR-12, this is rejected if it would leave any owned scope with zero owners.
Hard deleting a Google User simply removes its record. A Google User cannot own an application (FR-AP-03 restricts application ownership to a ScopeAdmin or SystemAdmin who owns the scope), so no further cascade is needed.
Hard deleting a scope permission simply removes its record. A scope permission is a leaf in the data model — no entity carries a foreign key to it — so nothing further cascades (FR-SP-08).
Logically deleting a scope permission flips only its own IsDeleted flag (FR-SP-07); nothing cascades, for the same leaf reason.
Hard deleting a person permanently removes their TWO_FACTOR_AUTH row and its TWO_FACTOR_RECOVERY_CODE rows, via the two_factor_recovery_code → two_factor_auth → person foreign keys’ ON DELETE CASCADE. Logical deletion of a person does not touch two-factor state — a restored person keeps whatever 2FA configuration they had.
Logical deletion of a person or a Google User now records when it happened and why (DeletedAt, DeletionKind), because NFR-20’s window is measured from the deletion and the two kinds carry different deadlines. UpdatedAt cannot serve: any later write moves it, which would silently restart the window.
Anonymisation removes the person’s single-use tokens and their TWO_FACTOR_AUTH row, whose ON DELETE CASCADE foreign keys take the recovery codes and email codes with it. It does not touch SCOPE_USER, SCOPE_OWNER, the applications an owner held, or the audit trail — all are references NFR-07 requires to keep resolving, and none identifies a person once the record it points at has been anonymised. The audit trail’s own actor reference is governed separately (see the retention schedule).
On scope permissions and a logically deleted scope. Unlike applications, scope permissions are not cascaded when their scope is logically deleted (UC-04): the scope’s permissions are left with whatever IsDeleted state they already had. They become unreachable through the listing endpoint, which gates on the scope’s IsDeleted (AF-31a reused), and they are excluded from the JWT-claim fold at login (FR-AU-08), which reads only permissions of non-deleted scopes. They are not purged, so a restored scope recovers its permission set unchanged; and a hard delete of the scope (UC-05) does purge them, via the cascade above.
On a ScopeAdmin left owning no scope. Hard-deleting a scope (UC-05) — and removing an owner (UC-22) — can leave a ScopeAdmin person with zero SCOPE_OWNER rows, which FR-PE-11 says should not happen. The record is deliberately left in place rather than deleted along with the scope: the person may be about to be given another scope (UC-21), and destroying a person as a side effect of a scope operation is not something either use case promises. Until they own a scope again they cannot authenticate — FR-AU-07 rejects a ScopeAdmin with no live owned scope — so the dangling state grants no access. Cleaning it up is UC-10 (Hard Delete Person). Read FR-PE-11 as an invariant the scope-assignment operations maintain (UC-01, UC-06 path c, UC-21, UC-23), not one that survives the removal of the scope itself.
9. Traceability
| Feature | Requirements |
|---|
| Person CRUD | FR-PE-01 through FR-PE-19 |
| Application CRUD and authentication | FR-AP-01 through FR-AP-16 |
| Scope CRUD & Ownership | FR-SC-01 through FR-SC-13 |
| Role Assignment | FR-RO-01 through FR-RO-06 |
| Authentication | FR-AU-01 through FR-AU-13 |
| Password Recovery | FR-PR-01 through FR-PR-08 |
| Email Verification | FR-EV-01 through FR-EV-08 |
| Google Sign-In | FR-GO-01 through FR-GO-20 |
| Self-Registration | FR-SR-01 through FR-SR-14 |
| Scope Permission CRUD | FR-SP-01 through FR-SP-09 |
| Two-Factor Authentication | FR-2F-01 through FR-2F-20 |
| Audit Trail | FR-AT-01 through FR-AT-02 |
| Health & monitoring | FR-HC-01 through FR-HC-07 — specified in the Operations & Infrastructure Document
, §3.4, together with UC-30, since they describe how the system is run rather than what it does for an actor |
| Cross-cutting | NFR-01 through NFR-18 (§6). These are not tied to a single feature; each is listed against the feature it constrains in the sections above where it applies |
3 - Use Case Specification Document
The actor-facing use cases, each with its main flow and numbered alternative flows.
Use Case Specification Document — Heimdall API
1. Introduction
1.1 Purpose
This document specifies the actor-facing use cases for the Heimdall API — UC-01 through UC-29 and UC-31 through UC-40. Each describes actor interactions, preconditions, postconditions, main flows, and alternative/exception flows.
UC-30 (Check API Health) is specified in the Operations & Infrastructure Document
, §3.5, alongside the FR-HC requirements it realises: it describes how the system is operated rather than what it does for an actor, and keeping it with the monitoring requirements keeps the two together.
Note on identifiers: every {id} / {scopeId} / {personId} referenced in these flows is the entity’s PublicId (a GUID). Internally, each entity also has an auto-increment bigint Id used only for storage and joins — it is never seen by any actor in these use cases (see the System Requirements Document, §4.0).
1.2 Actors
| Actor | Description |
|---|
| System Admin | Has full access to all scopes, persons, and applications across the entire system; belongs to no scope |
| Scope Admin | Owns one or more scopes and manages the users and applications within those scopes; can add co-owners to a scope they own by creating a new Scope Admin or promoting an existing User |
| User | An authenticated person belonging to exactly one scope, with basic access to their own profile |
| Anonymous | An unauthenticated caller (can only access public endpoints) |
| Email Service | External system that delivers emails for verification and password recovery |
| Google User | A person authenticated via Google Sign-In rather than a password; always equivalent to the User role, stored in a separate table from Person |
| Google Identity Platform | External system (Google) that authenticates the end user and issues a signed ID token containing their identity claims |
1.3 Use Case Overview
graph LR
subgraph Actors
SA(("System Admin"))
SCA(("Scope Admin"))
U(("User"))
AN(("Anonymous"))
ES(("Email Service"))
GU(("Google User"))
GIP(("Google Identity Platform"))
end
subgraph "Scope Management"
UC01[UC-01: Create Scope]
UC02[UC-02: View Scope]
UC03[UC-03: Update Scope]
UC04[UC-04: Logical Delete Scope]
UC05[UC-05: Hard Delete Scope]
UC21[UC-21: Add Scope Owner]
UC22[UC-22: Remove Scope Owner]
UC23[UC-23: Promote User to Scope Owner]
end
subgraph "Person Management"
UC06[UC-06: Create Person]
UC07[UC-07: View Person]
UC08[UC-08: Update Person]
UC09[UC-09: Logical Delete Person]
UC10[UC-10: Hard Delete Person]
end
subgraph "Authentication & Security"
UC11[UC-11: Login]
UC12[UC-12: Password Recovery]
UC13[UC-13: Reset Password]
UC14[UC-14: Email Verification]
UC15[UC-15: Resend Verification Email]
end
subgraph "Application Management"
UC16[UC-16: Create Application]
UC17[UC-17: View Application]
UC18[UC-18: Update Application]
UC19[UC-19: Logical Delete Application]
UC20[UC-20: Hard Delete Application]
end
subgraph "Google Sign-In"
UC24[UC-24: Enable/Disable Google Sign-In]
UC25[UC-25: Sign Up / Sign In via Google]
UC26[UC-26: Sign Out via Google]
UC27[UC-27: View Google User]
UC28[UC-28: Logical Delete Google User]
UC29[UC-29: Hard Delete Google User]
end
subgraph "Scope Permission Management"
UC31[UC-31: Create Scope Permission]
UC32[UC-32: View Scope Permission]
UC33[UC-33: Update Scope Permission]
UC34[UC-34: Logical Delete Scope Permission]
UC35[UC-35: Hard Delete Scope Permission]
end
subgraph "Two-Factor Authentication"
UC36[UC-36: Enable Two-Factor Authentication]
UC37[UC-37: Confirm Two-Factor Authentication Setup]
UC38[UC-38: Verify Second Factor]
UC39[UC-39: Disable Two-Factor Authentication]
UC40[UC-40: Regenerate Recovery Codes]
end
SA --> UC01 & UC02 & UC03 & UC04 & UC05
SA --> UC21 & UC22 & UC23
SCA --> UC02 & UC21 & UC22 & UC23
SA --> UC06 & UC07 & UC08 & UC09 & UC10
SCA --> UC06 & UC07 & UC08 & UC09
U --> UC07 & UC08
SA & SCA & U --> UC11 & UC15
AN --> UC11 & UC12 & UC13 & UC14
UC12 --> ES
UC14 --> ES
UC06 --> ES
UC15 --> ES
SA --> UC16 & UC17 & UC18 & UC19 & UC20
SCA --> UC16 & UC17 & UC18 & UC19
SA --> UC24 & UC27 & UC28 & UC29
SCA --> UC24 & UC27 & UC28
AN --> UC25
UC25 --> GIP
GU --> UC26 & UC27
UC25 -.-> GU
SA --> UC31 & UC32 & UC33 & UC34 & UC35
SCA --> UC31 & UC32 & UC33 & UC34
SA & SCA & U --> UC36 & UC37 & UC39 & UC40
AN --> UC38
UC36 --> ES
UC38 --> ES
2. Use Case Specifications
UC-01: Create Scope
| Field | Value |
|---|
| ID | UC-01 |
| Name | Create Scope |
| Actors | System Admin |
| Description | Allows a System Admin to create a new scope to onboard a client system, designating at least one Scope Admin as its initial owner |
| Preconditions | Actor is authenticated and has the SystemAdmin role; each specified initial owner is an existing, non-logically-deleted person with the ScopeAdmin or SystemAdmin role |
| Postconditions | A new scope record exists in the system, with one or more SCOPE_OWNER rows linking it to its initial owners |
Main Flow:
sequenceDiagram
actor SA as System Admin
participant API as Heimdall API
participant DB as Database
SA->>API: POST /api/scopes { name, description, ownerIds }
API->>API: Validate input
API->>DB: Check scope name uniqueness
DB-->>API: Name is unique
API->>DB: Verify each ownerId is a non-logically-deleted ScopeAdmin or SystemAdmin
DB-->>API: Owners found
API->>DB: Insert scope record
API->>DB: Insert SCOPE_OWNER row for each owner
DB-->>API: Scope created
API-->>SA: 201 Created { scope }- System Admin sends a request with scope name, optional description, and at least one owner ID.
- The system validates the input fields.
- The system verifies the scope name is unique.
- The system verifies each referenced owner is an existing, non-logically-deleted person with the
ScopeAdmin or SystemAdmin role (FR-SC-08). - The system creates the scope record with
IsDeleted = false and a SCOPE_OWNER row for each initial owner. - The system returns the created scope.
Alternative Flows:
| ID | Condition | Outcome |
|---|
| AF-01a | Scope name already exists | Return 409 Conflict |
| AF-01b | Invalid input data, or no owner specified | Return 400 Bad Request with validation errors |
| AF-01c | Actor is not System Admin | Return 403 Forbidden |
| AF-01d | An owner ID does not reference an existing, non-logically-deleted ScopeAdmin or SystemAdmin | Return 400 Bad Request |
UC-02: View Scope
| Field | Value |
|---|
| ID | UC-02 |
| Name | View Scope |
| Actors | System Admin, Scope Admin, User |
| Description | Retrieve scope details by ID, or list scopes. There are two distinct reads: (a) a single scope by ID, via GET /api/scopes/{id}, open to any authenticated actor; or (b) the list of scopes, via GET /api/scopes, open to System Admins (every scope) and Scope Admins (the scopes they own) |
| Preconditions | Actor is authenticated |
| Postconditions | Scope information is returned |
Main Flow (read a — scope by ID):
- Actor requests scope details by ID.
- The system loads the scope, filtering out logically deleted scopes unless explicitly requested.
- The system checks authorization:
- System Admin: can view any scope.
- Scope Admin: can view only the scopes they own.
- User: can view only the scope they belong to.
- The system returns the scope data.
Main Flow (read b — list scopes):
- A System Admin or a Scope Admin requests a list of scopes, optionally filtering by name
(case-insensitive) and paging the result (FR-SC-03).
- For a Scope Admin, the system keeps only the scopes they own, as the scope-owner records stand now.
- The system filters out logically deleted scopes unless explicitly requested.
- The system returns the page of scopes, its total counting only what the caller may see.
Alternative Flows:
| ID | Condition | Outcome |
|---|
| AF-02a | Scope not found, or logically deleted and not explicitly requested (read a) | Return 404 Not Found |
| AF-02b | Actor not authorized for the requested scope (read a); actor is a User (read b) | Return 403 Forbidden |
On who may list. A Scope Admin’s console has a scopes screen, and building it from the
ownedScopeIds claim plus one read (a) per scope made the screen depend on a sign-in-time snapshot
and cost a request per row. So read (b) is open to Scope Admins, filtered to the scopes they own
before pagination: the total they see is their own count, and the number of scopes other tenants
hold — the fact that kept the listing System-Admin-only — stays out of reach. Ownership is read
from the scope-owner records, so an owner removed by UC-22 stops seeing the scope at once rather
than when their token expires. A User has exactly one scope and reads it by ID; the collection
stays closed to them. System Requirements Document §5.1 and §7 record the same.
UC-03: Update Scope
| Field | Value |
|---|
| ID | UC-03 |
| Name | Update Scope |
| Actors | System Admin |
| Description | Modify an existing scope’s name or description |
| Preconditions | Actor is authenticated with SystemAdmin role; scope exists and is not logically deleted |
| Postconditions | Scope record is updated |
Main Flow:
- System Admin sends an update request with the scope ID and new field values.
- The system validates the input.
- The system verifies the scope exists and is not logically deleted.
- The system applies the updates and sets
UpdatedAt. - The system returns the updated scope.
Alternative Flows:
| ID | Condition | Outcome |
|---|
| AF-03a | Scope not found or logically deleted | Return 404 Not Found |
| AF-03b | Name conflicts with another scope | Return 409 Conflict |
UC-04: Logical Delete Scope
| Field | Value |
|---|
| ID | UC-04 |
| Name | Logical Delete Scope |
| Actors | System Admin |
| Description | Soft-delete a scope by setting IsDeleted = true |
| Preconditions | Actor is authenticated with SystemAdmin role; scope exists |
| Postconditions | Scope IsDeleted is set to true; all Users belonging to the scope (via SCOPE_USER), all Google Users in the scope, and all applications in the scope are also logically deleted. Scope Admins who own the scope are unaffected, since they may own other active scopes. The response reports the scope’s identifier and the total number of Users, Google Users, and applications belonging to the scope |
Main Flow:
sequenceDiagram
actor SA as System Admin
participant API as Heimdall API
participant DB as Database
SA->>API: DELETE /api/scopes/{id}
API->>DB: Find scope by ID
DB-->>API: Scope found
API->>DB: Set scope.IsDeleted = true
API->>DB: Set IsDeleted = true for all SCOPE_USER persons in scope
API->>DB: Set IsDeleted = true for all Google Users in scope
API->>DB: Set IsDeleted = true for all applications in scope
DB-->>API: Updated
API-->>SA: 200 OK- System Admin sends a delete request for a scope.
- The system locates the scope.
- The system sets
IsDeleted = true on the scope. - The system sets
IsDeleted = true on all Users belonging to the scope (via SCOPE_USER), all Google Users in the scope, and all applications in the scope. Scope Admins who own the scope are not modified. - The system returns success, including the scope’s identifier and the total number of Users, Google Users, and applications belonging to the scope (counted regardless of their individual deletion state).
Alternative Flows:
| ID | Condition | Outcome |
|---|
| AF-04a | Scope not found | Return 404 Not Found |
| AF-04b | Scope already logically deleted | Return 200 OK (idempotent); the scope and its Users/Google Users/applications are left unchanged, and the response still reports the scope’s identifier and its User/Google User/application totals |
UC-05: Hard Delete Scope
| Field | Value |
|---|
| ID | UC-05 |
| Name | Hard Delete Scope |
| Actors | System Admin |
| Description | Permanently remove a scope, its Users, its applications, and its scope permissions |
| Preconditions | Actor is authenticated with SystemAdmin role; scope exists |
| Postconditions | Scope, its SCOPE_OWNER/SCOPE_USER rows, its Users, its Google Users, its applications, and its scope permissions are permanently removed from the database. Scope Admin person records are not removed, since they may own other scopes |
Main Flow:
- System Admin sends a hard delete request.
- The system locates the scope.
- The system records a tombstone in
ERASED_SUBJECT for each User and Google User it is about to remove — their PublicId, which kind, and when — before anything is deleted, so the audit entries naming them can still be cleared once their rows are gone (NFR-21, #195). Scope Admins are not removed and get none; nor does the scope. - The system permanently deletes all Users belonging to the scope (via
SCOPE_USER), all Google Users in the scope, and all applications in the scope. - The system removes all
SCOPE_OWNER and SCOPE_USER rows referencing the scope, and cascade-deletes any remaining scope permissions. - The system permanently deletes the scope record.
- The system returns success, including the count of scope permissions removed.
Alternative Flows:
| ID | Condition | Outcome |
|---|
| AF-05a | Scope not found | Return 404 Not Found |
On NFR-12 and this use case. There is deliberately no last-owner guard here, unlike UC-10.
NFR-12 protects a scope from losing its owners; a scope being removed outright has nothing left to
protect. An already logically deleted scope can be hard-deleted too — soft deletion is a state a
cleanup pass starts from, not a block on it.
On an owner left with no scope. A ScopeAdmin whose only scope this removes is left owning
none, which FR-PE-11 describes as a state that should not exist. Their person record is kept
anyway: they may be given another scope next (UC-21), and destroying a person as a side effect of
deleting a scope is not something this use case promises. They cannot authenticate meanwhile
(AF-11e), so the dangling state grants nothing; removing them is UC-10. See §8 of the System
Requirements Document.
UC-06: Create Person
| Field | Value |
|---|
| ID | UC-06 |
| Name | Create Person |
| Actors | System Admin, Scope Admin |
| Description | Register a new person. There are three distinct paths depending on the target role and actor: (a) a User, created by a Scope Admin (owner) or System Admin within a specific scope, via POST /api/scopes/{scopeId}/persons; (b) a ScopeAdmin or SystemAdmin, created by a System Admin with no scope, via POST /api/persons; or (c) a ScopeAdmin, created by an existing owner of a scope (or a System Admin) directly as a co-owner of that scope, via POST /api/scopes/{scopeId}/owners |
| Preconditions | Actor is authenticated; for paths (a) and (c), the target scope exists and is not logically deleted |
| Postconditions | A new person record exists (with a SCOPE_USER row for path (a), a SCOPE_OWNER row for path (c), and no scope association for path (b)); a verification email is sent |
Main Flow (path a — Create User in a scope):
sequenceDiagram
actor Admin as Scope Admin (owner) / System Admin
participant API as Heimdall API
participant DB as Database
participant ES as Email Service
Admin->>API: POST /api/scopes/{scopeId}/persons { name, email, password }
API->>API: Validate input
API->>DB: Verify scope exists and is not logically deleted
DB-->>API: Scope found
API->>DB: Check email uniqueness within scope
DB-->>API: Email is unique
API->>API: Generate random Salt
API->>API: Hash password using Salt
API->>DB: Insert person record { PasswordHash, Salt, RoleId = User }
API->>DB: Insert SCOPE_USER row { ScopeId, PersonId }
DB-->>API: Person created
API->>API: Generate email verification token
API->>DB: Store verification token
API->>ES: Send verification email
ES-->>API: Email queued
API-->>Admin: 201 Created { person }- Scope Admin (an owner of the scope) or System Admin sends a request with person data (name, email, password) targeting a scope.
- The system validates all fields and verifies the scope exists and is not logically deleted.
- The system checks that the email is unique within the scope.
- The system generates a random
Salt and hashes the password using it. - The system creates the person record with
RoleId = User, IsDeleted = false, EmailVerified = false, storing PasswordHash and Salt as byte arrays, and inserts a SCOPE_USER row linking the person to the scope. - The system generates a verification token and sends a verification email.
- The system returns the created person (excluding
PasswordHash and Salt).
Main Flow (path b — Create Scope Admin / System Admin, no scope):
- System Admin sends a request to
POST /api/persons with person data (name, email, password, roleId referencing ScopeAdmin or SystemAdmin). - The system validates all fields and verifies the email is not already held by another
ScopeAdmin or SystemAdmin anywhere in the system (FR-PE-09). - The system generates a random
Salt and hashes the password using it. - The system creates the person record with the requested
RoleId, IsDeleted = false, EmailVerified = false. No SCOPE_OWNER or SCOPE_USER row is created (a ScopeAdmin created this way becomes an owner separately, via UC-21). - The system generates a verification token and sends a verification email.
- The system returns the created person.
Main Flow (path c — Scope Admin creates a new co-owner):
sequenceDiagram
actor Owner as Scope Admin (owner) / System Admin
participant API as Heimdall API
participant DB as Database
participant ES as Email Service
Owner->>API: POST /api/scopes/{scopeId}/owners { name, email, password }
API->>API: Validate input
API->>DB: Verify scope exists and is not logically deleted
DB-->>API: Scope found
API->>DB: Check email uniqueness among admins system-wide
DB-->>API: Email is unique
API->>API: Generate random Salt
API->>API: Hash password using Salt
API->>DB: Insert person record { PasswordHash, Salt, RoleId = ScopeAdmin }
API->>DB: Insert SCOPE_OWNER row { ScopeId, PersonId }
DB-->>API: Person created
API->>API: Generate email verification token
API->>DB: Store verification token
API->>ES: Send verification email
ES-->>API: Email queued
API-->>Owner: 201 Created { person }- A Scope Admin who owns the scope, or a System Admin, sends a request with person data (name, email, password) targeting the scope.
- The system validates all fields and verifies the target scope exists and is not logically deleted.
- The system checks that the email is not already held by another
ScopeAdmin or SystemAdmin
anywhere in the system (since ScopeAdmin emails are not scoped — FR-PE-09). - The system generates a random
Salt and hashes the password using it. - The system creates the person record with
RoleId = ScopeAdmin, IsDeleted = false, EmailVerified = false, and inserts a SCOPE_OWNER row linking the person to the scope as a co-owner. - The system generates a verification token and sends a verification email.
- The system returns the created person.
Alternative Flows:
| ID | Condition | Outcome |
|---|
| AF-06a | Email already belongs to a User of the target scope (path a), or to a ScopeAdmin/SystemAdmin anywhere in the system (paths b, c) — see FR-PE-09 | Return 409 Conflict |
| AF-06b | Scope not found or logically deleted (paths a, c) | Return 404 Not Found |
| AF-06c | Actor other than System Admin attempts path (b) | Return 403 Forbidden |
| AF-06d | Invalid input | Return 400 Bad Request |
| AF-06e | Scope Admin attempts path (a) or (c) on a scope they do not own | Return 403 Forbidden |
UC-07: View Person
| Field | Value |
|---|
| ID | UC-07 |
| Name | View Person |
| Actors | System Admin, Scope Admin, User |
| Description | Retrieve a person’s details or list persons. There are five distinct reads: (a) a single person by ID, via GET /api/persons/{id}; (b) the User persons of a scope, via GET /api/scopes/{scopeId}/persons; (c) the owners of a scope, via GET /api/scopes/{scopeId}/owners; (d) the persons who may own a scope, via GET /api/persons/scope-admins; or (e) the system’s System Admins, via GET /api/persons/system-admins |
| Preconditions | Actor is authenticated; for reads (b) and (c), the target scope exists and is not logically deleted; for read (d), the scope named for exclusion — if any — exists and is not logically deleted |
| Postconditions | Person information is returned, never including PasswordHash or Salt |
Main Flow (read a — person by ID):
- Actor requests a person by ID.
- The system loads the person, excluding logically deleted persons unless
includeDeleted is explicitly requested (FR-PE-08). - The system checks authorization, allowing the read when any of the following holds:
- The actor is a System Admin — they may view any person.
- The actor is the requested person — every actor may view their own record.
- The actor is a Scope Admin and the requested person is a
User of a scope the actor owns. - The actor is a Scope Admin and the requested person is another Scope Admin co-owning one of the actor’s scopes.
- The system returns the person data (excluding
PasswordHash and Salt). The scopes the person owns are listed in full to a System Admin and to the person themselves; to a Scope Admin reading somebody else, only the scopes the actor owns too (FR-PE-19).
Main Flow (read b — list the Users of a scope):
- A System Admin or a Scope Admin requests the Users of a scope, optionally filtering by name or email and paging the result (FR-PE-04).
- The system verifies the scope exists and is not logically deleted.
- The system verifies the actor may read the scope: a System Admin always may; a Scope Admin must own it.
- The system returns the scope’s
User persons, excluding logically deleted persons unless explicitly requested. The scope’s owners are not part of this listing. Each person’s scope ownerships are reported as in read a (FR-PE-19).
Main Flow (read c — list the owners of a scope):
- A System Admin or a Scope Admin requests the owners of a scope, optionally filtering by name or email and paging the result (FR-PE-04).
- The system applies the same scope and authorization checks as read (b).
- The system returns the scope’s owners, excluding logically deleted persons unless explicitly requested. The scope’s Users are not part of this listing. Each owner’s scope ownerships are reported as in read a (FR-PE-19): to a Scope Admin, the scopes the owner shares with them, this one among them.
Main Flow (read d — list the system’s Scope Admins):
- A System Admin or a Scope Admin requests the persons who may own a scope, optionally filtering by name or email, optionally naming a scope whose current owners are to be excluded, and paging the result (FR-PE-12).
- If a scope was named for exclusion, the system verifies it exists, is not logically deleted, and that the actor may manage it: a System Admin always may; a Scope Admin must own it.
- The system returns every
ScopeAdmin person that is not logically deleted — and, for a System Admin caller, every such SystemAdmin person too — less the named scope’s current owners if one was named, projected to the person’s identifier, name, and email only.
Main Flow (read e — list the system’s System Admins):
- A System Admin requests the system’s
SystemAdmin persons, optionally filtering by name or email and paging the result (FR-PE-13). - The system returns every
SystemAdmin person that is not logically deleted and whose processing is not restricted, ordered by name, projected to the person’s identifier, name, and email only.
Alternative Flows:
| ID | Condition | Outcome |
|---|
| AF-07a | Person not found, or logically deleted and not explicitly requested (read a); target scope not found or logically deleted (reads b, c); scope named for exclusion not found or logically deleted (read d) | Return 404 Not Found |
| AF-07b | Actor not authorized to view the requested person (read a); actor is not an owner of the target scope (reads b, c); actor is not an owner of the scope named for exclusion (read d) | Return 403 Forbidden |
On read d being open to a Scope Admin. Read a’s visibility rule lets a Scope Admin see only
the Scope Admins co-owning the scopes they own. Read d is deliberately wider: the client’s UI-14
(Manage scope owners, specified in the heimdall-ui repository) has a Scope Admin add a co-owner
through UC-21, and that person by definition does not own the scope yet, so under read a’s rule
that person could never be found and the flow could not be completed at all. What makes the widening
safe is the projection, not the audience — identifier, name, and email, and nothing else. A Scope
Admin learns that an administrator with a given address exists, which they can already establish
by submitting that address to UC-06 path c and reading the duplicate-email refusal. The
exclusion parameter is gated separately, because it is a different question: subtracting a
scope’s owners from a list reveals them by comparison, so only an actor entitled to manage that
scope may ask for it.
On read e being System Admin only. Read d already offers System Admins to a System Admin
caller, but mixed with the Scope Admins and with no role to tell them apart, because it answers a
different question — who may own a scope. Read e answers who else holds full access to the
deployment. Its only actor is the System Admin, and the endpoint’s
[RoleRequirement(SystemAdmin)] settles that: a ScopeAdmin or User is refused with 403
before the handler runs, and an unauthenticated caller with 401. No data-dependent rule is left,
which is why read e adds no alternative flow — the same reasoning UC-35 and UC-43 follow. A Scope
Admin is excluded because a System Admin’s address is not tenant-facing, the reason read d
withholds System Admins from them too.
UC-08: Update Person
| Field | Value |
|---|
| ID | UC-08 |
| Name | Update Person |
| Actors | System Admin, Scope Admin, User |
| Description | Modify person’s name, email, or role |
| Preconditions | Actor is authenticated; person exists and is not logically deleted |
| Postconditions | Person record is updated. When the email changed, every token the person held is revoked (FR-AU-11), every live password-reset and verification link is retired (FR-PE-17), and the previous address has been sent a notice (FR-PE-15) |
Main Flow:
- Actor sends an update request to
PUT /api/persons/{id} with the person ID and new values. Name
and Email are replaced; RoleId is optional and, when omitted, leaves the role unchanged. An
actor changing their own email also sends currentPassword, and twoFactorCode when their
two-factor authentication is active (step 4). - The system validates the input.
- The system checks authorization, allowing the update when any of the following holds:
- The actor is a System Admin — they may update any person, including
RoleId. - The actor is the person being updated — every actor may update their own name and email.
- The actor is a Scope Admin and the person is a
User of a scope the actor owns — name and email
only; a Scope Admin may never change RoleId.
- If email changes — compared exactly, after trimming, so a change of case is a change too:
- When the actor is the person being updated, the system first re-authenticates them
(FR-PE-14):
currentPassword must match their password, checked as UC-39 checks it (AF-08h), and
when their two-factor authentication is active, twoFactorCode must be a current authenticator-app
or email code or an unused recovery code, checked as UC-38 checks it (AF-08i). A recovery code is
spent, as everywhere. No endpoint sends an email code to a signed-in caller, so a person whose only
method is Email uses a recovery code here. This happens before the uniqueness check, so a caller without them learns
nothing about the new address. An administrator changing someone else’s email presents no
credential of that person: their own authorisation (step 3) covers it. A change of name alone asks
for nothing. - The system checks uniqueness per FR-PE-09 — among the scope’s
Users for a User, among all
ScopeAdmin/SystemAdmin persons for an admin, evaluated against the role the person will hold
after this update — and resets EmailVerified = false. No verification email is sent — issuing a
fresh token is UC-14 / UC-15’s responsibility. - The system retires every live password-reset link and verification link the person holds
(FR-PE-17): they were mailed to the address being replaced. It does so before the new address
is saved and again immediately after, before the notice below, for any mailed to the old
address in between; a reset or verification that read the person before the change writes only
while the address is the one it read, so it cannot write after the change (FR-PR-08, FR-EV-08).
Following a retired link answers UC-13’s AF-13b or UC-14’s AF-14b.
- Just before the update is saved, the system replaces the person’s security stamp, which revokes
every token they hold (FR-AU-11) — whoever made the change — and replaces it once more after the
save. The first replacement means a save that fails costs one extra sign-in rather than leaving
old sessions live; the second ends any session opened while the save was in flight. The new
address is stored trimmed; an address that differs only by surrounding whitespace is no change and
is not written. An actor who changed their own email is answered
200 OK, but the token that made
the request is revoked with the rest: the client signs in again, with the new address. - The system sends a notice to the previous address (FR-PE-15): the account’s email was changed,
and if that was not the person, they should contact an administrator. The notice names neither the
new address nor any link. A failed delivery is logged and does not fail the update.
- If
RoleId changes (System Admin only), the system supports only a change to SystemAdmin,
and removes the person’s SCOPE_USER or SCOPE_OWNER rows so that a System Admin holds no scope
association (FR-PE-10). Any other target role is refused: making someone a ScopeAdmin requires
naming the scope they will own (FR-PE-11) and making someone a User requires naming the scope
they will join (FR-PE-02), neither of which this request carries. Those transitions belong to
UC-21 (Add Scope Owner) and UC-23 (Promote User to Scope Owner). - The system applies updates and sets
UpdatedAt. - The system returns the updated person (excluding
PasswordHash and Salt), its scope ownerships reported as UC-07 read a reports them (FR-PE-19).
On re-authenticating an email change. An address change requires proof of identity, because
account recovery follows the address and a bearer token is not proof that the account holder is
present. An own email change therefore asks for what UC-39 and UC-42 ask for. The previous address
is told, so a change its owner did not make does not go unnoticed, and the stamp rotation ends the
sessions opened before it.
On FR-RO-05. FR-RO-05 states that Scope Admins may assign the User role to persons within
their scope. That is satisfied by UC-06 path (a), where a Scope Admin creates a person in a scope
they own with RoleId = User. UC-08 keeps role changes System-Admin-only, per step 3 and AF-08c.
Alternative Flows:
| ID | Condition | Outcome |
|---|
| AF-08a | Person not found or logically deleted | Return 404 Not Found |
| AF-08b | New email conflicts within the person’s scope, or among admins system-wide (FR-PE-09) | Return 409 Conflict |
| AF-08c | Unauthorized role change (only System Admin may change RoleId) | Return 403 Forbidden |
| AF-08d | Actor not authorized to update the person at all | Return 403 Forbidden |
| AF-08e | Invalid input | Return 400 Bad Request |
| AF-08f | Role change to a role that would require naming a target scope | Return 400 Bad Request |
| AF-08g | Role change would leave a scope with no owner (NFR-12) | Return 409 Conflict |
| AF-08h | Actor changes their own email and currentPassword is missing or wrong (FR-PE-14), or their re-authentication budget is paused (FR-AU-13) | Return 401 Unauthorized — “The password or code is incorrect.” (UC-39’s AF-39b). Nothing is changed |
| AF-08i | Actor changes their own email, their two-factor authentication is active, and twoFactorCode is missing, wrong, expired or already used (FR-PE-14) | Return 401 Unauthorized — “The password or code is incorrect.”, the same answer as AF-08h (UC-39’s AF-39c). Nothing is changed |
| AF-08j | Actor changes their own email (which mails the previous address, FR-PE-15) and their account-email budget refuses it (FR-PE-16): one such email within the cooldown, or five in the current day-long window. Checked once the password and second factor are accepted, and before a recovery or email code is spent | Return 429 Too Many Requests — “An email of this kind was sent to you recently. Try again in a few minutes.” inside the cooldown, or “You have reached the limit for account emails. Try again later.” Nothing is changed or sent, and no recovery or email code is spent; an authenticator-app code was accepted, so its time step is recorded as any accepted one’s is (FR-2F-14) and a retry needs the next code |
| AF-08k | Actor changes their own email while their email two-factor method is enabled, active or pending (FR-PE-18). Checked once the password and second factor are accepted | Return 409 Conflict — “Turn off the email sign-in method before changing your email address.” Nothing is charged, changed or sent, and no recovery or email code is spent; an authenticator-app code was accepted, so its time step is recorded as any accepted one’s is (FR-2F-14) and a retry needs the next code. The person disables the method (UC-39), or re-initiates a pending setup without it (UC-36), changes the address, and sets the method up again at the new one |
UC-09: Logical Delete Person
| Field | Value |
|---|
| ID | UC-09 |
| Name | Logical Delete Person |
| Actors | System Admin, Scope Admin |
| Description | Soft-delete a person by setting IsDeleted = true |
| Preconditions | Actor is authenticated; person exists; the actor is not the person being deleted |
| Postconditions | Person’s IsDeleted is true. Nothing else changes: their SCOPE_USER/SCOPE_OWNER rows, tokens, and owned applications are left untouched (see §8 of the System Requirements Document) |
Main Flow:
- Actor sends
DELETE /api/persons/{id} for a person. - The system loads the person in any deletion state — an already-deleted person must be found so
AF-09b can answer idempotently rather than as a
404. - The system checks authorization: System Admin may delete any person; a Scope Admin may only delete
a
User within a scope they own. - The system sets
IsDeleted = true on the person record and stamps UpdatedAt. - The system returns success, reporting whether the person was already deleted.
Alternative Flows:
| ID | Condition | Outcome |
|---|
| AF-09a | Person not found | Return 404 Not Found |
| AF-09b | Already logically deleted | Return 200 OK (idempotent) |
| AF-09c | Actor not authorized to delete the person (a Scope Admin targeting a person who is not a User of a scope they own) | Return 403 Forbidden |
| AF-09d | Actor is the person being deleted | Return 403 Forbidden |
| AF-09e | Person is the sole owner of one or more scopes, whatever their role (NFR-12) | Return 409 Conflict — “Cannot remove the last owner of a scope” |
On AF-09e. NFR-12 names only removing an owner (UC-22) and hard-deleting the last owning
person (UC-10). It is applied to a logical deletion too because a soft-deleted ScopeAdmin can no
longer authenticate, so a scope whose only owner is soft-deleted is effectively ownerless — the
state NFR-12 exists to prevent. UC-08 resolves the same tension the same way for its role change.
On the order of AF-09b and AF-09e. An already-deleted person is answered before the last-owner
check runs. Such an owner is already out of the scope, so re-checking would turn the idempotent
success AF-09b requires into a conflict.
On AF-09d. UC-09 grants a System Admin the right to delete “any person”, which would literally
include themselves. Self-deletion is refused so a single call cannot lock an administrator out of
the system.
UC-10: Hard Delete Person
| Field | Value |
|---|
| ID | UC-10 |
| Name | Hard Delete Person |
| Actors | System Admin |
| Description | Permanently remove a person record from the database |
| Preconditions | Actor is authenticated with SystemAdmin role; person exists; the actor is not the person being deleted; if the person owns any scope, removing them must not leave it without an owner |
| Postconditions | Person record, all associated tokens, their SCOPE_USER/SCOPE_OWNER rows, and any applications they own are permanently removed |
Main Flow:
- System Admin sends a hard delete request to
DELETE /api/persons/{id}/hard. - The system loads the person in any deletion state — a logically deleted person is exactly what a
cleanup pass starts from, so soft deletion must not block a hard one.
- If the person owns any scope, the system verifies that every scope they own has at least one other owner.
- The system records a tombstone for the person in
ERASED_SUBJECT — their PublicId, that it was a person, and when — before anything is deleted, so the audit entries naming them, this hard delete’s own among them, can still be cleared once the row is gone (NFR-21, #195). A retry records it again without error. - The system permanently deletes all tokens (password reset, email verification) associated with the person.
- The system permanently deletes any applications owned by the person.
- The system removes the person’s
SCOPE_USER row (if a User) or SCOPE_OWNER rows (if they own any scope). - The system permanently deletes the person record.
- The system returns success, reporting how many applications and tokens went with the person.
Alternative Flows:
| ID | Condition | Outcome |
|---|
| AF-10a | Person not found | Return 404 Not Found |
| AF-10b | Person is the sole owner of one or more scopes | Return 409 Conflict — “Cannot remove the last owner of a scope” |
| AF-10c | Actor is the person being deleted | Return 403 Forbidden |
On AF-10c. UC-10 grants a System Admin the right to remove a person, which would literally include
themselves. Self-deletion is refused so a single, irreversible call cannot destroy the caller’s own
account. UC-09 refuses the same thing for the same reason (AF-09d).
On AF-10b and an already-deleted target. The last-owner check runs even when the person is
already logically deleted — deliberately the opposite of UC-09, where the idempotent AF-09b is
answered before the guard. NFR-12 names hard-deleting the last owning person outright, and applying
the guard unconditionally keeps every scope backed by at least one SCOPE_OWNER row. Removing a
soft-deleted sole owner therefore takes two steps: add another owner (UC-21) or hard-delete the scope
(UC-05) first.
On the cascade. Hard deletion is the mirror image of UC-09: where a logical deletion touches
nothing but the flag, this removes the person’s tokens, the applications they own (NFR-11), and their
join rows (System Requirements Document §8). Google Users are unaffected — they belong to a scope
rather than to a person, and cannot own an application.
UC-11: Login
| Field | Value |
|---|
| ID | UC-11 |
| Name | Login (Authenticate) |
| Actors | Anonymous, User, Scope Admin, System Admin |
| Description | Authenticate a person with email and password to obtain a token. A User must also provide their scope ID, since their email is only unique within that scope; a ScopeAdmin or SystemAdmin logs in with email and password only |
| Preconditions | None |
| Postconditions | An authentication token is issued, and the response reports whether the person’s email is verified |
Main Flow (User):
sequenceDiagram
actor U as User
participant API as Heimdall API
participant DB as Database
U->>API: POST /api/auth/login { email, password, scopeId }
API->>DB: Find SCOPE_USER person by email and scopeId
DB-->>API: Person found { PasswordHash, Salt, RoleId }
API->>API: Hash provided password using stored Salt
API->>API: Compare computed hash to stored PasswordHash
API->>API: Check IsDeleted == false
API->>API: Check scope.IsDeleted == false
API->>API: Generate JWT token { personId, role, scopeId }
API-->>U: 200 OK { token, expiresAt, emailVerified }Main Flow (Scope Admin / System Admin):
sequenceDiagram
actor A as Scope Admin / System Admin
participant API as Heimdall API
participant DB as Database
A->>API: POST /api/auth/login { email, password }
API->>DB: Find person by email (system-wide)
DB-->>API: Person found { PasswordHash, Salt, RoleId }
API->>API: Hash provided password using stored Salt
API->>API: Compare computed hash to stored PasswordHash
API->>API: Check IsDeleted == false
API->>DB: If ScopeAdmin, load owned ScopeIds and check at least one is not logically deleted
API->>API: Generate JWT token { personId, role, ownedScopeIds? }
API-->>A: 200 OK { token, expiresAt, emailVerified }- Caller sends email and password to
POST /api/auth/login (a User also sends their scope ID). The endpoint is open to anonymous callers — it is where every other endpoint’s token comes from. - The system locates the person: a
User is looked up by email within the given scope; a ScopeAdmin/SystemAdmin is looked up by email system-wide. Emails are compared case-insensitively, and the lookup does not filter logically deleted persons — AF-11c rejects them at step 4. - The system hashes the provided password using the person’s stored
Salt and compares it to the stored PasswordHash. - The system confirms the person is not logically deleted.
- For a
User, the system confirms their scope is not logically deleted. For a ScopeAdmin, the system confirms at least one owned scope is not logically deleted.
If processing of the person is restricted (NFR-24), steps 4 and 5 do not apply and the login ends in AF-11i instead. - If the person does not have active two-factor authentication (
TWO_FACTOR_AUTH.IsActive), the system generates and returns the full authentication token containing the person’s PublicId, display name and role, plus: the scope’s PublicId for a User; the list of owned scopes’ PublicIds for a ScopeAdmin — only those not logically deleted; no scope claim for a SystemAdmin. The response also reports when the token expires. Every token issued to a person — this one, AF-11g’s challenge token and AF-11i’s subject-rights-only token — also carries the person’s current security stamp, and is refused on every later request once the stamp has been replaced by a credential change (FR-AU-11). If the person does have active two-factor authentication, go to AF-11g instead.
Alternative Flows:
| ID | Condition | Outcome |
|---|
| AF-11a | Person not found | Return 401 Unauthorized |
| AF-11b | Password mismatch | Return 401 Unauthorized |
| AF-11c | Person is logically deleted | Return 401 Unauthorized |
| AF-11d | User’s scope is logically deleted | Return 401 Unauthorized |
| AF-11e | Scope Admin’s owned scopes are all logically deleted | Return 401 Unauthorized |
| AF-11f | Email missing or malformed, or password missing | Return 400 Bad Request |
| AF-11g | Person has active two-factor authentication (FR-2F-07) | Return 200 OK with a short-lived challenge token and the available second-factor methods, instead of the full authentication token; if the Email method is enabled, also send a fresh email code (FR-2F-08). See UC-38 for how the login is completed |
| AF-11h | The person’s account is locked out after ten consecutive failed password attempts (FR-AU-09); the tenth attempt locks the account when it is counted, and a correct password on that tenth attempt still signs in and clears the lock | Return 401 Unauthorized, the same answer and the same message as AF-11a through AF-11e |
| AF-11i | The password checks out and processing of the person is restricted (NFR-24, UC-44), whether or not they are logically deleted | Return 200 OK with a subject-rights-only token and processingRestricted: true, instead of the full token. The token names the person and their role and claims no scope; it is accepted only by UC-41, UC-42, UC-44 and UC-45, and every other endpoint answers it 403 Forbidden. A person with active two-factor authentication gets AF-11g’s challenge first, and UC-38 ends the same way |
On the lockout. AF-11h joins the collapse deliberately. Answering it distinctly would tell an
anonymous caller that an address is registered and that they have made it stop responding,
which is the pair of facts the uniform rejection exists to withhold. FR-AU-10 keeps the timing
uniform too, so the lockout is not detectable by how quickly the refusal arrives. It expires on
its own after fifteen minutes — no operator action clears it — because reaching the threshold
needs nothing but wrong guesses, and a lock that persisted would hand any caller a denial of
service against any address they know.
On the single rejection. AF-11a through AF-11e are five different conditions with one
indistinguishable response: the same 401 and the same message. Telling them apart would turn the
login endpoint into a directory — confirming which emails are registered, which accounts have been
deleted, and which scopes still exist — to a caller who has proved nothing. UC-12 takes the same
position for password recovery.
On AF-11i. A restriction used to be a refusal here, folded into the uniform 401. That left
a restricted subject only the token they held when they asked for it — an hour by default — to
lift their own restriction (UC-45), export (UC-41) or erase (UC-42); after that only a System Admin
could act for them. A token confined to those four endpoints gives the subject their rights back
without letting the restricted record be processed for anything else. It comes after the password
and lockout checks, so only someone holding the credential learns that the account is restricted,
and it skips the deletion and scope checks because a suspended subject keeps their rights until
the record is anonymised (NFR-20), and a token that claims no scope has no scope to check. It stays
confined once the restriction is lifted: the subject signs in again for a full token.
On AF-11f. NFR-10 requires every input to be validated, so a request missing an email or
password is refused as malformed before any lookup happens. The validation deliberately stops
short of a minimum password length, unlike person creation: at login a short password is a wrong
password (401), and answering 400 to it would tell the caller their guess was too short to be
anyone’s password.
UC-12: Password Recovery
| Field | Value |
|---|
| ID | UC-12 |
| Name | Request Password Recovery |
| Actors | Anonymous |
| Description | Request a password reset email |
| Preconditions | None |
| Postconditions | A password reset token is generated and emailed to the person |
Main Flow:
sequenceDiagram
actor U as Anonymous
participant API as Heimdall API
participant DB as Database
participant ES as Email Service
U->>API: POST /api/auth/password-recovery { email, scopeId? }
API->>DB: Find person by email (and scopeId, if provided, for a User)
DB-->>API: Person found
API->>API: Generate time-limited reset token
API->>DB: Store reset token
API->>ES: Send recovery email with reset link
ES-->>API: Email queued
API-->>U: 200 OK "If the email exists, a reset link has been sent"- Caller provides their email (and scope ID, if they are a
User, since email is only unique within a scope for Users). - The system locates the person.
- The system generates a time-limited password reset token.
- The system stores the token and sends a recovery email.
- The system returns a generic success message (does not reveal whether the email exists).
Steps 2 to 4 run after step 5, outside the request: the API validates the request, queues it, and
answers; a background worker then does the lookup, the token and the email (FR-PR-02). The answer
is therefore identical in time as well as in content for a registered and an unknown address —
while the work ran on the request, a registered address answered a Mailgun round trip later, which
gave away exactly what AF-12a withholds. The queue lives in memory, so a restart drops pending
requests; a person who receives nothing asks again, as for any email that does not arrive.
Alternative Flows:
| ID | Condition | Outcome |
|---|
| AF-12a | Email not found, or the person is logically deleted, restricted (NFR-24), or cannot sign in because their scope or scopes are deleted | Return 200 OK with the same generic message, in the same time (prevents enumeration). No token is issued and nothing is sent |
| AF-12b | A reset token was issued for the person within the cooldown (HEIMDALL_ADMIN_EMAIL_COOLDOWN_IN_SECONDS, five minutes by default), by an earlier request or by UC-52, used or not (FR-PR-07) | Return 200 OK with the same generic message. Nothing is issued or sent; the link already mailed keeps working |
UC-13: Reset Password
| Field | Value |
|---|
| ID | UC-13 |
| Name | Reset Password |
| Actors | Anonymous |
| Description | Set a new password using a valid reset token |
| Preconditions | A valid, non-expired reset token exists |
| Postconditions | Person’s password is updated; the token is marked as used; every token the person held is revoked (FR-AU-11) |
Main Flow:
- Caller provides the reset token and a new password.
- The system validates the token (exists, its person may still be acted on, not expired, not used).
The person is checked by UC-12’s rule as they stand now (FR-PR-08, AF-13e).
- The system generates a new random
Salt and hashes the new password using it. - The system marks the token as used, then writes the new
PasswordHash and Salt together with a
fresh security stamp, which revokes every token issued to the person before the reset (FR-AU-11) —
full, challenge and subject-rights-only alike. It is one conditional write that repeats step 2’s
checks on the person and requires the address they were read with, so no sign-in can read the new
password beside the old stamp, and a write refused because the person changed meanwhile leaves the
person, stamp included, as it was (answered as AF-13e). - The system returns success. The person signs in again with the new password; a session opened with
the old one, by them or by anybody else, ends at its next request.
Alternative Flows:
| ID | Condition | Outcome |
|---|
| AF-13a | Token expired | Return 400 Bad Request — “Token expired” |
| AF-13b | Token already used — including by a request carrying the same token at the same time (NFR-27): exactly one of them sets a password | Return 400 Bad Request — “Token already used” |
| AF-13c | Token not found | Return 400 Bad Request — “Invalid token” |
| AF-13d | New password fails validation | Return 400 Bad Request with validation errors |
| AF-13e | The token’s person may no longer be acted on: since the link was mailed they were put under a restriction on processing (NFR-24), logically deleted or anonymised, or can no longer sign in (FR-AU-06, FR-AU-07); checked before the token’s expiry and use, and again by the write in step 4 (FR-PR-08) | As AF-13c: 400 Bad Request — “Invalid token”. Nothing is written, the token included, so a restricted record is preserved |
UC-14: Email Verification
| Field | Value |
|---|
| ID | UC-14 |
| Name | Verify Email |
| Actors | Anonymous (via email link) |
| Description | Confirm a person’s email address |
| Preconditions | A valid, non-expired verification token exists |
| Postconditions | Person’s EmailVerified is set to true; the token is marked as used |
Main Flow:
sequenceDiagram
actor U as Person
participant API as Heimdall API
participant DB as Database
U->>API: POST /api/auth/verify-email { token }
API->>DB: Find verification token
DB-->>API: Token found
API->>API: Validate token (not expired, not used)
API->>DB: Set person.EmailVerified = true
API->>DB: Mark token as used
DB-->>API: Updated
API-->>U: 200 OK "Email verified"- Person clicks the verification link which calls the API with the token.
- The system locates and validates the token, and checks the person by UC-12’s rule as they stand
now (FR-EV-08, AF-14d).
- The system sets
EmailVerified = true on the person record, in one conditional write that repeats
step 2’s checks and requires the address the person was read with. - The system marks the token as used.
- The system returns success.
Alternative Flows:
| ID | Condition | Outcome |
|---|
| AF-14a | Token expired | Return 400 Bad Request — “Token expired” |
| AF-14b | Token already used — including by a request carrying the same token at the same time (NFR-27) | Return 400 Bad Request — “Token already used” |
| AF-14c | Token not found | Return 400 Bad Request — “Invalid token” |
| AF-14d | The token’s person may no longer be acted on, as UC-13’s AF-13e (FR-EV-08) | As AF-14c: 400 Bad Request — “Invalid token”. Nothing is written, the token included |
A token may name a pending registration. A token that matches no verification token is looked up among pending registrations (UC-47). If one matches, the request must also carry password, the one chosen at sign-up (FR-SR-09), and with it the confirmation creates the account: the system checks the password, claims the row, re-checks that the scope is still open and the address still free, creates the User with its address verified, and removes the address’s other pending registrations. An expired one answers AF-14a. Without a password the answer is 422 Unprocessable Entity, and with a wrong one 401 Unauthorized; neither consumes the registration, so the registrant may try again, but each guess is charged before it is compared, and after 10 wrong passwords the registration is discarded and the token answers AF-14c. A link followed again once the account exists finds no registration and answers AF-14c — “Invalid token” — not AF-14b, which here means only a concurrent request that claimed the registration first. One the system refuses to act on, because the scope closed or the address was taken, also answers AF-14c. password is ignored for an ordinary verification token.
UC-15: Resend Verification Email
| Field | Value |
|---|
| ID | UC-15 |
| Name | Resend Verification Email |
| Actors | User, Scope Admin, System Admin |
| Description | Resend the email verification message |
| Preconditions | Actor is authenticated; email is not already verified |
| Postconditions | A new verification token is generated and emailed |
Main Flow:
- Authenticated person requests a new verification email.
- The system checks that the email is not already verified, and reserves the send against the person’s account-email budget (FR-PE-16).
- The system invalidates any existing verification tokens.
- The system generates a new time-limited verification token.
- The system sends the verification email.
- The system returns success.
Alternative Flows:
| ID | Condition | Outcome |
|---|
| AF-15a | Email already verified | Return 400 Bad Request — “Email already verified” |
| AF-15b | The person’s own requests had an account email sent within the cooldown (HEIMDALL_ADMIN_EMAIL_COOLDOWN_IN_SECONDS, five minutes by default) — a resend, a setup code or an email-change notice (FR-PE-16) | Return 429 Too Many Requests — “An email of this kind was sent to you recently. Try again in a few minutes.” Nothing is retired or sent, so the link already mailed keeps working |
| AF-15c | The person’s own requests have had five account emails sent in the current day-long window (FR-PE-16) | Return 429 Too Many Requests — “You have reached the limit for account emails. Try again later.” Nothing is retired or sent |
On the cooldown and the rate limit. The endpoint mails the account’s own address on every
successful call, and a bearer token is all it asks for, so without a bound whoever held one could
mail that address once per request — and the address may be one the account holder set without
proving it. The resend shares one budget with the person’s other self-service account mail (FR-PE-16),
reserved by one write before anything is sent, so even a burst of simultaneous requests sends one.
A link an administrator sent (UC-06, UC-51) is not charged to it, so the person may still ask once
straight after. The endpoint is also under the credential endpoints’ rate limit, whose own 429 is
“Too many requests.” with a Retry-After header.
UC-16: Create Application
| Field | Value |
|---|
| ID | UC-16 |
| Name | Create Application |
| Actors | System Admin, Scope Admin |
| Description | Register a new application (a non-person identity representing another system) within a scope |
| Preconditions | Actor is authenticated; target scope exists and is not logically deleted; the owner is an existing, non-logically-deleted ScopeAdmin or SystemAdmin who owns the scope (via SCOPE_OWNER) |
| Postconditions | A new application record exists, associated with the scope and owned by the specified owner |
Main Flow:
sequenceDiagram
actor Caller
participant API as Heimdall API
participant DB as Database
Caller->>API: POST /api/scopes/{scopeId}/applications { name, ownerId }
API->>API: Validate input
API->>DB: Verify scope exists and is not logically deleted
DB-->>API: Scope found
API->>DB: Verify owner is a ScopeAdmin or SystemAdmin with a SCOPE_OWNER row for the scope, and not logically deleted
DB-->>API: Owner found
API->>DB: Insert application record
DB-->>API: Application created
API-->>Caller: 201 Created { application }- Caller sends a request with application data (name, ownerId) targeting a scope.
- The system validates all fields.
- The system verifies the target scope exists and is not logically deleted.
- The system verifies the owner is an existing, non-logically-deleted
ScopeAdmin or SystemAdmin who owns the scope. - The system creates the application record with
IsDeleted = false. - The system returns the created application.
Alternative Flows:
| ID | Condition | Outcome |
|---|
| AF-16a | Scope not found or logically deleted | Return 404 Not Found |
| AF-16b | Owner not found, logically deleted, neither a ScopeAdmin nor a SystemAdmin, or not an owner of the scope (no SCOPE_OWNER row) | Return 400 Bad Request |
| AF-16c | Scope Admin attempts to set an owner other than themself | Return 403 Forbidden |
| AF-16d | Invalid input | Return 400 Bad Request |
| AF-16e | Scope Admin does not own the target scope | Return 403 Forbidden |
UC-17: View Application
| Field | Value |
|---|
| ID | UC-17 |
| Name | View Application |
| Actors | System Admin, Scope Admin |
| Description | Retrieve an application’s details or list applications within a scope |
| Preconditions | Actor is authenticated |
| Postconditions | Application information is returned |
Main Flow:
- Actor requests an application by ID, or a list of the applications within a scope.
- The system checks authorization:
- System Admin: can view every application, in any scope.
- Scope Admin: can view only the applications they own. To list a scope’s applications they must also own that scope.
- Logically deleted applications are excluded unless explicitly requested.
- The system returns application data.
Alternative Flows:
| ID | Condition | Outcome |
|---|
| AF-17a | Application not found, or — when listing — the scope is not found or is logically deleted | Return 404 Not Found |
| AF-17b | Actor not authorized: a Scope Admin who does not own the requested application, a Scope Admin who does not own the scope being listed, or a User | Return 403 Forbidden |
UC-18: Update Application
| Field | Value |
|---|
| ID | UC-18 |
| Name | Update Application |
| Actors | System Admin, Scope Admin |
| Description | Modify an application’s name or owner |
| Preconditions | Actor is authenticated; application exists and is not logically deleted |
| Postconditions | Application record is updated |
Main Flow:
- Actor sends an update request with the application ID and new values.
- The system validates the input.
- The system checks authorization:
- System Admin: can update any application.
- Scope Admin: can update only the applications they own.
- If the owner changes, the system verifies the new owner is an existing, non-logically-deleted
ScopeAdmin or SystemAdmin who owns the application’s scope. - The system applies the updates and sets
UpdatedAt. - The system returns the updated application.
Alternative Flows:
| ID | Condition | Outcome |
|---|
| AF-18a | Application not found or logically deleted | Return 404 Not Found |
| AF-18b | New owner not found, logically deleted, neither a ScopeAdmin nor a SystemAdmin, or not an owner of the application’s scope | Return 400 Bad Request |
| AF-18c | Actor not authorized | Return 403 Forbidden |
UC-19: Logical Delete Application
| Field | Value |
|---|
| ID | UC-19 |
| Name | Logical Delete Application |
| Actors | System Admin, Scope Admin |
| Description | Soft-delete an application by setting IsDeleted = true |
| Preconditions | Actor is authenticated; application exists |
| Postconditions | Application’s IsDeleted is true |
Main Flow:
- Actor sends a delete request for an application.
- The system checks authorization (System Admin, or the
ScopeAdmin who owns the application). - The system sets
IsDeleted = true on the application record. - The system returns success.
Alternative Flows:
| ID | Condition | Outcome |
|---|
| AF-19a | Application not found | Return 404 Not Found |
| AF-19b | Already logically deleted | Return 200 OK (idempotent) |
| AF-19c | Actor not authorized | Return 403 Forbidden |
UC-20: Hard Delete Application
| Field | Value |
|---|
| ID | UC-20 |
| Name | Hard Delete Application |
| Actors | System Admin |
| Description | Permanently remove an application record from the database |
| Preconditions | Actor is authenticated with SystemAdmin role; application exists |
| Postconditions | Application record is permanently removed, and with it, by cascade, every credential issued to it (UC-53), live, revoked or expired |
Main Flow:
- System Admin sends a hard delete request.
- The system permanently deletes the application record.
- The system returns success.
Alternative Flows:
| ID | Condition | Outcome |
|---|
| AF-20a | Application not found | Return 404 Not Found |
UC-21: Add Scope Owner
| Field | Value |
|---|
| ID | UC-21 |
| Name | Add Scope Owner |
| Actors | System Admin, Scope Admin (existing owner) |
| Description | Add an existing ScopeAdmin or SystemAdmin person as an additional owner of a scope. To make a brand-new person a co-owner, see UC-06 (path c); to make an existing User of the scope a co-owner, see UC-23 (Promote User to Scope Owner) |
| Preconditions | Actor is authenticated with SystemAdmin role, or is an existing owner of the target scope; the target person exists, is not logically deleted, and has the ScopeAdmin or SystemAdmin role |
| Postconditions | A new SCOPE_OWNER row links the scope to the person |
Main Flow:
sequenceDiagram
actor Admin as System Admin / Existing Owner
participant API as Heimdall API
participant DB as Database
Admin->>API: POST /api/scopes/{id}/owners/{personId}
API->>DB: Verify scope exists and is not logically deleted
DB-->>API: Scope found
API->>DB: Verify person exists, is not logically deleted, and has the ScopeAdmin or SystemAdmin role
DB-->>API: Person found
API->>DB: Insert SCOPE_OWNER row { ScopeId, PersonId }
DB-->>API: Owner added
API-->>Admin: 201 Created- Actor sends a request to add a person as an owner of a scope.
- The system verifies the scope exists and is not logically deleted.
- The system verifies the person exists, is not logically deleted, and has the
ScopeAdmin or SystemAdmin role. - The system inserts a
SCOPE_OWNER row linking the scope to the person (no-op if it already exists). - The system returns success.
Alternative Flows:
| ID | Condition | Outcome |
|---|
| AF-21a | Scope not found or logically deleted | Return 404 Not Found |
| AF-21b | Person not found, logically deleted, or neither a ScopeAdmin nor a SystemAdmin | Return 400 Bad Request |
| AF-21c | Actor not authorized (not System Admin nor an existing owner) | Return 403 Forbidden |
| AF-21d | Person is already an owner of the scope | Return 200 OK (idempotent) |
On the person added. The person is not asked to accept; the ownership takes effect at once
(Threat Model TH-41).
UC-22: Remove Scope Owner
| Field | Value |
|---|
| ID | UC-22 |
| Name | Remove Scope Owner |
| Actors | System Admin, Scope Admin (existing owner) |
| Description | Remove a Scope Admin’s ownership of a scope |
| Preconditions | Actor is authenticated with SystemAdmin role, or is an existing owner of the target scope; the scope has more than one owner |
| Postconditions | The SCOPE_OWNER row linking the scope to the person is removed |
Main Flow:
- Actor sends a request to remove a person’s ownership of a scope.
- The system verifies the scope exists and that the person is currently an owner.
- The system verifies the scope has more than one owner.
- The system removes the
SCOPE_OWNER row. - The system returns success.
Alternative Flows:
| ID | Condition | Outcome |
|---|
| AF-22a | Scope not found, or person is not an owner of it | Return 404 Not Found |
| AF-22b | Scope has only one owner (this one) | Return 409 Conflict — “Cannot remove the last owner of a scope” |
| AF-22c | Actor not authorized (not System Admin nor an existing owner) | Return 403 Forbidden |
| Field | Value |
|---|
| ID | UC-23 |
| Name | Promote User to Scope Owner |
| Actors | System Admin, Scope Admin (existing owner) |
| Description | Promote an existing User of a scope to ScopeAdmin, making them a co-owner of that scope |
| Preconditions | Actor is authenticated with SystemAdmin role, or is an existing owner of the target scope; the target person exists, is not logically deleted, and is currently a User belonging to that scope (via SCOPE_USER) |
| Postconditions | The person’s RoleId is changed to ScopeAdmin; their SCOPE_USER row is removed; a new SCOPE_OWNER row links them to the scope |
Main Flow:
sequenceDiagram
actor Admin as System Admin / Existing Owner
participant API as Heimdall API
participant DB as Database
Admin->>API: POST /api/scopes/{id}/users/{personId}/promote
API->>DB: Verify scope exists and is not logically deleted
DB-->>API: Scope found
API->>DB: Verify person is a non-logically-deleted SCOPE_USER of the scope
DB-->>API: Person found
API->>DB: Set person.RoleId = ScopeAdmin
API->>DB: Remove SCOPE_USER row { ScopeId, PersonId }
API->>DB: Insert SCOPE_OWNER row { ScopeId, PersonId }
DB-->>API: Person promoted
API-->>Admin: 200 OK { person }- Actor sends a request to promote a person to owner of a scope.
- The system verifies the scope exists and is not logically deleted.
- The system verifies the person exists, is not logically deleted, and is currently a
User belonging to that scope. - The system changes the person’s
RoleId to ScopeAdmin. - The system removes the person’s
SCOPE_USER row and inserts a SCOPE_OWNER row linking them to the scope. - The system returns the updated person.
Alternative Flows:
| ID | Condition | Outcome |
|---|
| AF-23a | Scope not found or logically deleted | Return 404 Not Found |
| AF-23b | Person not found, logically deleted, or not a User of that scope | Return 400 Bad Request |
| AF-23c | Actor not authorized (not System Admin nor an existing owner) | Return 403 Forbidden |
| AF-23d | Person is already a ScopeAdmin | Return 409 Conflict — “Person already holds the ScopeAdmin role” |
| AF-23e | Person’s email collides with an existing ScopeAdmin/SystemAdmin elsewhere in the system (FR-PE-09) — the promoted person moves from the scope-local User email namespace to the global admin namespace | Return 409 Conflict |
On AF-23e. A User’s email is unique only within their scope; a ScopeAdmin’s is unique
system-wide (FR-PE-09). Promotion moves the person between those two namespaces, so the same email
that was fine as a User can collide with an existing admin the moment it becomes global. This is
checked the same way UC-08’s role change re-checks uniqueness against the role the person is about
to hold.
UC-24: Enable/Disable Google Sign-In
| Field | Value |
|---|
| ID | UC-24 |
| Name | Enable/Disable Google Sign-In |
| Actors | System Admin, Scope Admin (existing owner) |
| Description | Turn Google Sign-In on or off for a scope, controlling whether Users of that scope may sign up/sign in with a Google account |
| Preconditions | Actor is authenticated with SystemAdmin role, or is an existing owner of the target scope; the scope exists |
| Postconditions | The scope’s GoogleSignInEnabled flag is updated |
Main Flow:
sequenceDiagram
actor Admin as System Admin / Existing Owner
participant API as Heimdall API
participant DB as Database
Admin->>API: PUT /api/scopes/{id}/google-signin { enabled }
API->>DB: Verify scope exists
DB-->>API: Scope found
API->>DB: Set scope.GoogleSignInEnabled = enabled
DB-->>API: Updated
API-->>Admin: 200 OK { scope }- Actor sends a request to enable or disable Google Sign-In for a scope.
- The system validates the input fields.
- The system verifies the scope exists.
- The system checks authorization: System Admin, or an existing owner of the scope.
- The system sets
GoogleSignInEnabled to the requested value. - The system returns the updated scope.
Alternative Flows:
| ID | Condition | Outcome |
|---|
| AF-24a | Scope not found | Return 404 Not Found |
| AF-24b | Actor not authorized (not System Admin nor an existing owner) | Return 403 Forbidden |
| AF-24c | enabled not supplied | Return 400 Bad Request |
On AF-24c. The request body carries a single boolean, so an omitted enabled cannot be
distinguished from an explicit false once bound — a malformed request would silently perform the
disable half of this use case. The value is therefore required rather than defaulted, and is
checked before the scope is looked up (step 2, as in UC-01). NFR-10 already asks for this; the flow
is named here so the refusal is specified rather than incidental.
UC-25: Sign Up / Sign In via Google
| Field | Value |
|---|
| ID | UC-25 |
| Name | Sign Up / Sign In via Google |
| Actors | Anonymous, Google Identity Platform |
| Description | Authenticate with a Google account against a specific scope. If no Google User exists yet for that Google account in that scope, one is created (sign-up); otherwise the existing Google User is authenticated (sign-in). Only usable for scopes with GoogleSignInEnabled = true, and only ever produces a User-equivalent identity |
| Preconditions | The target scope exists, is not logically deleted, and has GoogleSignInEnabled = true; the caller holds a valid Google ID token |
| Postconditions | A Google User record exists for this Google account within the scope (created if this was the first sign-in), with its stored EmailVerified refreshed from the token’s claims when they differ, or left unchanged when the token carries no email_verified claim (FR-GO-19); an authentication token is issued, and the response reports whether the account’s email is verified |
Main Flow:
sequenceDiagram
actor Caller as Anonymous
participant GIP as Google Identity Platform
participant API as Heimdall API
participant DB as Database
Caller->>GIP: Authenticate with Google
GIP-->>Caller: ID token { sub, email, email_verified, name, picture }
Caller->>API: POST /api/auth/google { scopeId, idToken, nonce? }
API->>GIP: Verify ID token signature, issuer, audience, expiration
GIP-->>API: Token valid, claims
API->>DB: Verify scope exists, is not logically deleted, and GoogleSignInEnabled = true
DB-->>API: Scope found
API->>DB: Find GOOGLE_USER by GoogleId (sub) and scopeId
alt Google User does not exist
API->>API: Require email_verified == true
API->>DB: Check email uniqueness in scope (jointly with User persons)
DB-->>API: Email is unique
API->>DB: Insert GOOGLE_USER row { GoogleId, Name, Email, EmailVerified, ProfilePictureUrl, ScopeId }
DB-->>API: Google User created
else Google User exists
API->>DB: Check IsDeleted == false
DB-->>API: Confirmed
API->>DB: Refresh EmailVerified from the token claims if it differs
end
API->>API: Generate JWT token { googleUserId, role = User, scopeId }
API-->>Caller: 200 OK { token, expiresAt, emailVerified }- The caller authenticates with Google (outside this API) and obtains a signed ID token.
- The caller sends the ID token and target scope ID to the API, and optionally the
nonce it sent Google when asking for the token. - The system verifies the ID token’s signature, issuer, audience, and expiration. When the request carries a
nonce, the token’s nonce claim must equal it (FR-GO-20, AF-25g). - The system verifies the target scope exists, is not logically deleted, and has
GoogleSignInEnabled = true. - The system looks up a Google User by
GoogleId (the token’s sub claim) within the scope. - If none exists: the system requires the token’s
email_verified claim to be true (AF-25f), verifies the token’s email is unique within the scope (jointly with User persons’ emails), then creates a new Google User populated from the token’s claims (Name, Email, EmailVerified, ProfilePictureUrl). - If one exists: the system confirms it is not under a restriction on processing (AF-25e) and not logically deleted, and refreshes the stored
EmailVerified from the token’s claims when the two differ (FR-GO-19). A token carrying no email_verified claim asserts nothing about the address, so the stored value is left as it stands. - The system issues an authentication token containing the Google User’s ID, display name,
role = User, and the scope ID. When Google supplied no name, the verified email is used as the display name.
Alternative Flows:
| ID | Condition | Outcome |
|---|
| AF-25a | ID token invalid, expired, or fails verification | Return 401 Unauthorized |
| AF-25b | Scope not found, logically deleted, or GoogleSignInEnabled = false | Return 403 Forbidden |
| AF-25c | Email from the token already used by another Google User or User person in the scope | Return 409 Conflict |
| AF-25d | Existing Google User is logically deleted, and not under a restriction on processing | Return 401 Unauthorized |
| AF-25e | Existing Google User is under a restriction on processing (NFR-24), whether or not logically deleted | Return 200 OK with a subject-rights-only token and processingRestricted: true, as UC-11’s AF-11i. Nothing is refreshed from the ID token, since that would be processing the record |
| AF-25f | No Google User exists yet and the token’s email_verified claim is not true — false or absent (FR-GO-07); checked before AF-25c | Return 403 Forbidden — “Google sign-up requires a verified email address.” Nothing is created |
| AF-25g | The request carries a nonce and the token’s nonce claim is absent or differs (FR-GO-20); checked with AF-25a, before the scope is read | As AF-25a: 401 Unauthorized — “Google authentication failed.” |
UC-26: Sign Out via Google
| Field | Value |
|---|
| ID | UC-26 |
| Name | Sign Out via Google |
| Actors | Google User |
| Description | End a Google User’s session in the client |
| Preconditions | The caller holds a valid authentication token issued via UC-25 |
| Postconditions | The client has been told to discard the token. Heimdall keeps no Google session and records no issued token, so nothing server-side ends: a copy of the token kept elsewhere stays valid until it expires (FR-GO-18) |
Main Flow:
- The Google User sends a sign-out request with their current token.
- The system confirms the token still names a live Google User and instructs the client to discard it. It revokes nothing: Heimdall’s tokens are stateless (NFR-03), a Google User’s token carries no security stamp (FR-AU-11), and Heimdall holds no session with Google to end.
- The system returns success.
Alternative Flows:
| ID | Condition | Outcome |
|---|
| AF-26a | Token missing or already invalid | Return 401 Unauthorized |
UC-27: View Google User
| Field | Value |
|---|
| ID | UC-27 |
| Name | View Google User |
| Actors | System Admin, Scope Admin, Google User |
| Description | Retrieve a Google User’s details or list Google Users within a scope |
| Preconditions | Actor is authenticated |
| Postconditions | Google User information is returned |
Main Flow:
- Actor requests a Google User by ID or a list of Google Users within a scope.
- The system checks authorization:
- System Admin: can view any Google User.
- Scope Admin: can view the Google Users of the scopes they own.
- Google User: can view only their own record.
- Logically deleted Google Users are excluded unless explicitly requested.
- The system returns Google User data.
Alternative Flows:
| ID | Condition | Outcome |
|---|
| AF-27a | Google User not found | Return 404 Not Found |
| AF-27b | Actor not authorized | Return 403 Forbidden |
UC-28: Logical Delete Google User
| Field | Value |
|---|
| ID | UC-28 |
| Name | Logical Delete Google User |
| Actors | System Admin, Scope Admin |
| Description | Soft-delete a Google User by setting IsDeleted = true |
| Preconditions | Actor is authenticated; Google User exists |
| Postconditions | Google User’s IsDeleted is true |
Main Flow:
- Actor sends a delete request for a Google User.
- The system checks authorization: System Admin, or an owner of the Google User’s scope.
- The system sets
IsDeleted = true on the Google User record. - The system returns success.
Alternative Flows:
| ID | Condition | Outcome |
|---|
| AF-28a | Google User not found | Return 404 Not Found |
| AF-28b | Already logically deleted | Return 200 OK (idempotent) |
| AF-28c | Actor not authorized | Return 403 Forbidden |
UC-29: Hard Delete Google User
| Field | Value |
|---|
| ID | UC-29 |
| Name | Hard Delete Google User |
| Actors | System Admin |
| Description | Permanently remove a Google User record from the database |
| Preconditions | Actor is authenticated with SystemAdmin role; Google User exists |
| Postconditions | Google User record is permanently removed |
Main Flow:
- System Admin sends a hard delete request.
- The system records a tombstone for the Google User in
ERASED_SUBJECT — their PublicId, that it was a Google User, and when — before the record is deleted, so the audit entries naming them, this hard delete’s own among them, can still be cleared once the row is gone (NFR-21, #195). A retry records it again without error. - The system permanently deletes the Google User record.
- The system returns success.
Alternative Flows:
| ID | Condition | Outcome |
|---|
| AF-29a | Google User not found | Return 404 Not Found |
UC-31: Create Scope Permission
| Field | Value |
|---|
| ID | UC-31 |
| Name | Create Scope Permission |
| Actors | System Admin, Scope Admin |
| Description | Register a new scope-specific permission within a scope. The permission carries a Name, an optional Description, and an IncludeAsJwtClaim flag controlling whether the Name is folded into the JWT issued to identities acting within the scope (FR-AU-08). A scope permission has no separate owner of its own — owning the scope is the whole of the authorization |
| Preconditions | Actor is authenticated; target scope exists and is not logically deleted; for a Scope Admin, the actor must own the target scope |
| Postconditions | A new scope permission record exists, associated with the scope, with IsDeleted = false and IncludeAsJwtClaim set as supplied (default false) |
Main Flow:
sequenceDiagram
actor Caller as System Admin / Scope Admin (owner)
participant API as Heimdall API
participant DB as Database
Caller->>API: POST /api/scopes/{scopeId}/permissions { name, description?, includeAsJwtClaim? }
API->>API: Validate input
API->>DB: Verify scope exists and is not logically deleted
DB-->>API: Scope found
API->>API: Verify actor may manage the scope (System Admin bypasses, Scope Admin must own it)
API->>DB: Insert scope permission record { Name, Description, IncludeAsJwtClaim, ScopeId, IsDeleted = false }
DB-->>API: Permission created
API-->>Caller: 201 Created { permission }- Caller sends a request with the permission
Name, an optional Description, and an optional IncludeAsJwtClaim flag, targeting a scope. - The system validates the input shape:
Name is required and at most 200 characters; Description, when supplied, is at most 500 characters. - The system verifies the target scope exists and is not logically deleted.
- The system verifies the actor may manage the scope — a System Admin bypasses the check; a Scope Admin must own it.
- The system creates the scope permission record with
IsDeleted = false and IncludeAsJwtClaim as supplied (defaulting to false when omitted). - The system returns the created permission.
Alternative Flows:
| ID | Condition | Outcome |
|---|
| AF-31a | Scope not found or logically deleted | Return 404 Not Found |
| AF-31d | Invalid input — Name missing or over 200 characters, or Description over 500 characters | Return 400 Bad Request with validation errors |
| AF-31e | Scope Admin does not own the target scope | Return 403 Forbidden |
On the role gate. The endpoint carries [RoleRequirement(SystemAdmin, ScopeAdmin)], so a User is refused with 403 before the handler runs — independently of the data-dependent AF-31e. The same framework-level refusal applies to every scope-permission endpoint in UC-31 through UC-34; only UC-35 restricts further, to SystemAdmin alone. These framework refusals are not listed as separate alternative flows because they are settled by the attribute, not by the use case’s data rules.
UC-32: View Scope Permission
| Field | Value |
|---|
| ID | UC-32 |
| Name | View Scope Permission |
| Actors | System Admin, Scope Admin |
| Description | Retrieve a scope permission’s details or list the permissions within a scope. There are two distinct reads: (a) a single permission by ID, via GET /api/scopes/{scopeId}/permissions/{id}; or (b) the permissions of a scope, via GET /api/scopes/{scopeId}/permissions |
| Preconditions | Actor is authenticated |
| Postconditions | Scope permission information is returned |
Main Flow (read a — permission by ID):
- Actor requests a scope permission by ID within a scope.
- The system loads the permission, qualifying the lookup by the route’s
scopeId, and excluding logically deleted permissions unless includeDeleted is explicitly requested (FR-SP-09). - The system checks authorization: a System Admin sees any permission; a Scope Admin must own the permission’s scope.
- The system returns the permission data.
Main Flow (read b — list a scope’s permissions):
- A System Admin or a Scope Admin requests the permissions of a scope, optionally filtering by
Name (case-insensitive) and paging the result (FR-SP-05). - The system verifies the scope exists and is not logically deleted.
- The system verifies the actor may manage the scope: a System Admin always may; a Scope Admin must own it.
- The system returns the page of the scope’s permissions, excluding logically deleted permissions unless explicitly requested. There is no per-owner narrowing — a scope permission has no owner of its own, so owning the scope is the whole of the rule.
Alternative Flows:
| ID | Condition | Outcome |
|---|
| AF-32a | Permission not found under the addressed scope, or logically deleted and not explicitly requested (read a) | Return 404 Not Found |
| AF-32b | Scope not found or logically deleted (read b — reuses AF-31a) | Return 404 Not Found |
| AF-32e | Actor not authorized — a Scope Admin who does not own the target scope (reads a, b) | Return 403 Forbidden |
UC-33: Update Scope Permission
| Field | Value |
|---|
| ID | UC-33 |
| Name | Update Scope Permission |
| Actors | System Admin, Scope Admin |
| Description | Modify a scope permission’s Name, Description, and IncludeAsJwtClaim flag |
| Preconditions | Actor is authenticated; permission exists under the addressed scope and is not logically deleted |
| Postconditions | Scope permission record is updated |
Main Flow:
- Actor sends an update request to
PUT /api/scopes/{scopeId}/permissions/{id} with the new Name, Description, and IncludeAsJwtClaim values. - The system validates the input shape, reusing UC-31’s rules.
- The system loads the permission inside the addressed scope, excluding logically deleted permissions.
- The system checks authorization: a System Admin may update any permission; a Scope Admin must own the permission’s scope.
- The system applies the updates and stamps
UpdatedAt. - The system returns the updated permission.
Alternative Flows:
| ID | Condition | Outcome |
|---|
| AF-33a | Permission not found under the addressed scope, or logically deleted | Return 404 Not Found |
| AF-33d | Invalid input (reuses AF-31d) | Return 400 Bad Request with validation errors |
| AF-33e | Scope Admin does not own the permission’s scope | Return 403 Forbidden |
UC-34: Logical Delete Scope Permission
| Field | Value |
|---|
| ID | UC-34 |
| Name | Logical Delete Scope Permission |
| Actors | System Admin, Scope Admin |
| Description | Soft-delete a scope permission by setting IsDeleted = true. Nothing cascades — a scope permission owns no dependent row |
| Preconditions | Actor is authenticated; the permission exists under the addressed scope |
| Postconditions | The permission’s IsDeleted is true |
Main Flow:
- Actor sends
DELETE /api/scopes/{scopeId}/permissions/{id}. - The system locates the permission inside the addressed scope in any deletion state — an already-deleted permission must be found so AF-34b can answer idempotently rather than as a
404. - The system checks authorization: a System Admin may delete any permission; a Scope Admin must own the permission’s scope. This runs before the idempotent check, so an already-deleted permission cannot be used to probe for scopes the caller may not act on.
- If the permission is already logically deleted, the system returns success with
alreadyDeleted = true and writes nothing. - Otherwise the system sets
IsDeleted = true, stamps UpdatedAt, and returns success with alreadyDeleted = false.
Alternative Flows:
| ID | Condition | Outcome |
|---|
| AF-34a | Permission not found under the addressed scope | Return 404 Not Found |
| AF-34b | Already logically deleted | Return 200 OK (idempotent), with alreadyDeleted = true |
| AF-34e | Scope Admin does not own the permission’s scope | Return 403 Forbidden |
UC-35: Hard Delete Scope Permission
| Field | Value |
|---|
| ID | UC-35 |
| Name | Hard Delete Scope Permission |
| Actors | System Admin |
| Description | Permanently remove a scope permission record from the database |
| Preconditions | Actor is authenticated with SystemAdmin role; the permission exists under the addressed scope |
| Postconditions | The scope permission record is permanently removed. Nothing cascades — a scope permission is a leaf in the data model |
Main Flow:
- System Admin sends
DELETE /api/scopes/{scopeId}/permissions/{id}/hard. - The system locates the permission inside the addressed scope in any deletion state — a logically deleted permission is exactly what a cleanup pass starts from, so soft deletion must not block a hard one.
- The system permanently deletes the record. No dependent is removed first — no entity carries a foreign key to a scope permission.
- The system returns success.
Alternative Flows:
| ID | Condition | Outcome |
|---|
| AF-35a | Permission not found under the addressed scope (includes a repeated call — the row is already gone, and UC-35 has no idempotent path) | Return 404 Not Found |
On UC-35’s only refusals. Authorization is settled entirely by the endpoint’s [RoleRequirement(SystemAdmin)]: a ScopeAdmin or User is refused with 403 before the handler runs, and an unauthenticated caller is refused with 401. No data-dependent authorization rule is left for the handler to apply, which is why no AF-35e appears here — unlike UC-31 through UC-34, whose scope-ownership rule is data-dependent and lives in the handler.
UC-36: Enable Two-Factor Authentication
| Field | Value |
|---|
| ID | UC-36 |
| Name | Enable Two-Factor Authentication (Initiate Setup and Read Status) |
| Actors | User, Scope Admin, System Admin |
| Description | Begin opting an authenticated person into two-factor authentication, selecting an authenticator-app method, an email method, or both; and read a person’s own current two-factor state. Setup is inactive until confirmed by UC-37. There are two operations: (a) initiate setup, via POST /api/auth/2fa/enable; and (b) read the caller’s own status, via GET /api/auth/2fa |
| Preconditions | Actor is authenticated; for (a), the person has no active two-factor configuration (TWO_FACTOR_AUTH.IsActive is not already true); for (b), none beyond authentication |
| Postconditions | For (a): a TWO_FACTOR_AUTH row exists for the person with IsActive = false; for the App method, a TOTP secret has been generated and returned; for the Email method, a first code has been emailed. For (b): nothing is changed |
Main Flow (a — initiate setup):
sequenceDiagram
actor P as Person
participant API as Heimdall API
participant DB as Database
participant ES as Email Service
P->>API: POST /api/auth/2fa/enable { methods: ["App", "Email"], currentPassword }
API->>API: Verify currentPassword against PasswordHash/Salt
API->>DB: Find or create TWO_FACTOR_AUTH row for the person (IsActive = false)
API->>API: If App selected, generate TOTP secret and encrypt it
API->>DB: Store encrypted secret, AppEnabled = true
API->>ES: If Email selected, send first 6-digit code
ES-->>API: Email queued
API-->>P: 200 OK { otpAuthUri?, emailCodeSent? }- Caller sends
POST /api/auth/2fa/enable with the method(s) they want: App, Email, or both, and their currentPassword (FR-2F-18). - The system confirms the caller has no already-active two-factor configuration, that their account-email budget would admit a send when
Email is selected (AF-36f, AF-36g), and that currentPassword matches their password (AF-36e); with Email, it then reserves the send (FR-2F-19), and creates (or reuses a not-yet-confirmed) TWO_FACTOR_AUTH row for them. - If
App was selected, the system generates a random TOTP secret (RFC 6238), stores it encrypted, and returns an otpauth:// provisioning URI for QR scanning. The plaintext secret is never stored — only the encrypted form. - If
Email was selected, the system generates a random 6-digit code, stores it (hashed, 10-minute expiry), and sends it through the email service. - The system returns success, confirming which method(s) are pending confirmation. Nothing is active yet — UC-37 finishes the job.
Main Flow (b — read the caller’s own status):
- An authenticated person requests their own two-factor state through
GET /api/auth/2fa (FR-2F-15). The person read is always the caller, taken from their token — a configuration is never addressed by an identifier in a path. - The system loads the caller’s
TWO_FACTOR_AUTH row, if one exists. - The system returns whether two-factor authentication is active, which methods are configured, and how many issued recovery codes remain unused. No secret is returned: never the TOTP secret, never a recovery code.
- A caller with no
TWO_FACTOR_AUTH row is answered successfully, with every flag false and a zero count. Never having enabled two-factor authentication is an ordinary state, not a fault, and a setup initiated but not yet confirmed is reported as inactive with its selected methods set.
Alternative Flows:
| ID | Condition | Outcome |
|---|
| AF-36a | Two-factor authentication is already active for the person | Return 409 Conflict |
| AF-36b | Caller is not a person eligible for two-factor authentication — a Google User, or a token naming no live person (either operation) | Return 403 Forbidden — Google Users are not eligible (FR-2F-01) |
| AF-36c | Neither App nor Email selected | Return 400 Bad Request |
| AF-36e | currentPassword missing or wrong (FR-2F-18), or the re-authentication budget is paused (FR-AU-13) | Return 401 Unauthorized — “The password or code is incorrect.” (UC-39’s AF-39b). Nothing is created or sent |
| AF-36d | Re-initiating setup while a prior, unconfirmed setup exists | Overwrite the pending configuration with the new selection (regenerates the TOTP secret and/or resends the email code); return 200 OK exactly as the main flow |
| AF-36f | Email is selected and the person’s own requests had an account email sent within the cooldown (HEIMDALL_ADMIN_EMAIL_COOLDOWN_IN_SECONDS, five minutes by default), FR-PE-16, FR-2F-19 | Return 429 Too Many Requests — “An email of this kind was sent to you recently. Try again in a few minutes.” Checked after AF-36a and before the password, so it costs no password check and no re-authentication charge. Nothing is changed or sent; a code already mailed still confirms. Selecting App alone is not affected |
| AF-36g | As AF-36f, but five account emails have been sent in the current day-long window | Return 429 Too Many Requests — “You have reached the limit for account emails. Try again later.” As AF-36f otherwise |
On requiring the password. Adding a second factor requires proof of identity, as removing one
does (UC-39): the method chosen here is what UC-38 demands at every later login, and a bearer token
is not proof that the account holder is present.
UC-37: Confirm Two-Factor Authentication Setup
| Field | Value |
|---|
| ID | UC-37 |
| Name | Confirm Two-Factor Authentication Setup |
| Actors | User, Scope Admin, System Admin |
| Description | Prove control of every method selected in UC-36 to activate two-factor authentication and receive the one-time set of recovery codes |
| Preconditions | A pending (IsActive = false) TWO_FACTOR_AUTH row exists for the person, from UC-36 |
| Postconditions | TWO_FACTOR_AUTH.IsActive is true; exactly ten recovery codes exist, hashed, and were returned to the caller in plaintext this one time; every token the person held is revoked, the one that confirmed included (FR-AU-11) |
Main Flow:
sequenceDiagram
actor P as Person
participant API as Heimdall API
participant DB as Database
P->>API: POST /api/auth/2fa/confirm { appCode?, emailCode? }
API->>DB: Load the pending TWO_FACTOR_AUTH row
API->>API: Verify appCode against the TOTP secret, if AppEnabled
API->>API: Verify emailCode against the stored hash, if EmailEnabled
API->>DB: Set IsActive = true
API->>API: Generate 10 recovery codes, hashing each
API->>DB: Store the 10 hashed recovery codes
API-->>P: 200 OK { enabled: true, recoveryCodes: [10 plaintext codes] }- Caller sends
POST /api/auth/2fa/confirm with a code for every method they selected in UC-36 (an appCode if AppEnabled, an emailCode if EmailEnabled — both, if both). - The system verifies each provided code against its method: the TOTP code against the stored secret, the email code against its stored hash and expiry.
- Only once every required code checks out, the system replaces the person’s security stamp, which
revokes every token issued before the second factor was required, the caller’s own included
(FR-AU-11).
- The system generates ten random recovery codes, hashes each, and stores the hashes. Each is 80 bits, sixteen characters of Crockford’s base32 shown as
XXXX-XXXX-XXXX-XXXX (FR-2F-20). - The system sets
IsActive = true. - The system returns the ten recovery codes in plaintext. This is the only response that will ever contain them — they are not retrievable again (only regenerable, via UC-40). The client keeps them, then signs in again; that login now goes through UC-38.
Alternative Flows:
| ID | Condition | Outcome |
|---|
| AF-37a | No pending setup exists for the person | Return 404 Not Found |
| AF-37b | appCode missing or incorrect, when AppEnabled — or the re-authentication budget is paused (FR-AU-13) | Return 401 Unauthorized — “The password or code is incorrect.” |
| AF-37c | emailCode missing, incorrect, expired, already used, or retired after five wrong guesses (FR-2F-13), when EmailEnabled | Return 401 Unauthorized — “The password or code is incorrect.”, the same answer as AF-37b. The code is not spent |
| AF-37d | Setup is already active (repeated confirm) | Return 409 Conflict |
On requiring every selected method’s code. If both App and Email were chosen in UC-36, both codes are required in the same confirmation request. Confirming with only one channel would activate a method whose configuration was never actually exercised — for the app, that a QR code was scanned into a real authenticator; for email, that the address reliably receives mail — leaving a silently broken second factor discovered only at the next login.
UC-38: Verify Second Factor (Login Challenge)
| Field | Value |
|---|
| ID | UC-38 |
| Name | Verify Second Factor |
| Actors | Anonymous (holding a challenge token from UC-11) |
| Description | Complete a login for a person with active two-factor authentication by exchanging a short-lived challenge token and a code — app, email, or a recovery code — for the real authentication token |
| Preconditions | UC-11’s password check succeeded for a person with TWO_FACTOR_AUTH.IsActive = true, and UC-11 issued a challenge token instead of a full authentication token |
| Postconditions | A full authentication token is issued, exactly as FR-AU-03/FR-AU-04 describe for a direct login; if a recovery code was used, it is marked used |
Main Flow:
sequenceDiagram
actor U as Anonymous
participant API as Heimdall API
participant DB as Database
U->>API: POST /api/auth/2fa/verify { challengeToken, code }
API->>API: Validate challengeToken (signature, not expired, MFA-pending claim)
API->>DB: Load the person's TWO_FACTOR_AUTH row
API->>API: Verify code as a current TOTP/email code, or as an unused recovery code
API->>DB: If a recovery code was used, mark it Used = true
API->>API: Generate full JWT token { personId, role, scopeId/ownedScopeIds? }
API-->>U: 200 OK { token, expiresAt, emailVerified }- Caller sends the challenge token from UC-11’s login response, together with either a current app/email code or a recovery code.
- The system validates the challenge token: signature, expiration, and that it carries the MFA-pending claim (FR-2F-10) — this endpoint is the only one that accepts such a token. The token must also still name the person’s outstanding challenge: redeeming a challenge spends it, and a newer login replaces it, so a challenge is redeemable once (FR-2F-10).
- The system checks the supplied value: a TOTP code against the stored secret, an email code against its stored hash and expiry, or a recovery code against the stored hashes — ignoring its hyphens, whitespace and case, and reading an O, I or L in a sixteen-character code as 0, 1 and 1; a code of the shorter
XXXX-XXXX shape issued before still matches as it was stored (FR-2F-20). A guess with an app code or a recovery code is first charged to the challenge, which allows five (FR-2F-17); an email code’s guesses are charged to the code (FR-2F-13). Every guess is also charged to the person, across challenges, and a correct password does not reset that count: the tenth failure locks the second factor, for fifteen minutes and twice as long at each repeat, up to a day, and only a second factor that checks out resets it (FR-AU-12). - If a recovery code was used, the system marks it consumed — it cannot be used again.
- The system marks the challenge redeemed, then generates and returns the full authentication token, identical in shape to a direct UC-11 success. A person whose processing is restricted (NFR-24) — including one restricted after the challenge was issued — gets UC-11’s AF-11i subject-rights-only token instead, and AF-38d’s scope check does not apply to them.
Alternative Flows:
| ID | Condition | Outcome |
|---|
| AF-38a | Challenge token expired or invalid, already redeemed — by an earlier request or a simultaneous one (NFR-27) — or superseded by a newer login (FR-2F-10), retired after five app-code or recovery-code guesses (FR-2F-17), issued before the person’s last credential change, so its security stamp is no longer theirs (FR-AU-11), or naming a person who is anonymised, or logically deleted and not under a restriction on processing (NFR-24) | Return 401 Unauthorized |
| AF-38b | Code does not match any valid app code, email code, or unused recovery code — including an app code already accepted once (FR-2F-14) and an email code retired after five wrong guesses (FR-2F-13) — or the person’s second factor is locked (FR-AU-12), whatever the code | Return 401 Unauthorized. A locked second factor answers exactly as a wrong code does. The code is still checked, so a correct app code’s time step is used up (FR-2F-14) and an email code’s guess is counted (FR-2F-13), but no recovery or email code is spent and no token is issued |
| AF-38c | Recovery code already used | Return 401 Unauthorized, same message as AF-38b (does not reveal that the code existed) |
| AF-38d | The submitted factor checks out, but the person’s scope eligibility (AF-11d/AF-11e) no longer holds by the time this runs | Return 401 Unauthorized with a distinct message, since — unlike AF-38a through AF-38c — nothing about the challenge token or the factor was wrong |
On the single rejection. As with UC-11’s own AF-11a..e, AF-38a through AF-38c collapse to one indistinguishable 401 — telling an unauthenticated caller which part of their guess was wrong would leak whether a challenge token is merely expired versus outright forged, or whether a recovery code was ever real.
On AF-38d being separate. AF-38d is not folded into that collapse: the caller who reaches it proved both a valid challenge token and a genuine factor, so the failure is not “you guessed wrong” but “your account stopped qualifying between UC-11 and now.” There is nothing to hide by distinguishing it — the caller already holds proof they are who they say they are.
UC-39: Disable Two-Factor Authentication
| Field | Value |
|---|
| ID | UC-39 |
| Name | Disable Two-Factor Authentication |
| Actors | User, Scope Admin, System Admin |
| Description | Turn off two-factor authentication for the caller’s own account |
| Preconditions | Actor is authenticated; TWO_FACTOR_AUTH.IsActive = true for the person |
| Postconditions | The person’s TWO_FACTOR_AUTH row and all of its recovery codes are permanently removed; every token the person held is revoked, the caller’s own included (FR-AU-11) |
Main Flow:
sequenceDiagram
actor P as Person
participant API as Heimdall API
participant DB as Database
P->>API: POST /api/auth/2fa/disable { password, code }
API->>DB: Load person and TWO_FACTOR_AUTH row
API->>API: Verify password against PasswordHash/Salt
API->>API: Verify code as a current TOTP/email code, or as an unused recovery code
API->>DB: Delete TWO_FACTOR_AUTH row (cascades to recovery codes)
API-->>P: 200 OK- Caller sends their current password together with a valid second factor — an app/email code or a recovery code.
- The system verifies the password, exactly as UC-11 does.
- The system verifies the second factor, exactly as UC-38 does.
- Only when both check out, the system replaces the person’s security stamp, revoking every token
issued while the factor stood (FR-AU-11), then deletes the
TWO_FACTOR_AUTH row, which cascades to
its recovery codes. - The system returns success. The token that made the request is revoked with the rest, so the client signs in again; two-factor authentication no longer applies to that login.
Alternative Flows:
| ID | Condition | Outcome |
|---|
| AF-39a | Two-factor authentication is not active for the person | Return 404 Not Found |
| AF-39b | Password mismatch, or the re-authentication budget is paused (FR-AU-13) | Return 401 Unauthorized — “The password or code is incorrect.” |
| AF-39c | Second factor invalid (per AF-38b/AF-38c) | Return 401 Unauthorized — “The password or code is incorrect.”, the same answer as AF-39b, so it does not say which of the two was wrong |
On requiring both factors to disable. Password alone is not enough: an attacker holding only a stolen session or a leaked password should not be able to strip two-factor protection off an account they don’t fully control. Requiring the second factor too makes disabling exactly as hard as a login.
UC-40: Regenerate Recovery Codes
| Field | Value |
|---|
| ID | UC-40 |
| Name | Regenerate Recovery Codes |
| Actors | User, Scope Admin, System Admin |
| Description | Invalidate the caller’s current recovery codes and issue a fresh set of ten |
| Preconditions | Actor is authenticated; TWO_FACTOR_AUTH.IsActive = true for the person |
| Postconditions | The previous ten recovery codes no longer validate; ten new ones exist, hashed, and were returned to the caller in plaintext this one time; every token the person held is revoked, the caller’s own included (FR-AU-11) |
Main Flow:
sequenceDiagram
actor P as Person
participant API as Heimdall API
participant DB as Database
P->>API: POST /api/auth/2fa/recovery-codes/regenerate { code }
API->>DB: Load TWO_FACTOR_AUTH row
API->>API: Verify code as a current TOTP/email code, or as an unused recovery code
API->>DB: Delete all existing TWO_FACTOR_RECOVERY_CODE rows for the person
API->>API: Generate 10 new recovery codes, hashing each
API->>DB: Store the 10 new hashed recovery codes
API-->>P: 200 OK { recoveryCodes: [10 plaintext codes] }- Caller sends a valid second factor — an app/email code, or one of their remaining recovery codes.
- The system verifies it, exactly as UC-38 does.
- The system replaces the person’s security stamp, revoking every token issued before (FR-AU-11): the
codes are usually replaced because the old set may be known to somebody else, and so may a session
opened with one.
- The system generates ten new codes of the current shape (FR-2F-20), hashes each, and stores them,
then deletes every earlier recovery code row for the person, including any still unused. This is
also how a person holding the shorter codes issued before FR-2F-20 gets the stronger ones.
- The system returns the ten new codes in plaintext — the only response that will ever contain them. The client keeps them, then signs in again.
Alternative Flows:
| ID | Condition | Outcome |
|---|
| AF-40a | Two-factor authentication is not active for the person | Return 404 Not Found |
| AF-40b | Second factor invalid (per AF-38b/AF-38c), or the re-authentication budget is paused (FR-AU-13) | Return 401 Unauthorized — “The password or code is incorrect.” |
On invalidating the whole set. Regeneration replaces all ten codes at once rather than topping up the used ones back to ten: a partial refill would leave old, already-distributed codes valid alongside new ones, defeating the point of rotating them.
UC-46: Resend Second-Factor Email Code
| Field | Value |
|---|
| ID | UC-46 |
| Name | Resend Second-Factor Email Code |
| Actors | Anonymous (holding a challenge token from UC-11) |
| Description | Reissue the email code for a challenge that is still outstanding, so a person who never received the first one can ask for another without restarting sign-in |
| Preconditions | UC-11 issued a challenge token for a person whose TWO_FACTOR_AUTH has EmailEnabled = true, and that token has not yet expired |
| Postconditions | The person’s outstanding email codes are retired and a fresh one is mailed to them; the challenge’s reissue count is incremented. The challenge token itself is unchanged and its expiry is not moved |
Main Flow:
sequenceDiagram
actor U as Anonymous
participant API as Heimdall API
participant DB as Database
participant M as Mail
U->>API: POST /api/auth/2fa/challenge/resend { challengeToken }
API->>API: Validate challengeToken (signature, not expired, MFA-pending claim)
API->>DB: Load the person and their active TWO_FACTOR_AUTH row
API->>DB: Increment the challenge's reissue count (refuse past the cap)
API->>DB: Mark outstanding email codes Used = true
API->>DB: Store a fresh 6-digit code, hashed, expiring per FR-2F-03
API->>M: Send the code to the person's stored address
API-->>U: 200 OK- Caller sends the challenge token UC-11’s login response returned. Nothing else — no address, no scope, no credential.
- The system validates the challenge token exactly as UC-38 step 2 does: signature, expiration, and the MFA-pending claim (FR-2F-10).
- The system resolves the person the token names and their active two-factor configuration, and confirms the email method is enabled for them.
- The system confirms the challenge has reissues left (FR-2F-13) and charges one against it.
- The system retires the person’s outstanding email codes and stores a fresh one, hashed, expiring per FR-2F-03.
- The system mails the code to the person’s stored address.
- The system answers
200 OK, carrying no data.
Alternative Flows:
| ID | Condition | Outcome |
|---|
| AF-46a | Challenge token missing, malformed, unsigned by this API, expired, or not carrying the MFA-pending claim — or no longer the person’s outstanding challenge, because it was redeemed or a newer login replaced it (FR-2F-10), or issued before the person’s last credential change (FR-AU-11) | Return 200 OK, identical to the main flow. Nothing is sent |
| AF-46b | The challenge names a person who no longer exists, is anonymised, or is logically deleted and not under a restriction on processing (NFR-24) | Return 200 OK, identical to the main flow. Nothing is sent. A restricted person’s code is sent: they asked for it to finish signing in for their own rights (UC-11 AF-11i), which is the consent Art. 18(2) asks for |
| AF-46c | The person has no active two-factor configuration, or has one without the email method — an authenticator-app holder has no code to resend | Return 200 OK, identical to the main flow. Nothing is sent |
| AF-46d | The challenge has already authorized its maximum reissues (FR-2F-13) | Return 200 OK, identical to the main flow. Nothing is sent |
| AF-46e | A full authentication token is presented in place of a challenge token | Treated as AF-46a — the MFA-pending claim is absent, so it is not a challenge token (FR-2F-10) |
On the single answer. Every flow above, and the main flow, return the same 200 OK with the same message and no data. That is the point of the endpoint’s design rather than an economy in writing it: this route is anonymous, and an answer that varied would say whether an address is registered, whether it has email two-factor enabled, and how much of its guessing budget is left. It is the same reasoning behind UC-11’s AF-11a…AF-11e and UC-38’s AF-38a…AF-38c, with the sign flipped — those collapse to one 401 because the caller asked to be let in; this collapses to one 200 because they asked for something to be sent, and “we have sent it if there was anything to send” is the most that can be said without answering a question they did not earn the right to ask.
On the challenge not being extended. A reissue puts a fresh code inside the existing challenge’s window; it never lengthens it and never returns a new challenge token. This is forced rather than chosen: a new token could only be returned for a challenge that was genuine, so returning one would be precisely the oracle the paragraph above rules out. The window’s ceiling is therefore NFR-17’s, which is the same ten minutes FR-2F-03 gives the code — so a reissue inside the window is worth having rather than a formality.
On the reissue cap. FR-2F-13 retires an email code after five wrong guesses. Without a cap, this use case would turn that bound into five guesses times however many reissues a caller could obtain, which would make it a property of the rate limiter’s configuration rather than of the specification. Three reissues per challenge keeps it stated: at most twenty guesses at a six-digit code per authentication, after which a further code costs a fresh password check — which is what FR-2F-13 always charged.
UC-47: Register a Password User
| Field | Value |
|---|
| ID | UC-47 |
| Name | Register a Password User |
| Actors | Anonymous |
| Description | Create a password User in a scope without an administrator, where the scope’s owners have opened self-registration |
| Preconditions | The scope exists, is not logically deleted, and has SelfRegistrationEnabled = true (FR-SR-01, FR-SR-02) |
| Postconditions | Either a pending registration exists and its link was sent, or the holder of the address was told of the attempt. The caller cannot tell which. No User exists until the link is followed with the password chosen at sign-up (FR-SR-07, FR-SR-09) |
Main Flow:
sequenceDiagram
actor U as Anonymous
participant API as Heimdall API
participant Q as Registration queue
participant DB as Database
participant M as Mail
U->>API: POST /api/auth/register { name, email, password, scopeId }
API->>API: Validate the request
API->>DB: Confirm the scope is open
API->>API: Hash the password
API->>Q: Queue { scopeId, name, email, hash, salt }
API-->>U: 202 Accepted
Q->>DB: Re-check the scope, look the address up in the scope
Q->>DB: Write a pending registration (address free)
Q->>M: Send the confirmation email (UC-14 link)
U->>API: POST /api/auth/verify-email { token, password } (UC-14)
API->>API: Check the password against the stored hash
API->>DB: Re-check the scope and the address
API->>DB: Create the User, verified; remove the address's pending registrations
API-->>U: 200 OK- Caller sends a name, an email address, a password and the scope’s
PublicId. - The system validates the request with the rules UC-06 path a applies (FR-SR-03).
- The system confirms the scope exists, is live and is open to self-registration.
- The system hashes the password, queues the request and answers
202 Accepted with a fixed message. - Afterwards, the system re-checks the scope and confirms the address is held by no live person and no Google User of the scope (FR-SR-04).
- The system writes a pending registration — name, address, password hash and salt, and the hash of a single-use token, expiring with the email-verification token lifetime (FR-SR-08) — and mails the token’s link (FR-EV-01/02), in a message that tells the registrant to enter the password chosen at sign-up. It creates no
User. - The person follows the link (UC-14) and enters the password chosen at sign-up (FR-SR-09).
- The system checks the password against the stored hash, re-checks that the scope is still open and the address still free, creates the
User, verified, records its legal basis (NFR-23), and removes the address’s pending registrations (FR-SR-07).
Alternative Flows:
| ID | Condition | Outcome |
|---|
| AF-47a | The scope is missing, logically deleted, or has self-registration off | 403 Forbidden with one message for all three. Nothing is queued |
| AF-47b | The address is already held in the scope by a live person or a Google User | 202 Accepted, identical to the main flow. Nothing is created; the holder is emailed, at the address on their record, that someone tried to register their address, unless their processing is restricted (NFR-24) or they are a Google User who has been logically deleted, who could not act on it (FR-SR-05) |
| AF-47c | The request is malformed | 400 Bad Request with the field’s message. Nothing is queued |
| AF-47d | The scope is closed or deleted while the request waits | Nothing is created or sent |
| AF-47e | The link is followed after it expired | UC-14 AF-14a: 400 Bad Request — “Token expired”. No User is created |
| AF-47f | The scope is closed or deleted, or the address is taken, before the link is followed | UC-14 AF-14c: 400 Bad Request — “Invalid token”. No User is created |
| AF-47g | The registrant signs in before following the link | UC-11 AF-11a: 401 Unauthorized, identical to a wrong password — no Person exists to find |
| AF-47h | The link is followed without the password chosen at sign-up | 422 Unprocessable Entity — “Enter the password you chose when you signed up to finish creating your account.” The registration is not consumed (FR-SR-09) |
| AF-47i | The link is followed with a wrong password | 401 Unauthorized — “That password does not match the one chosen when signing up.” The registration is not consumed, but the guess is charged before it is compared (NFR-27): on the 10th wrong password the registration is discarded, that attempt is still answered 401, and the link then answers UC-14 AF-14c (FR-SR-09) |
| AF-47j | The link is followed again after the account exists | UC-14 AF-14c: 400 Bad Request — “Invalid token”. The registration was removed when the account was created, so there is nothing to claim and nothing is created |
| AF-47k | The address already has three live pending registrations in the scope (FR-SR-13) | 202 Accepted, identical to the main flow. Nothing is stored or mailed; the three links already sent keep working |
| AF-47l | The address is held (AF-47b), and three attempt notices have gone to it within the current day, whichever scopes they were for (FR-SR-14) | 202 Accepted, identical to the main flow. Nothing is created and no notice is sent |
On the single answer. As UC-12 and UC-46 do, this anonymous route answers a free and a taken address alike, in content and in time (FR-SR-06). Everything whose cost depends on the answer runs after the response. The scope check stays on the request path because it says nothing about any address.
On the mail an address can be sent. Each request may mail the address it names, and the caller
cannot see whether it did, so the bound has to be on the recipient rather than the caller: the
per-IP rate limit does nothing against a sender with many source addresses. AF-47k bounds the
confirmation mail to three live links per scope; AF-47l bounds the notices to three a day per
address across every scope. Both are applied after the response, like everything else that depends
on the address, so they change nothing the caller sees.
UC-41: Export Own Personal Data
| |
|---|
| ID | UC-41 |
| Name | Export Own Personal Data |
| Actor | Any authenticated identity — a person of any role, or a Google User |
| Description | A data subject obtains a copy of everything held about them, with the context that makes it intelligible (GDPR Art. 15 and 20, LGPD Art. 18 II and V). |
| Preconditions | The caller holds a valid authentication token. |
| Postconditions | None; the use case discloses. One audit entry records that it did. |
Main flow
- The system resolves the subject from the authentication token — never from the request, which carries no identifier for one.
- The system assembles the identity, the security state — whether a password and second factor are set, and the counters and locks of the login lockout (FR-AU-09), the second-factor budget (FR-AU-12) and the re-authentication budget (FR-AU-13) — the lawful basis and notice version, the deletion and erasure state, scope memberships and ownerships, owned applications, and the audit entries still attributed to the subject.
- The system adds the context Art. 15 requires alongside the data: who else receives it, how long each category is kept, and what is held but not reproduced.
- The system returns the document with
Cache-Control: no-store.
Alternative flows
| ID | Condition | Outcome |
|---|
| AF-41a | The token names no person and no Google User | NotEligible (403) |
| AF-41b | More audit entries exist than the document carries | The copy is returned with auditEntriesTruncated set. A silently truncated copy is not the copy Art. 15 asks for |
Notes
POST, for something that reads. A GET response is cacheable, and this one is a document containing everything about a person — exactly what must not sit in a proxy or browser cache. The export is also the highest-value action a stolen token can take, so it must reach the audit trail, which here means being a command.- One endpoint, not one per identity type. The subject is the token’s, so a second endpoint would be a second way to say the same thing and a second place to get authorization wrong.
- Secret material is named, not reproduced. Art. 15(4) says the right to a copy must not adversely affect others’ rights, and reproducing a password hash or a TOTP secret would hand whoever holds the document the material to attack the account. Each is listed with the reason, so the subject learns what exists.
- A suspended identity can still export. The lookups omit the
!IsDeleted filter most handlers apply: somebody suspended pending erasure has more reason to want a copy than anyone, and Art. 15 is not conditional on the account being in good standing. - So can a restricted one (NFR-24: “remaining reachable by its own subject’s export”). The liveness check that refuses a restricted or suspended identity’s token everywhere else steps aside for this endpoint and for UC-42, UC-44 and UC-45; an anonymised identity is still refused.
- Cleared audit attributions cannot appear. Once NFR-21 clears an attribution, nothing connects the entry to the person; reporting it would itself be a re-identification.
UC-42: Request Erasure of Own Identity
| |
|---|
| ID | UC-42 |
| Name | Request Erasure of Own Identity |
| Actor | Any authenticated identity — a person of any role, or a Google User |
| Description | A data subject asks for their own identity to be erased (GDPR Art. 17, LGPD Art. 18 VI). The request is recorded, the deadline starts, and the identity is suspended immediately; the anonymisation pass (NFR-20) carries the erasure out. |
| Preconditions | The caller holds a valid authentication token and has not already requested erasure. |
| Postconditions | The request and its deadline are recorded, and any restriction the subject had in place (UC-44) is lifted. The identity is suspended, unless something blocks it, in which case the blocking reason is recorded and the deadline still runs. |
Main flow
- The caller submits their credential: a password if they are a person, a freshly issued Google ID token if they are a Google User.
- The system resolves the subject from the authentication token — never from the request, which carries no identifier for one.
- The system verifies the credential against that identity.
- The system records
ErasureRequestedAt and computes ErasureDueAt from the statutory deadline, storing it so a later configuration change cannot move an obligation that has already attached. - If the subject’s processing is restricted (UC-44), the system lifts the restriction in the same write, as UC-45 does for a subject lifting their own — no notification — and records the lift in the audit trail (NFR-09).
- The system suspends the identity, marking the deletion as subject-requested so the shorter deadline applies.
- The system returns the request timestamp, the deadline, and whether a restriction was lifted.
Alternative flows
| ID | Condition | Outcome |
|---|
| AF-42a | The credential is absent, wrong, unverifiable, or names a different Google account — or, for a person, their re-authentication budget is paused (FR-AU-13) | 401. A person’s password is refused with “The password or code is incorrect.”, the answer every re-authentication shares; a Google User’s ID token with CredentialNotAccepted. Nothing is recorded. A bearer token alone must not be able to trigger an irreversible operation. |
| AF-42b | The token names no live person and no live Google User | NotEligible (403), answered alike for every such condition. |
| AF-42c | An erasure has already been requested | ErasureAlreadyRequested (409). Checked before the credential, so a repeat costs no password derivation — and before AF-42b, so a subject whose first request suspended them is told it exists rather than that they are not eligible. |
| AF-42d | The caller is the last owner of a scope (NFR-12) | ErasureRequestedButBlocked (200). The request is recorded and the deadline runs; the identity is not suspended, and the reason is stored for UC-43. A restriction is still lifted (step 5). The anonymisation pass retries it once an owner has been transferred (UC-21). |
Notes
- The suspension is immediate and deliberate: a subject who has asked to be erased has withdrawn the basis on which the account operates, so continuing to authenticate them while the deadline runs would be processing they have objected to.
- AF-42d is a success rather than a refusal. The subject’s right does not depend on the scope’s ownership arrangements, and GDPR Art. 12(3)’s clock starts at the request whether or not anything blocks it. Answering 4xx would tell the subject their right had been refused when it has not.
- Because it verifies a password, this use case’s endpoint is governed by NFR-18 rather than NFR-05.
- A restricted subject may request erasure, and the request lifts the restriction (NFR-24). The right does not lapse with the restriction, and the request is the subject’s own consent to the processing it involves (Art. 18(2)). Left in place, the restriction would keep the record out of the anonymisation pass while the deadline ran out. Only the subject can impose a restriction (UC-44), and only the subject, credential in hand, can reach this step — so the request is their later and more specific instruction, and it supersedes their wish that the record be preserved. No Art. 18(3) notification is sent: the subject is the person it would inform (UC-45). The response says so (
restrictionLifted), and the audit trail records a LiftProcessingRestrictionCommand entry on the subject beside the request’s own. - A restriction imposed after the request holds the erasure. A suspended identity may still restrict (UC-44), and that later instruction is not overridden: NFR-24 keeps the record out of the anonymisation pass until it is lifted — by the subject (UC-45), or by a System Admin after informing them — and UC-43 shows it meanwhile. Repeating the erasure request is AF-42c and lifts nothing.
- This use case does not change UC-09 or UC-10. Those remain administrative operations and keep refusing self-deletion; this is a separate path with a different actor and a different meaning.
UC-44: Restrict Processing of Own Identity
| |
|---|
| ID | UC-44 |
| Name | Restrict Processing of Own Identity |
| Actor | Any authenticated identity — a person of any role, or a Google User |
| Description | A data subject suspends processing of their identity without anything being deleted (GDPR Art. 18, LGPD Art. 18 III–IV), while something about it is disputed. |
| Preconditions | The caller holds a valid authentication token and is not already restricted. |
| Postconditions | The identity is restricted. Nothing is deleted, and no retention window starts. |
Main flow
- The caller names one of Art. 18(1)’s four grounds.
- The system resolves the subject from the authentication token — never from the request.
- The system records the restriction and the ground.
- From that moment the identity can authenticate only for its subject’s own rights (a subject-rights-only token — UC-11 AF-11i, UC-25 AF-25e), receives no email but the second-factor codes its subject asks for when signing in, and is withheld from tenant-facing listings.
Alternative flows
| ID | Condition | Outcome |
|---|
| AF-44a | Already restricted | ProcessingAlreadyRestricted (409) |
| AF-44b | The ground is not one of Art. 18(1)’s four | RestrictionGroundUnknown (400) |
| AF-44c | The token names no live identity | NotEligible (403) |
Notes
- Restriction is not deletion, and the two are independent states.
IsDeleted means the identity is on its way out — excluded from reads, cascaded by UC-04, eventually anonymised by NFR-20. A restriction means the opposite: the record is disputed and must be preserved exactly as it stands. Reusing the flag would start an erasure clock on data the subject has specifically asked be kept. - No credential is required, unlike UC-42. Erasure is irreversible and demands proof the person is present. A restriction destroys nothing and is liftable, and somebody asking for one may be doing so precisely because they believe the account is compromised — demanding the password of a person in that position would be the wrong way round.
- A suspended identity may still restrict. Art. 18 does not require the account to be in good standing, and a person pending erasure may well want to contest what is held about them. Such a restriction holds the erasure until it is lifted (UC-45), because it is the subject’s later instruction; the reverse order differs — an erasure request lifts a restriction already in place (UC-42).
- A restricted identity can still reach this use case and its siblings. Every other endpoint refuses its token (NFR-24), but UC-41, UC-42, UC-44 and UC-45 are how the subject exercises their rights over the restricted record, so they admit a restricted or suspended (not anonymised) subject and answer with their own flows — here, AF-44a.
- The grounds are an enum rather than free text: Art. 18(1) is exhaustive, so prose would add unbounded personal data with no purpose the ground does not already serve.
UC-45: Lift a Restriction on Processing
| |
|---|
| ID | UC-45 |
| Name | Lift a Restriction on Processing |
| Actor | The subject, for their own; a System Admin, for anyone’s |
| Description | Ends a restriction, after informing the subject where the law requires it (GDPR Art. 18(3)). |
| Preconditions | The named identity is restricted. |
| Postconditions | The restriction is cleared. Where somebody else lifted it, the notification is recorded. |
Main flow
- The system resolves the subject — the caller’s own identity, or the one named where a System Admin is acting.
- Where the caller is not the subject, the system informs the subject before proceeding.
- The system clears the restriction and records that the notification happened.
Alternative flows
| ID | Condition | Outcome |
|---|
| AF-45a | The identity is not restricted | NotRestricted (404) |
| AF-45b | The subject could not be informed | RestrictionLiftNotificationFailed (503). The restriction stands |
| AF-45c | A non-System-Admin names somebody else, or a System Admin who is themselves restricted or suspended, or who holds a subject-rights-only token, does | NotEligible (403) |
Notes
- AF-45b is the opposite of how every other delivery in this API behaves. Elsewhere a failed send is deliberately not the caller’s problem: a verification email that does not arrive is re-requested, and surfacing the failure would leak whether an address exists. Art. 18(3) makes this one a precondition — the subject “shall be informed before the restriction is lifted” — so a lift that proceeded after a failed send would be unlawful, and would look identical to one that worked.
- The subject can reach this while restricted. Their token is refused everywhere else (NFR-24), but lifting their own restriction is their right, so this endpoint admits it. It admits nothing more: a restricted System Admin is a subject here, not an administrator (AF-45c).
- Signing in still reaches it. A subject whose earlier tokens have expired signs in as usual and is issued a subject-rights-only token (UC-11 AF-11i, UC-25 AF-25e, UC-38), which this endpoint admits. That token acts for its subject alone — a System Admin holding one is refused somebody else’s (AF-45c) — and stays confined to UC-41, UC-42, UC-44 and UC-45 after the lift, so the subject signs in again for a full token.
- A subject lifting their own needs no notification. They are the person Art. 18(3) exists to inform. Requiring an email to somebody standing in front of you, and refusing their request when it bounces, would be the article’s letter against its purpose.
UC-43: List Outstanding Erasure Requests
| |
|---|
| ID | UC-43 |
| Name | List Outstanding Erasure Requests |
| Actor | System Admin |
| Description | Lists the erasure requests that have been made and not yet carried out, soonest deadline first, flagging those already overdue. |
| Preconditions | The caller is a System Admin. |
| Postconditions | None; the use case reads. |
Main flow
- The system reads the outstanding requests from both identity tables — those with a request recorded and no anonymisation yet.
- The system marks each as overdue or not, against the moment the queue is read.
- The system returns them paginated, soonest deadline first, optionally filtered to the overdue ones.
Notes
- The listing exists because a deadline nobody can see is a deadline nobody meets. Most requests need no human at all — the anonymisation pass carries them out — but the ones AF-42d blocks need an owner transferred first, and without this they would sit unnoticed until the deadline had passed.
- A Scope Admin is deliberately excluded, though they administer their scope’s users: which of them has asked to be erased is not something the request entitles anyone to know.
- The projection carries no name and no email address. The subject has asked to be erased; a queue an administrator works from is the last place to widen who sees their details.
UC-48: List the Audit Trail
| Field | Value |
|---|
| ID | UC-48 |
| Name | List the Audit Trail |
| Actors | System Admin |
| Description | Read the audit trail (NFR-09) through the API via GET /api/audit-log, newest first, filtered by when an entry was recorded, by actor, action, target and outcome |
| Preconditions | Actor is authenticated as a System Admin |
| Postconditions | None; the use case reads, and the read itself is not audited |
Main Flow:
sequenceDiagram
actor SA as System Admin
participant API as Heimdall API
participant DB as Database
SA->>API: GET /api/audit-log?from&to&actorId&action&targetId&succeeded&pageNumber&pageSize
API->>API: Validate the filters and the page
API->>DB: Read the matching AUDIT_LOG rows, newest first
API-->>SA: 200 OK — a page of entries- A System Admin requests the audit trail, optionally filtering by an inclusive range of when entries were recorded (
from, to, UTC; a value without an offset is read as UTC), by the actor’s identifier, by the exact action (the command’s type name, e.g. CreateScopeCommand), by the affected entity’s identifier, and by outcome (succeeded), and paging the result (FR-AT-01). Filters combine with AND; an empty action is no filter. - The system validates the request: the range must not run backwards, the action must fit its column (200 characters), and the page must be within NFR-10’s bounds.
- The system returns the matching entries newest first, ties broken by the order they were written, each with its identifier, when it was recorded, the actor and their role, the action, the target, whether it succeeded and, for a refusal, the canonical reason (FR-AT-02).
Alternative Flows:
| ID | Condition | Outcome |
|---|
| AF-48a | A filter or the page is invalid — the range ends before it starts, the action is longer than 200 characters, or the page is out of bounds | 400 Bad Request — “The end of the date range must not be earlier than its start.” or the pagination message |
| AF-48b | The caller is not a System Admin | 403 Forbidden; an anonymous caller gets 401 Unauthorized |
| AF-48c | No entry matches | 200 OK with an empty page |
On cleared actors. The listing shows each entry as stored and never reconstructs a cleared
actor — no join to PERSON, no inference from other entries (NFR-09, NFR-21, Data Retention
Schedule §7.1). An entry whose attribution period is over, or whose actor has been anonymised,
reads actorId: null, and filtering by actorId cannot find it. The same holds for targets: an
entry whose target was a person or Google User has that target cleared on the same two grounds and
reads targetId: null, so filtering by targetId cannot find it either. So does an entry whose
target was hard-deleted (UC-05, UC-10, UC-29) — the hard delete’s own entry included — on the pass
after the delete, at any age, because the hard delete tombstoned the id first (#195). Targets
naming anything else — a scope, an application, a credential, a permission — are kept and stay
filterable, as is the id of a person hard-deleted before tombstones existed, which nothing
recorded.
On Scope Admins. AUDIT_LOG has no scope column, and an old entry’s target may since have been
deleted or anonymised, so the trail cannot be divided by scope reliably. The listing is therefore
System Admins’ only.
UC-49: List Pending Registrations
| Field | Value |
|---|
| ID | UC-49 |
| Name | List Pending Registrations |
| Actors | System Admin, Scope Admin |
| Description | List the self-registrations (UC-47) of a scope that are awaiting confirmation, via GET /api/scopes/{id}/pending-registrations |
| Preconditions | Actor is authenticated; the scope exists and is not logically deleted |
| Postconditions | None; the use case reads |
Main Flow:
sequenceDiagram
actor A as System Admin / Scope Admin
participant API as Heimdall API
participant DB as Database
A->>API: GET /api/scopes/{id}/pending-registrations?pageNumber&pageSize
API->>DB: Find the live scope
API->>DB: Confirm the actor may manage it
API->>DB: Read the scope's PENDING_REGISTRATION rows, newest first
API-->>A: 200 OK — a page of registrations- A System Admin or a Scope Admin requests a scope’s pending registrations, paging the result (FR-SR-11).
- The system confirms the scope exists and is not logically deleted.
- The system confirms the actor may manage the scope: a System Admin always may; a Scope Admin must own it.
- The system returns the scope’s pending registrations, newest first, each with its identifier, name, address, when it was stored and expires, how many wrong confirmation passwords it may still take (never below zero), and whether it has already expired. Whether the scope is still open to self-registration does not matter. The password hash, salt and token hash are never returned.
Alternative Flows:
| ID | Condition | Outcome |
|---|
| AF-49a | The scope is not found or is logically deleted | 404 Not Found — “Scope not found.” |
| AF-49b | The actor is neither a System Admin nor an owner of the scope | 403 Forbidden — “You are not an owner of the target scope.”; a User is refused 403 by the role gate, an anonymous caller 401 |
| AF-49c | Nothing is pending | 200 OK with an empty page |
On anti-enumeration. FR-SR-06’s guarantee is about the anonymous POST /api/auth/register,
which still answers free and taken addresses alike. This listing is authenticated and limited to
the scope’s owners, who would see the same name and address in their user list once the
registration is confirmed, and a taken address never produces a pending row — so it reveals
nothing about existing accounts.
UC-50: Discard a Pending Registration
| Field | Value |
|---|
| ID | UC-50 |
| Name | Discard a Pending Registration |
| Actors | System Admin, Scope Admin |
| Description | Remove one of a scope’s pending self-registrations, so its link stops working, via DELETE /api/scopes/{id}/pending-registrations/{registrationId} |
| Preconditions | Actor is authenticated; the scope exists and is not logically deleted; the registration belongs to it |
| Postconditions | The registration no longer exists; following its link answers as for any unknown token; the discard is audited with the registration as target |
Main Flow:
sequenceDiagram
actor A as System Admin / Scope Admin
participant API as Heimdall API
participant DB as Database
A->>API: DELETE /api/scopes/{id}/pending-registrations/{registrationId}
API->>DB: Find the live scope, confirm the actor may manage it
API->>DB: Find the registration within that scope
API->>DB: Delete it
API->>DB: Write the audit entry (target: the registration)
API-->>A: 200 OK- A System Admin or a Scope Admin asks to discard a scope’s pending registration, by the identifier UC-49 returned (FR-SR-12).
- The system confirms the scope exists, is not logically deleted, and that the actor may manage it.
- The system finds the registration among that scope’s.
- The system deletes it, records the discard in the audit trail with the registration as target (NFR-09), and answers with its identifier. Following its link now behaves as for any unknown token (UC-14 AF-14c); the registrant may sign up again.
Alternative Flows:
| ID | Condition | Outcome |
|---|
| AF-50a | The scope is not found or is logically deleted | 404 Not Found — “Scope not found.” |
| AF-50b | The actor is neither a System Admin nor an owner of the scope | 403 Forbidden — “You are not an owner of the target scope.”; a User 403, an anonymous caller 401 |
| AF-50c | No registration with that identifier belongs to the scope — it never existed, belongs to another scope, or was confirmed or purged a moment earlier | 404 Not Found — “Pending registration not found.” Nothing is removed |
UC-51: Send a Verification Email on a Person’s Behalf
| Field | Value |
|---|
| ID | UC-51 |
| Name | Send a Verification Email on a Person’s Behalf |
| Actors | System Admin, Scope Admin, Email Service |
| Description | An administrator has a fresh verification link mailed to a person’s own address, via POST /api/persons/{id}/verification-email |
| Preconditions | Actor is authenticated; the person exists; a Scope Admin acts only on a User of a scope they own |
| Postconditions | The person’s live verification links are retired and a new one is mailed to their stored address; the request is audited with the person as target |
Main Flow:
sequenceDiagram
actor A as System Admin / Scope Admin
participant API as Heimdall API
participant DB as Database
participant M as Email Service
A->>API: POST /api/persons/{id}/verification-email
API->>DB: Find the person, confirm the actor may act on them
API->>API: Refuse if restricted, deleted or already verified
API->>DB: Any verification token issued within the cooldown?
API->>DB: Retire live verification tokens, write a new one
API->>M: Send the link to the person's stored address
API-->>A: 200 OK- A System Admin or a Scope Admin asks for a verification email for a person, by the person’s identifier; there is no body (FR-EV-06).
- The system finds the person and confirms the actor may act on them: a System Admin on anyone; a Scope Admin only on a
User of a scope they own. - The system refuses a person whose processing is restricted (NFR-24), who is logically deleted, or whose address is already verified.
- The system refuses if a verification email went to this person within the cooldown — any verification token issued for them in that window, by anyone, used or not (FR-EV-07).
- As UC-15 does, the system retires the person’s live verification tokens, issues a new one and mails its link to the person’s stored address, using the scope’s verification URL when it has one.
- The system answers with a fixed message; the token is never returned, logged or accepted.
Alternative Flows:
| ID | Condition | Outcome |
|---|
| AF-51a | No person has that identifier (including a Google User’s identifier) | 404 Not Found — “Person not found.” |
| AF-51b | The actor may not act on this person — a Scope Admin aiming at anyone but a User of a scope they own | 403 Forbidden — “You are not allowed to send email to this person.”; a User is refused 403 by the role gate, an anonymous caller 401 |
| AF-51c | The person’s processing is restricted, or they are logically deleted | 409 Conflict — “Processing of this person’s data is restricted, so no email was sent.” or “This person is deleted, so no email was sent.” |
| AF-51d | The address is already verified | 400 Bad Request — “Email already verified.” |
| AF-51e | A verification email went to this person within the cooldown (HEIMDALL_ADMIN_EMAIL_COOLDOWN_IN_SECONDS, 5 minutes by default) | 429 Too Many Requests — “An email of this kind was sent to this person recently. Try again in a few minutes.” Nothing is sent |
Best effort. The cooldown is read from the issued token rows, not held under a lock, so
simultaneous requests for the same person can both pass it.
On delivery. 200 means the email was handed to the sender. A delivery failure is logged and
swallowed, as for every email this API sends.
UC-52: Send a Password Reset on a Person’s Behalf
| Field | Value |
|---|
| ID | UC-52 |
| Name | Send a Password Reset on a Person’s Behalf |
| Actors | System Admin, Scope Admin, Email Service |
| Description | An administrator has a password-reset link mailed to a person’s own address, via POST /api/persons/{id}/password-reset |
| Preconditions | Actor is authenticated; the person exists and could sign in (UC-12’s eligibility); a Scope Admin acts only on a User of a scope they own |
| Postconditions | A new reset token exists for the person and its link was mailed to their stored address; the request is audited with the person as target |
Main Flow:
sequenceDiagram
actor A as System Admin / Scope Admin
participant API as Heimdall API
participant DB as Database
participant M as Email Service
A->>API: POST /api/persons/{id}/password-reset
API->>DB: Find the person, confirm the actor may act on them
API->>API: Apply UC-12's eligibility
API->>DB: Any reset token issued within the cooldown?
API->>DB: Write a reset token
API->>M: Send the link to the person's stored address
API-->>A: 200 OK- A System Admin or a Scope Admin asks for a password reset for a person, by the person’s identifier; there is no body (FR-PR-05).
- The system finds the person and confirms the actor may act on them, as UC-51.
- The system applies UC-12’s eligibility, shared with it: the person’s processing must not be restricted, the person must not be logically deleted, and the account must be able to sign in — a
User’s scope live, a ScopeAdmin owning at least one live scope. - The system refuses if a reset email went to this person within the cooldown — any reset token issued for them in that window, by anyone, used or not (FR-PR-06).
- The system issues a reset token and mails its link to the person’s stored address, using the scope’s reset URL when it has one. Unlike UC-12 the work is not queued: the caller is authenticated and is owed a real answer. Earlier live reset links are left as UC-12 leaves them.
- The system answers with a fixed message; neither a token nor a password is returned, logged or accepted.
Alternative Flows:
| ID | Condition | Outcome |
|---|
| AF-52a | No person has that identifier | 404 Not Found — “Person not found.” |
| AF-52b | The actor may not act on this person | 403 Forbidden — “You are not allowed to send email to this person.”; a User 403, an anonymous caller 401 |
| AF-52c | The person’s processing is restricted, or they are logically deleted | 409 Conflict — the restricted or deleted message of AF-51c |
| AF-52d | The account cannot sign in — a User whose scope is logically deleted, or a ScopeAdmin with no live scope | 409 Conflict — “This person’s account cannot sign in, so no password reset was sent.” |
| AF-52e | A reset email went to this person within the cooldown | 429 Too Many Requests — “An email of this kind was sent to this person recently. Try again in a few minutes.” Nothing is sent |
Best effort. As for UC-51, simultaneous requests for the same person can both pass the cooldown.
UC-53: Issue an Application Credential
| Field | Value |
|---|
| ID | UC-53 |
| Name | Issue an Application Credential |
| Actors | System Admin, Scope Admin |
| Description | Give a registered application a secret it can exchange for a token (UC-56), via POST /api/scopes/{scopeId}/applications/{id}/credentials. The body is optional and carries only expiresAt |
| Preconditions | Actor is authenticated as a System Admin, or as a Scope Admin who owns the application and still owns its scope (UC-17 AF-17b); the application is live and sits in the addressed, live scope; it holds fewer than two live credentials |
| Postconditions | A credential exists whose SHA-256 hash alone is stored; the secret was returned once and is held nowhere; the issue is audited with the credential as target |
Main Flow:
sequenceDiagram
actor A as System Admin / Scope Admin
participant API as Heimdall API
participant DB as Database
A->>API: POST /api/scopes/{scopeId}/applications/{id}/credentials (expiresAt?)
API->>API: Validate the expiry
API->>DB: Find the live application in the live scope
API->>API: Confirm the actor may manage it
API->>API: Generate a secret, hash it
API->>DB: Lock the application's row, count live credentials, insert if fewer than two
API-->>A: 201 Created — id, secret, createdAt, expiresAt- A System Admin or a Scope Admin asks for a credential for an application, optionally with an expiry (
expiresAt) (FR-AP-10). - The system validates the expiry: it must be in the future, and a value without an offset (what a
datetime-local input produces) is read as UTC. - The system resolves the application in the addressed scope; it must be live, and so must the scope.
- The system checks the actor may manage the application: a System Admin always may; a Scope Admin only if they own the application and still own its scope (FR-AP-12).
- The system generates a secret (
hds_ plus 32 random bytes, base64url, 47 characters) and stores only its SHA-256, under a lock on the application’s row, provided fewer than two live credentials exist (FR-AP-11). Two administrators issuing at the same moment cannot leave three live secrets. - The system answers
201 Created with the credential’s id, the secret, createdAt and expiresAt. The audit entry names the credential as target and never holds the secret (NFR-09).
Alternative Flows:
| ID | Condition | Outcome |
|---|
| AF-53a | No live application with that id in the addressed, live scope (also a logically deleted application, or one in another scope) | 404 Not Found — “Application not found.” |
| AF-53b | A Scope Admin who does not own the application, or no longer owns its scope | 403 Forbidden — “You are not allowed to manage this application’s credentials.”; a User 403, an anonymous caller 401 |
| AF-53c | expiresAt is not in the future, or the body cannot be read (not JSON, or an expiresAt that is not a date-time) | 400 Bad Request — “The expiry must be in the future.” or, for a body that cannot be read, “The request is malformed.” (answered before the use case runs, NFR-10); never a credential without expiry |
| AF-53d | The application already holds two live credentials | 409 Conflict — “This application already has two live credentials. Revoke one before issuing another.” |
Why two. A secret is rotated without downtime by issuing the second, deploying it to the
consumer, and only then revoking the first (FR-AP-11). A third would only let spent secrets pile
up unrevoked. A credential that has expired or been revoked does not count towards the two.
Why the secret is never shown again. Heimdall keeps the hash alone, so neither the database,
a backup nor a log can leak it, and no later call can return it. A lost secret is replaced, not
recovered: issue another and revoke the lost one. The hds_ prefix lets a secret scanner
recognise a leak (TH-33).
UC-54: List an Application’s Credentials
| Field | Value |
|---|
| ID | UC-54 |
| Name | List an Application’s Credentials |
| Actors | System Admin, Scope Admin |
| Description | Read an application’s credentials, newest first, via GET /api/scopes/{scopeId}/applications/{id}/credentials. Each carries id, createdAt, expiresAt, revokedAt, lastUsedAt and live; never the secret or its hash |
| Preconditions | As UC-53: the actor may manage the application, which is live in a live scope |
| Postconditions | None; the use case reads, and the read itself is not audited |
Main Flow:
sequenceDiagram
actor A as System Admin / Scope Admin
participant API as Heimdall API
participant DB as Database
A->>API: GET /api/scopes/{scopeId}/applications/{id}/credentials
API->>DB: Find the live application in the live scope
API->>API: Confirm the actor may manage it
API->>DB: Read the application's credentials, newest first
API-->>A: 200 OK — the credentials- A System Admin or a Scope Admin asks for an application’s credentials (FR-AP-12).
- The system resolves the application and checks the actor may manage it, exactly as UC-53 does, not-found before authorization.
- The system answers
200 OK with every credential, newest first, each with whether it is live now (not revoked, and not expired). The list is not paginated: at most two credentials are live, and spent ones are purged (Data Retention Schedule §5).
Alternative Flows:
| ID | Condition | Outcome |
|---|
| AF-54a | As AF-53a | 404 Not Found — “Application not found.” |
| AF-54b | As AF-53b | 403 Forbidden — “You are not allowed to manage this application’s credentials.”; a User 403, an anonymous caller 401 |
| AF-54c | The application has no credentials | 200 OK with an empty list |
UC-55: Revoke an Application Credential
| Field | Value |
|---|
| ID | UC-55 |
| Name | Revoke an Application Credential |
| Actors | System Admin, Scope Admin |
| Description | Withdraw one credential via DELETE /api/scopes/{scopeId}/applications/{id}/credentials/{credentialId} |
| Preconditions | As UC-53: the actor may manage the application, which is live in a live scope; the credential belongs to the application and is not yet revoked |
| Postconditions | The credential’s RevokedAt is set. UC-56 refuses its secret, and UC-57 refuses every token already issued from it. The revocation is audited with the credential as target |
Main Flow:
sequenceDiagram
actor A as System Admin / Scope Admin
participant API as Heimdall API
participant DB as Database
A->>API: DELETE /api/scopes/{scopeId}/applications/{id}/credentials/{credentialId}
API->>DB: Find the live application in the live scope
API->>API: Confirm the actor may manage it
API->>DB: Find the credential on this application
API->>DB: Set RevokedAt
API-->>A: 200 OK — id, revokedAt- A System Admin or a Scope Admin asks to revoke a credential of an application (FR-AP-12).
- The system resolves the application and checks the actor may manage it, as UC-53 does.
- The system finds the credential on that application; one that belongs to another application is not found.
- The system sets
RevokedAt and answers 200 OK with the credential’s id and revokedAt (FR-AP-16, NFR-09).
Alternative Flows:
| ID | Condition | Outcome |
|---|
| AF-55a | As AF-53a | 404 Not Found — “Application not found.” |
| AF-55b | As AF-53b | 403 Forbidden — “You are not allowed to manage this application’s credentials.”; a User 403, an anonymous caller 401 |
| AF-55c | No credential with that id on this application | 404 Not Found — “Application credential not found.” |
| AF-55d | The credential is already revoked | 409 Conflict — “This application credential is already revoked.” |
Expired credentials. A credential that has expired may still be revoked: it is stored until the
retention purge, and revoking it only records the decision.
Revocation is immediate. It does not wait for the tokens already issued from the credential
to expire; they fail UC-57’s live check at once (FR-AP-16).
UC-56: Obtain an Application Token
| Field | Value |
|---|
| ID | UC-56 |
| Name | Obtain an Application Token |
| Actors | Application (anonymous; the secret authenticates) |
| Description | Exchange a live credential’s secret for an application token via POST /api/applications/token, with the body applicationId, scopeId and secret |
| Preconditions | The credential is live (not revoked, not expired), belongs to the application, the application is live, and sits in the given live scope |
| Postconditions | A token has been issued; the credential’s LastUsedAt is stamped; the issue is audited with no actor and the application as target. The token is not stored |
Main Flow:
sequenceDiagram
participant App as Application
participant API as Heimdall API
participant DB as Database
App->>API: POST /api/applications/token (applicationId, scopeId, secret)
API->>API: Validate the fields
API->>DB: Find a live credential by the hash of the trimmed secret
API->>API: Confirm credential, application and scope match and are live
API->>API: Sign a token under the application audience
API->>DB: Stamp LastUsedAt, write the audit entry
API-->>App: 200 OK — token, expiresAt- The application sends its
applicationId, the scopeId and the secret. - The system validates their shape: each field is present and the secret is at most 100 characters.
- The system trims the secret (a secret read from a file often carries a trailing newline), hashes it and looks the hash up among live credentials.
- The system confirms the credential belongs to that application, the application to that scope, and that the application and scope are live.
- The system issues a token signed like person tokens (HMAC-SHA256, the same keys) but under
HEIMDALL_APPLICATION_TOKEN_AUDIENCE, valid for HEIMDALL_APPLICATION_TOKEN_EXPIRATION_IN_SECONDS (default 600), with the claims tokenKind, id, scopeId, credentialId and name and no role (FR-AP-13). - The system stamps the credential’s
LastUsedAt, audits the issue (no actor; the application as target), and answers 200 OK with id, token and expiresAt.
Alternative Flows:
| ID | Condition | Outcome |
|---|
| AF-56a | A field is missing, the secret is over 100 characters, or the body cannot be read (not JSON, or an id that is not a GUID) | 400 Bad Request — the field’s message (“Application id is required.”, “Scope id is required.”, “Secret is required.”, “Secret must be at most 100 characters.”) or, for a body that cannot be read, “The request is malformed.” (answered before the use case runs, NFR-10) |
| AF-56b | Anything else is wrong: an unknown, revoked or expired secret; another application’s secret; the application or scope deleted; the wrong scope | 401 Unauthorized — “The application credentials were not accepted.”, always the same |
One refusal. As for UC-11, every refusal of the exchange is the same 401 with the same
message, whatever failed, so the endpoint reveals nothing about which applications, scopes or
credentials exist (FR-AP-14). Only a malformed request is told apart, and it learns nothing about
any credential.
Rate limit. The endpoint shares the AuthAnonymous limit with person sign-in, per client
address; TH-32’s trusted-caller key applies to it as to them.
UC-57: Check an Application Token
| Field | Value |
|---|
| ID | UC-57 |
| Name | Check an Application Token |
| Actors | Consumer service, holding the application’s token |
| Description | Confirm, live, that an application token is valid and that the application, its scope and the credential it came from are all still live, via GET /api/applications/self with Authorization: Bearer <token> |
| Preconditions | A token obtained through UC-56, still within its lifetime |
| Postconditions | None; the use case reads, and the read itself is not audited |
Main Flow:
sequenceDiagram
participant C as Consumer service
participant API as Heimdall API
participant DB as Database
C->>API: GET /api/applications/self (Authorization: Bearer token)
API->>API: Validate the token
API->>DB: Is the credential live, the application and scope live and matching?
API-->>C: 200 OK — applicationId, name, scopeId, credentialId, expiresAt- The system validates the token: signature against the current and previous keys, HS256 only, the issuer (when one is configured), the application audience, the lifetime with zero clock skew, and
tokenKind = application. - The system confirms the credential named in the token is live, and that the application and scope it names are live and match the token (FR-AP-15).
- The system answers
200 OK with applicationId, name, scopeId, credentialId and expiresAt.
Alternative Flows:
| ID | Condition | Outcome |
|---|
| AF-57a | No or malformed token; wrong signature, issuer, audience or kind; expired; the application, scope or credential no longer live; or the scope not the token’s | 401 Unauthorized — “The application token was not accepted.”, always the same |
Revocation policy (FR-AP-16). Revoking or expiring a credential, or deleting the application
or its scope, takes effect at the next check for every token already issued. A token’s exp is
fixed at issue, so a consumer validating it offline alone would accept it for up to its whole
lifetime after its credential expires or is revoked. Only this check reflects that at once, which
is why consumers re-check here before every sensitive release.
Clock skew. Tokens are validated with zero skew. Instances whose clocks differ can refuse a
freshly issued token for the length of the drift; keep them synchronised (NTP).
3. Use Case — Requirements Traceability
| Use Case | Requirements Covered |
|---|
| UC-01: Create Scope | FR-SC-01, FR-SC-08 |
| UC-02: View Scope | FR-SC-02, FR-SC-03, FR-SC-07 |
| UC-03: Update Scope | FR-SC-04 |
| UC-04: Logical Delete Scope | FR-SC-05, FR-SC-07 |
| UC-05: Hard Delete Scope | FR-SC-06 |
| UC-06: Create Person | FR-PE-01, FR-PE-02, FR-PE-09, FR-PE-10, FR-PE-11, FR-RO-01, FR-RO-02, FR-RO-03, FR-SC-12, FR-EV-01, FR-EV-02 |
| UC-07: View Person | FR-PE-03, FR-PE-04, FR-PE-08, FR-PE-12, FR-PE-13, FR-PE-19 |
| UC-08: Update Person | FR-PE-05, FR-PE-14, FR-PE-15, FR-PE-17, FR-PE-19, FR-AU-11, FR-AU-13, FR-RO-02, FR-RO-03, FR-RO-05 |
| UC-09: Logical Delete Person | FR-PE-06, FR-PE-08 |
| UC-10: Hard Delete Person | FR-PE-07 |
| UC-11: Login | FR-AU-01, FR-AU-02, FR-AU-03, FR-AU-04, FR-AU-05, FR-AU-06, FR-AU-07, FR-AU-09, FR-AU-11, FR-2F-07, NFR-27 |
| UC-12: Password Recovery | FR-PR-01, FR-PR-02 |
| UC-13: Reset Password | FR-PR-03, FR-PR-04, FR-PR-08, FR-PE-17, FR-AU-11, NFR-24, NFR-27 |
| UC-14: Email Verification | FR-EV-03, FR-EV-08, FR-PE-17, NFR-24, NFR-27 |
| UC-15: Resend Verification Email | FR-EV-04 |
| UC-16: Create Application | FR-AP-01, FR-AP-02, FR-AP-03 |
| UC-17: View Application | FR-AP-04, FR-AP-05, FR-AP-09 |
| UC-18: Update Application | FR-AP-06 |
| UC-19: Logical Delete Application | FR-AP-07, FR-AP-09 |
| UC-20: Hard Delete Application | FR-AP-08 |
| UC-21: Add Scope Owner | FR-SC-08, FR-SC-09 |
| UC-22: Remove Scope Owner | FR-SC-08, FR-SC-10 |
| UC-23: Promote User to Scope Owner | FR-SC-08, FR-SC-13, FR-RO-03 |
| UC-24: Enable/Disable Google Sign-In | FR-GO-01, FR-GO-02 |
| UC-25: Sign Up / Sign In via Google | FR-GO-03 through FR-GO-11, FR-GO-19, FR-GO-20 |
| UC-26: Sign Out via Google | FR-GO-18 |
| UC-27: View Google User | FR-GO-14, FR-GO-17 |
| UC-28: Logical Delete Google User | FR-GO-15, FR-GO-17 |
| UC-29: Hard Delete Google User | FR-GO-16 |
| UC-31: Create Scope Permission | FR-SP-01, FR-SP-02, FR-SP-03 |
| UC-32: View Scope Permission | FR-SP-04, FR-SP-05, FR-SP-09 |
| UC-33: Update Scope Permission | FR-SP-06 |
| UC-34: Logical Delete Scope Permission | FR-SP-07, FR-SP-09 |
| UC-35: Hard Delete Scope Permission | FR-SP-08 |
| UC-36: Enable Two-Factor Authentication | FR-2F-01, FR-2F-02, FR-2F-03, FR-2F-15, FR-2F-18, FR-AU-13 |
| UC-37: Confirm Two-Factor Authentication Setup | FR-2F-04, FR-2F-05, FR-2F-20, FR-AU-11, FR-AU-13 |
| UC-38: Verify Second Factor | FR-2F-06, FR-2F-08, FR-2F-09, FR-2F-10, FR-2F-17, FR-2F-20, FR-AU-11, FR-AU-12, NFR-27 |
| UC-39: Disable Two-Factor Authentication | FR-2F-11, FR-AU-11, FR-AU-13 |
| UC-40: Regenerate Recovery Codes | FR-2F-12, FR-2F-20, FR-AU-11, FR-AU-13, NFR-27 |
| UC-46: Resend Second-Factor Email Code | FR-2F-03, FR-2F-13, FR-2F-16, FR-AU-11, NFR-27 |
| UC-47: Register a Password User | FR-SR-01 through FR-SR-10, FR-EV-01, FR-EV-02, NFR-23, NFR-24 |
| UC-48: List the Audit Trail | FR-AT-01, FR-AT-02, NFR-09, NFR-21 |
| UC-49: List Pending Registrations | FR-SR-11 |
| UC-50: Discard a Pending Registration | FR-SR-12, NFR-09 |
| UC-51: Send a Verification Email on a Person’s Behalf | FR-EV-06, FR-EV-07, NFR-24 |
| UC-52: Send a Password Reset on a Person’s Behalf | FR-PR-05, FR-PR-06, NFR-24 |
| UC-53: Issue an Application Credential | FR-AP-10, FR-AP-11, FR-AP-12, NFR-09 |
| UC-54: List an Application’s Credentials | FR-AP-12 |
| UC-55: Revoke an Application Credential | FR-AP-12, FR-AP-16, NFR-09 |
| UC-56: Obtain an Application Token | FR-AP-13, FR-AP-14, NFR-09 |
| UC-57: Check an Application Token | FR-AP-15, FR-AP-16 |
4. State Diagrams
4.1 Person Lifecycle
stateDiagram-v2
[*] --> Created: UC-06 Create Person
Created --> Active: UC-14 Email Verified
Created --> LogicallyDeleted: UC-09 Logical Delete
Active --> LogicallyDeleted: UC-09 Logical Delete
Active --> Active: UC-08 Update Person
LogicallyDeleted --> Active: Restore (set IsDeleted = false)
LogicallyDeleted --> [*]: UC-10 Hard Delete
Active --> [*]: UC-10 Hard Delete
Created --> [*]: UC-10 Hard Delete4.2 Scope Lifecycle
stateDiagram-v2
[*] --> Active: UC-01 Create Scope
Active --> Active: UC-03 Update Scope
Active --> LogicallyDeleted: UC-04 Logical Delete
LogicallyDeleted --> Active: Restore (set IsDeleted = false)
LogicallyDeleted --> [*]: UC-05 Hard Delete
Active --> [*]: UC-05 Hard DeleteNote: Ownership (SCOPE_OWNER rows, added/removed via UC-21/UC-22) can change at any time while a scope is Active, independently of this lifecycle.
4.3 Application Lifecycle
stateDiagram-v2
[*] --> Active: UC-16 Create Application
Active --> Active: UC-18 Update Application
Active --> LogicallyDeleted: UC-19 Logical Delete
LogicallyDeleted --> Active: Restore (set IsDeleted = false)
LogicallyDeleted --> [*]: UC-20 Hard Delete
Active --> [*]: UC-20 Hard Delete4.4 Google User Lifecycle
stateDiagram-v2
[*] --> Active: UC-25 Sign Up via Google (first sign-in)
Active --> Active: UC-25 Sign In via Google (subsequent sign-ins)
Active --> LogicallyDeleted: UC-28 Logical Delete
LogicallyDeleted --> Active: Restore (set IsDeleted = false)
LogicallyDeleted --> [*]: UC-29 Hard Delete
Active --> [*]: UC-29 Hard DeleteNote: A Google User has no Created/unverified-email intermediate state — Google has already verified the account before the ID token is issued, so the record is Active immediately upon sign-up.
4.5 Scope Permission Lifecycle
stateDiagram-v2
[*] --> Active: UC-31 Create Scope Permission
Active --> Active: UC-33 Update Scope Permission
Active --> LogicallyDeleted: UC-34 Logical Delete
LogicallyDeleted --> Active: Restore (set IsDeleted = false)
LogicallyDeleted --> [*]: UC-35 Hard Delete
Active --> [*]: UC-35 Hard DeleteNote: Logically deleting a scope (UC-04) does not cascade to its scope permissions — they keep whatever IsDeleted state they had. They become unreachable through the listing endpoint (which gates on the scope’s deletion) and are excluded from the JWT-claim fold at login, but are not purged; a restored scope recovers its permission set unchanged. Hard-deleting a scope (UC-05) does purge its scope permissions, via the scope_permission → scope foreign key’s ON DELETE CASCADE.
4.6 Two-Factor Authentication Lifecycle
stateDiagram-v2
[*] --> Pending: UC-36 Enable (Initiate)
Pending --> Pending: UC-36 Re-initiate (change method selection)
Pending --> Active: UC-37 Confirm Setup
Active --> Active: UC-40 Regenerate Recovery Codes
Pending --> [*]: UC-39 Disable (n/a — nothing to disable until Active)
Active --> [*]: UC-39 DisableNote: There is no Pending → [*] transition through UC-39 in practice — UC-39’s precondition requires IsActive = true, so an abandoned Pending configuration is only ever replaced (UC-36 re-initiation) or superseded by hard-deleting the person (UC-10), never explicitly disabled. UC-38 (Verify Second Factor) does not appear on this diagram — it acts on a login attempt, not on the two-factor configuration’s own state.
4 - Technology Stack Document
The technologies, libraries, and pinned versions the project is built on.
Technology Stack Document — Heimdall API
1. Purpose
This document is the single source of truth for the technologies used to build the Heimdall API — the runtime platform, language, first-party libraries, database, data-access stack, cross-cutting concerns, and testing tools, together with the version each is pinned to and the role it plays.
Every other document in this folder references this document for technical choices instead of restating them, so that:
Rule: when a technology choice changes, it changes here first. Other documents link to this one rather than duplicating the detail.
| Concern | Choice | Notes |
|---|
| Runtime / framework | .NET 10 (net10.0) | Every project targets net10.0. The Web API uses the Microsoft.NET.Sdk.Web SDK; libraries use Microsoft.NET.Sdk. |
| Language | C# 14 | C# 14 is the default language version for net10.0, so it is used implicitly — no explicit <LangVersion> is set, which keeps the language pinned to the target framework’s default. |
| Language features | Nullable enabled, ImplicitUsings enabled | Applied consistently across all production and test projects. |
Why explicit: C# 14 ships with and is the default for .NET 10. New code may freely use C# 14 features (collection expressions, primary constructors — already used e.g. in Startup, etc.). Do not pin an older <LangVersion>; let it track the .NET 10 default.
3. First-Party Libraries (ArturRios.*)
The project is built on a set of the author’s own reusable libraries, all published under the ArturRios package prefix. They are consumed as NuGet PackageReferences. The table lists every one currently in use, its pinned version, where it is referenced, and its role.
| Package | Version | Referenced by | Role |
|---|
| ArturRios.Util | 2.1.0 | Command, Domain, Shared, Data | Core cross-cutting utilities. Provides the standard DataOutput<T> result type (namespace ArturRios.Output) that handlers return, password hashing helpers (ArturRios.Util.Hashing), HTTP helpers (ArturRios.Util.Http), cryptographically strong random text (ArturRios.Util.Random.CustomRandom, used to mint the verification and password reset tokens), and general-purpose helpers used throughout the codebase. 1.5.0 is a floor, not a preference: earlier versions ignored CustomRandom.Text’s character-set flags for all but the first character of each set and drew from System.Random rather than RandomNumberGenerator, which made the security tokens neither URL-safe nor cryptographically sourced. |
| ArturRios.Util.WebApi | 5.1.0 | WebApi | Web API foundation. Supplies the WebApiStartup base class — a fixed standard sequence (configuration, controllers, Swagger, then forwarded headers, tracing, exception handling, CORS and authentication in the pipeline) that Startup completes in ConfigureServices — environment/configuration loading (ArturRios.Util.WebApi.Configuration), the security stack (role attributes/enums/extensions/middleware — e.g. [AllowAnonymous], role requirements), JWT issuance & validation (namespace ArturRios.Jwt), exception & authentication middleware, Swagger wiring, and the ToActionResult extensions that map a DataOutput<T> to an HTTP response. 5.x requires ArturRios.Util 2.1.0 and Google.Apis.Auth 1.76.0, which is why both are pinned there. |
| ArturRios.Jwt | 1.3.0 | WebApi | Token signing and signature validation (JwtHandler, JwtConfiguration, JwtKey). Arrives transitively through ArturRios.Util.WebApi, which asks for 1.2.0, and is declared on the WebApi project, which uses it directly. 1.3.0 is a floor: it accepts HS256 only, the one algorithm tokens are signed with. It checks the signature and lifetime but not the issuer or the audience, which the API checks itself (NFR-30). |
| ArturRios.Mediator | 2.0.0 | Command, Query, WebApi | Lightweight CQRS mediator. Provides CommandMediator / QueryMediator and the handler contracts (ICommandHandlerAsync, IQueryHandlerAsync, IPaginatedQueryHandlerAsync) that dispatch a command/query to its single handler. |
| ArturRios.Data.Relational.Core | 5.0.0 | Command, Query, Domain | Provider-agnostic relational data layer. Provides entity base types, the repository abstractions the handlers depend on (IAsyncRepository<T, long>, IAsyncReadOnlyRepository<T, long>), the EF Core DbContext base plus diagnostics options, and the DI entry point AddDataConfigFromEnvironment<TDbContext>(prefix) that binds the context to a connection from the environment. Entities derive from Entity<long>, and the repository contracts carry the key type. |
| ArturRios.Data.PostgreSql | 3.0.2 | Data | PostgreSQL binding for the relational core. Provides AddPostgreSqlProvider(), which wires EF Core to Npgsql so the relational layer runs against PostgreSQL. |
| ArturRios.Messaging | 1.3.0 | WebApi | Outbound email. Provides IEmailService and MailgunEmailService (namespace ArturRios.Messaging.Email), which sends plain-text transactional mail through the Mailgun HTTP API. Used by UC-06’s verification email and UC-12’s password reset email. Reads MAILGUN_API_KEY, MAILGUN_DOMAIN, and the optional MAILGUN_API_VERSION from the environment at call time. |
| ArturRios.Util.Test | 5.0.0 | all test projects | Testing toolkit (see §7). Provides the category test attributes ([UnitFact]/[UnitTheory], [FunctionalFact]/[FunctionalTheory]), the WebApiTest<TEntryPoint> functional base class, FakeRepository<T, long>, AsyncFakeRepository<T, long> (async repository fake with an async-capable Query()), FakeScheduler, and CustomAssert. |
ArturRios.Output is a namespace surfaced by the ArturRios.Util family, not a separately referenced package.
4. Relational Database
| Concern | Choice |
|---|
| Relational database | PostgreSQL — the sole supported relational database for the project. |
| Provider integration | ArturRios.Data.PostgreSql → AddPostgreSqlProvider() (EF Core over Npgsql). |
| Connection configuration | Read from environment variables HEIMDALL_DATA_CONNECTIONSTRING and HEIMDALL_DATA_DATABASETYPE (PostgreSql) via AddDataConfigFromEnvironment<AppDbContext>("HEIMDALL_DATA"). |
PostgreSQL is used in every environment, including automated tests — functional tests run against a real PostgreSQL instance provisioned by Testcontainers (never an in-memory provider), so behavior matches production. There is no secondary/alternate relational engine.
5. Data Layer (Entity Framework Core)
| Concern | Choice | Version |
|---|
| ORM | Entity Framework Core (code-first) | 10.x |
| Design-time / migrations | Microsoft.EntityFrameworkCore.Design (enables dotnet ef; the Data library is its own startup project) | 10.0.12 |
| Naming convention | EFCore.NamingConventions — maps entities to snake_case, singular table/column names | 10.0.1 |
| Context | AppDbContext, based on the ArturRios.Data.Relational.Core context base, configured via entity maps (ArturRios.Heimdall.Data.EntityMaps) | — |
| Diagnostics | DbContextDiagnosticsOptions — sensitive-data logging & detailed errors are on only outside Production (they would expose password hashes, salts, e-mails) | — |
| Startup seeding | DatabaseSeeder seeds the roles and the master System Admin on startup | — |
The data access pattern is repository-based: application handlers depend on IAsyncReadOnlyRepository<T, long> / IAsyncRepository<T, long> (from ArturRios.Data.Relational.Core) rather than on DbContext directly, which is also what makes them unit-testable with FakeRepository<T, long>.
6. Cross-Cutting Technologies
| Concern | Technology | Version | How it is used |
|---|
| Input validation | FluentValidation | 12.1.1 | Command inputs have IValidator<TCommand> implementations (e.g. CreateScopeCommandValidator), registered in DI and invoked inside the handlers. |
| Logging | Serilog (Serilog, Serilog.AspNetCore) | 4.4.0 / 10.0.0 | Structured logging wired through Host.UseSerilog(), with JSON formatting; the log directory and the retention period are configurable via environment variables (NFR-22). |
| Authentication / authorization | JWT via ArturRios.Util.WebApi and ArturRios.Jwt | (see §3) | Signed bearer tokens; issuer, audience, secret, and expiration are supplied via HEIMDALL_AUTH_* environment variables. Role-based authorization and an AuthenticationMiddleware gate the endpoints. |
| Google ID token verification | Google.Apis.Auth | 1.77.0 | UC-25’s GoogleIdTokenVerifier calls GoogleJsonWebSignature.ValidateAsync to check an incoming ID token’s signature, issuer, audience, and expiration (FR-GO-11, NFR-13) before its claims are trusted. Arrives transitively through ArturRios.Util.WebApi and is declared explicitly on the WebApi project because that project uses its types directly. Why not the library’s own verifier: ArturRios.Util.WebApi ships IGoogleTokenVerifier, but its GoogleTokenPayload carries only sub, email, and email_verified — it cannot supply the name and picture claims a Google User is populated from (FR-GO-05), so UC-25 declares IGoogleIdTokenVerifier in the application layer and implements it here. |
| Local token validation | Microsoft.IdentityModel.JsonWebTokens | 8.23.0 | JsonWebTokenHandler, used by UC-25’s LocalGoogleIdTokenVerifier — the functional suite’s stand-in for Google, which validates locally signed ID tokens (see the Testing Specification §10). Also transitive, via ArturRios.Jwt; the explicit reference must match the version that package resolves, since a lower one fails restore with NU1605. |
| Result / error model | DataOutput<T> (namespace ArturRios.Output, from the ArturRios.Util family) | (see §3) | Handlers return success/errors/messages/data on a DataOutput<T> instead of throwing; ToActionResult maps it to an HTTP response. |
| API documentation | Swagger / OpenAPI (via ArturRios.Util.WebApi) | — | Generated and served by the standard sequence in the environments it allows, described by SwaggerConfiguration (options.Swagger.ConfigureGenerator), which also defines the bearer scheme. |
| Outbound email | Mailgun via ArturRios.Messaging | (see §3) | Verification (UC-06) and password reset (UC-12) emails. Startup.AddEmailSenders registers the Mailgun-backed senders only when MAILGUN_API_KEY and MAILGUN_DOMAIN are both set, and logging senders otherwise — so local runs and the functional suite work without credentials and never reach the network. Delivery failures are logged, never thrown: an anonymous caller’s response must not vary with whether the mail went out. |
| Metrics | OpenTelemetry (OpenTelemetry.Extensions.Hosting, .Instrumentation.AspNetCore, .Instrumentation.Http, .Exporter.Prometheus.AspNetCore) | 1.19.1 (instrumentation 1.19.0) / exporter 1.19.1-beta.1 | ASP.NET Core, HttpClient, System.Runtime, Kestrel, EF Core and Npgsql meters, served for Prometheus at /metrics on the private port HEIMDALL_METRICS_PORT (9464) only. The exporter has never had a stable release; it is accepted as a beta because it only formats what the stable SDK collected, on a port that is never published. |
| Configuration | .env.<environment> files + environment variables | — | Loaded by the ArturRios.Util.WebApi configuration loader; .env* files are copied next to the built assembly. |
7. Testing Technologies
These are the technologies mandated for tests. How they are applied to each use case (naming, structure, coverage, the per-use-case workflow) is defined in the Testing Specification Document
; this section is the canonical list of the tools and versions.
| Concern | Technology | Version | How it is used |
|---|
| Test framework | xUnit v3 (xunit.v3.mtp-off, xunit.runner.visualstudio) | 4.0.2 / 4.0.1 | The test framework for every test project. The mtp-off flavour runs through VSTest, so dotnet test, its --filter and the .trx logger work as before. |
| Test SDK / runner | Microsoft.NET.Test.Sdk | 18.10.1 | Test host/runner integration for dotnet test and IDEs. |
| Coverage | coverlet.collector | 10.1.0 | Collects code coverage during test runs. |
| Test helpers & doubles | ArturRios.Util.Test | 5.0.0 | Category attributes ([UnitFact]/[FunctionalFact], which stamp a Category trait), the WebApiTest<TEntryPoint> functional base class (spins up the host via WebApplicationFactory<T> and exposes an HttpGateway + authentication helpers), FakeRepository<T, long>, AsyncFakeRepository<T, long> (async repository fake whose Query() is async-capable, for unit-testing handlers that depend on IAsyncReadOnlyRepository<T, long>/IAsyncRepository<T, long>), FakeScheduler, and CustomAssert. |
| Mocking | Moq | 4.21.0 | The single mocking library for stubbing non-repository collaborators (validators, mediators, services). Do not introduce a second mocking framework. |
| Test data generation | Bogus | 35.6.5 | The standard way to generate entities/commands/DTOs (Faker<T>) instead of large inline literals or shared fixtures. |
| Functional database | Testcontainers (Testcontainers.PostgreSql) | 4.16.0 | Provisions a real, throwaway PostgreSQL container for functional (end-to-end) tests, so tests run against the same engine as production. |
Note: Moq and Bogus were pinned to the versions above when the first tests needing them were written (UC-03’s handler unit tests). Keep every test project on the same versions.
Tests are split by category — unit tests exercise Command/Query handlers and Domain behavior in isolation; functional tests exercise the Web API end-to-end against Testcontainers PostgreSQL. Run one kind at a time with dotnet test --filter "Category=Unit" or "Category=Functional".
8. Version Summary
| Category | Package / Tool | Version |
|---|
| Platform | .NET | 10 (net10.0) |
| Language | C# | 14 (framework default) |
| First-party | ArturRios.Util | 2.1.0 |
| First-party | ArturRios.Util.WebApi | 5.1.0 |
| First-party | ArturRios.Messaging | 1.3.0 |
| First-party | ArturRios.Util.Test | 5.0.0 |
| First-party | ArturRios.Mediator | 2.0.0 |
| First-party | ArturRios.Data.Relational.Core | 5.0.0 |
| First-party | ArturRios.Data.PostgreSql | 3.0.2 |
| Data | Microsoft.EntityFrameworkCore.Design | 10.0.12 |
| Data | EFCore.NamingConventions | 10.0.1 |
| Authentication | Google.Apis.Auth | 1.77.0 |
| Authentication | Microsoft.IdentityModel.JsonWebTokens | 8.23.0 |
| Validation | FluentValidation | 12.1.1 |
| Logging | Serilog | 4.4.0 |
| Logging | Serilog.AspNetCore | 10.0.0 |
| Metrics | OpenTelemetry.Extensions.Hosting | 1.19.1 |
| Metrics | OpenTelemetry.Instrumentation.AspNetCore | 1.19.0 |
| Metrics | OpenTelemetry.Instrumentation.Http | 1.19.0 |
| Metrics | OpenTelemetry.Exporter.Prometheus.AspNetCore | 1.19.1-beta.1 |
| Testing | xunit.v3.mtp-off | 4.0.2 |
| Testing | xunit.runner.visualstudio | 4.0.1 |
| Testing | Microsoft.NET.Test.Sdk | 18.10.1 |
| Testing | coverlet.collector | 10.1.0 |
| Testing | Testcontainers.PostgreSql | 4.16.0 |
| Testing | Moq | 4.21.0 |
| Testing | Bogus | 35.6.5 |
5 - Data Protection Impact Assessment
The risks this system creates for the people in it, and what is done about them — GDPR Art. 35, LGPD Art. 38.
Data Protection Impact Assessment — Heimdall API
Version 1.3 — 10 September 2026 · Assessed by Artur Rios, controller and Encarregado
| Version | Date | Change |
|---|
| 1.3 | 10 September 2026 | Backup location and transfer ground established (MEGA — §7.3 of the Data Protection Document). R-08 re-rated Medium → Low: the signals are now collected and mailed. No open actions remain |
| 1.2 | 10 September 2026 | GDPR confirmed to apply. R-01 re-rated Medium → Low on confirmation of encryption at rest, for the volume and the backups. EEA hosting resolved: direct sign-up is not a Chapter V transfer, and an EEA tenant’s is covered by SCCs now incorporated in the DPA |
| 1.1 | 10 September 2026 | R-07 re-rated High → Low: the Google transfer was found not to exist, and the email transfer’s ground established under LGPD Art. 33 IX |
| 1.0 | 9 September 2026 | First assessment |
1. Why this exists, and why the Threat Model is not it
GDPR Art. 35 requires a DPIA before processing likely to result in a high risk to data subjects,
expressly including large-scale processing and systematic monitoring. LGPD Art. 38 gives the ANPD
power to require a Relatório de Impacto à Proteção de Dados, and its guidance treats identity
platforms serving multiple organisations as within scope.
This repository already has a good Threat Model
, and it is the wrong
instrument for this obligation. The difference is not depth but direction:
| Threat Model | This document |
|---|
| Asks | What can an attacker do to the system? | What can the system do to a person? |
| Organised by | Trust boundary | Risk to a data subject |
| A control working as designed is | Not a threat | Sometimes exactly the risk |
That last row is the reason both are needed. A lockout is a control functioning correctly and a
denial of a person’s access to every system they use; the threat model is right not to list it, and
this document would be negligent to omit it.
2. The processing, described
Heimdall is a centralised identity provider. One database holds the credentials, addresses,
second-factor secrets and complete action history of every user of every client system that adopts
it. Each Scope is a tenant, usually the controller for its own users, with Heimdall as processor
(Data Protection Document §3).
The concentration is the product. The vision document offers a single source of truth for
identity across many systems, and that is genuinely valuable — and it is also precisely what makes
the risk high. A compromise here is not a compromise of one product; it is a compromise of every
tenant at once, and of every person in them.
The full description — categories of subject, categories of data, purposes, legal bases, recipients,
transfers, retention — is the
Data Protection Document
, and is not repeated here. What follows
assesses it.
3. Necessity and proportionality
| Test | Assessment |
|---|
| Lawful basis | Contract performance for identity and credentials; legitimate interests for security data; legal obligation for erasure records. Recorded per identity since NFR-23. Consent is used for nothing and is not selectable, because it would be withdrawable and an identity provider that stopped verifying passwords on withdrawal would be broken rather than compliant |
| Purpose limitation | Every column has a stated purpose (Data Protection Document §5.1), enforced by a test that fails the build when a new one has none |
| Data minimisation | Reviewed. One field — GoogleUser.ProfilePictureUrl — is recorded as the weakest justification in the system and marked for removal if no client reads it |
| Storage limitation | Every category has a period; all but the backup regime are enforced by scheduled passes |
| Alternatives considered | Federating to each tenant’s own directory would remove the concentration and the product with it. Not holding password hashes would mean not being an identity provider. The concentration is inherent to the purpose rather than incidental to the design |
Proportionate, on the condition that the risks in §4 are held where they are. The assessment is not
“this is safe” but “this is worth doing, given these controls, and would not be without them.”
4. Risks to data subjects
Rated for the person, not the system. Likelihood and severity are the assessor’s judgement.
R-01 · One compromise exposes every tenant’s users at once
| |
|---|
| Risk to the person | Credentials, addresses and a full action history disclosed. Because the same identity spans every client system, a single disclosure follows them everywhere they use it |
| Severity | High · Likelihood Low · Residual Low |
Controls: Argon2id with per-person salts, TOTP secrets encrypted at rest, tokens stored as digests,
scope isolation re-read per request, PublicId values only across the boundary — and, confirmed by
the controller on 10 September 2026, encryption at rest for the database volume. That was the
gap that held this at Medium: everything not protected at the application level sits in clear text
within the database, and only the volume beneath it stands between a stolen disk and all of it.
Backup encryption was confirmed separately on the same date, so the copy of the database that lives
outside the server is protected too.
Resolved, 10 September 2026. The service is MEGA, and the copy is encrypted on this side before
MEGA receives it — MEGA holds no key that decrypts it. The backups do leave Brazil, which is a
transfer rather than a security question and is grounded in
§7.3 of the Data Protection Document
.
What that leaves is not a gap in this risk but a shift in where it lives: the whole argument that a
full copy of the database is safe outside the country rests on the MEGA account credentials, because
the keys are derived from the account password. That is now an operational control rather than an
open question, recorded in
§0.1 of the Operations document
— unique password,
two-factor authentication, held in the same custody as the production database credentials.
This is the risk the product’s value creates. It cannot be designed out without abandoning the
purpose, which is why the controls around it carry more weight than they would elsewhere.
R-02 · Lockout denies a person access to everything at once
| |
|---|
| Risk to the person | Ten wrong passwords locks the account for fifteen minutes — and with it every client system that authenticates through it. Somebody targeted, or simply unlucky, loses access to their working life rather than to one product |
| Severity | Medium · Likelihood Medium · Residual Medium |
Controls: the window is short and self-clearing; the per-IP limiter makes targeting expensive;
LOCKOUT_SPIKE (NFR-26) now surfaces mass lockout.
This is a control working exactly as designed, and it is still a risk to the person. The threat
model is right not to list it — it is not something an attacker does to the system — and that is
the clearest illustration of why this document exists. Accepted: the alternative is weakening the
brute-force defence, which harms subjects more.
R-03 · The audit trail is a behavioural record
| |
|---|
| Risk to the person | Every action they take, timestamped, in an append-only table nothing could remove from. Over years that is a detailed record of when a person works, from where, and how often |
| Severity | Medium · Likelihood High (it is inherent) · Residual Low |
Controls: attribution cleared at 18 months, or immediately on erasure (NFR-21), enforced by a
database trigger the application cannot talk its way around. A target naming a person or Google User
is cleared on the same grounds (#184), so an entry about a person — their own self-service command,
or an administrator’s action on them — stops naming them too. Once both are cleared the entry relates
to no identifiable person.
Consequence recorded rather than discovered: an active person’s entries are pseudonymised at 18
months while they are still active, capping how far back a current account can be investigated. That
is the intended trade-off.
R-04 · Erasure that does not erase
| |
|---|
| Risk to the person | Asking to be forgotten and not being. Before this year’s work, logical deletion kept name, address and password hash indefinitely, and nothing purged them |
| Severity | High · Likelihood Low · Residual Low |
Controls: anonymisation on a schedule (NFR-20), self-service request (UC-42), audit attribution
cleared with it (NFR-21), restore reconciled against the erasure ledger (NFR-25). Gap: the
reconciliation is implemented and tested but has never run against a real restore; the workflow’s
quarterly review exists to change that.
R-05 · A tenant administrator sees more than the relationship warrants
| |
|---|
| Risk to the person | A Scope Admin reads the identities in their scope. Where the tenant is an employer, that is an employment relationship with an inherent power imbalance |
| Severity | Medium · Likelihood Medium · Residual Medium |
Controls: scope isolation; minimal projections in listings; erasure requests visible only to a System
Admin, so an administrator does not learn which of their users asked to leave; restricted identities
withheld from tenant listings (NFR-24).
Not fully mitigable here. What a tenant may do with its own users is the tenant’s lawfulness to
establish, which is what the DPA
exists to bind.
R-06 · The subject cannot exercise rights without the controller’s cooperation
| |
|---|
| Risk to the person | Rights that exist on paper and require an administrator’s goodwill in practice |
| Severity | Medium · Likelihood Was High · Residual Low |
Controls: access and portability (UC-41), erasure (UC-42), restriction and objection (UC-44), all
self-service and none requiring an administrator. Blocked erasures are surfaced with deadlines
(UC-43) rather than sitting unnoticed.
R-07 · Data crosses borders
| |
|---|
| Risk to the person | Their address reaches the United States on every transactional email, into a jurisdiction whose protections differ from Brazil’s |
| Severity | Medium · Likelihood High (it happens on every send) · Residual Low |
Controls: LGPD Art. 33 IX resting on Art. 7 V — the transfer is necessary to perform the service
the person is party to, since the email that activates or restores their account is the service
being performed. Where GDPR applies, Sinch’s Data Processing Agreement incorporates the EU Standard
Contractual Clauses automatically, with no separate signature.
This was the highest residual risk in version 1.0, rated High, and it has come down for two
reasons — one a correction and one a decision.
The correction: Google was recorded as a second transfer and is not one. The ID token is validated
offline against cached public certificates and is never sent to Google, so there is nothing to
transfer. Google is a source of personal data. That removed half the exposure by establishing it
had never existed.
The decision: the controller declined to pursue new contractual arrangements, which closed the Art.
33 II route through the ANPD’s own clauses. Art. 33 IX is a self-standing statutory ground requiring
no counterparty agreement, and it rests on the same Art. 7 V basis §5 of the record already records
for the underlying processing — which is what makes it coherent rather than a ground found for the
occasion.
Residual is Low rather than absent because Art. 33 IX is a necessity test, and necessity is a
judgement that could be read more narrowly than it is here. If the ANPD recognises the EU SCCs as
equivalent, Art. 33 II becomes available through Sinch’s existing DPA with nothing to sign, and is
the stronger ground.
The EEA → Brazil question in R-07’s original framing remains open and is tracked separately: Brazil
holds no EU adequacy decision, so if any scope serves EEA subjects, the hosting is a restricted
transfer. See #124
.
R-08 · A breach nobody notices
| |
|---|
| Risk to the person | Not being told their data was exposed, because nobody realised. Both notification clocks run from awareness, and until NFR-26 nothing read the signals the system recorded |
| Severity | High · Likelihood Was Medium · Residual Low |
Controls: signal detection (NFR-26), collection and alerting (scripts/security_signals.py), the Incident Response Document
,
the Art. 33(5) register.
Closed, 10 September 2026. scripts/security_signals.py collects the signals on a schedule and
e-mails whatever is new to a named address, through the Mailgun account the API already uses. The
detection now reaches a person, which is what Art. 33’s clock needs it to do.
Two properties of the collector matter to this rating rather than to its operation. It keeps a
watermark, so each signal is reported once and the alert stays readable — an alert that resends
its whole history is one that gets filtered, and a filtered alert is the same non-detection in a
different costume. And it exits non-zero when it cannot deliver, printing the signals it was
holding to stderr, so a broken collector is itself noticed rather than looking like a quiet week.
Residual, and why it is not zero. Delivery depends on Mailgun and on somebody reading the
mailbox. The first is visible — a failure exits non-zero where cron reports it. The second is not:
nothing here can tell whether the message was opened. That is a control the controller operates, not
one this repository can enforce, which is what keeps the residual at Low rather than removing it.
5. Summary
| Risk | Residual |
|---|
| R-02 · Lockout as denial of access | Medium (accepted) |
| R-05 · Tenant administrator visibility | Medium |
| R-01 · Concentration | Low |
| R-03 · Behavioural record | Low |
| R-04 · Erasure | Low |
| R-06 · Exercising rights | Low |
| R-07 · Cross-border email delivery | Low |
| R-08 · A breach nobody notices | Low |
No residual risk is rated High, and Art. 36 prior consultation is not required.
No action remains open. #125
’s last two
questions were answered on 10 September 2026. The backup service is MEGA, storing in Europe or an
Art. 45-adequate country and holding no key that decrypts what it stores — grounded in §7.3 of the
Data Protection Document. And SECURITY_SIGNAL lines are now collected by a scheduled job that
mails them, which took R-08 from Medium to Low.
One control passed to the controller rather than closed here: the MEGA account’s credentials.
They are what the backup encryption rests on, so they carry the confidentiality of a full copy of
the database. Nothing in this repository can enforce that, which is why it is written down.
What changed since version 1.0. R-07 was the highest risk and is now Low. Half of it was a
factual error on my part — Google was recorded as a recipient when the ID token is validated offline
and never leaves the server — and the other half was resolved by relying on LGPD Art. 33 IX rather
than on contractual clauses the controller had decided not to pursue. Nothing was signed, and
nothing about the system changed; what changed was that the assessment now describes it accurately.
6. When this is revisited
On a new purpose, category of data, recipient or transfer; on a change of hosting region; after any
incident; when any of §5’s actions completes; and otherwise on the cadence in the
Development Workflow
. A DPIA describing a system that has
moved on is worse than none, because it is relied upon.
6 - Data Retention Schedule Document
How long each category of personal data is kept, why, and which periods the API enforces itself.
Data Retention Schedule Document — Heimdall API
1. Purpose
This document states how long the Heimdall API keeps each category of personal data, and why.
It is the record NFR-19 requires, and it is the source of truth the retention passes are configured
against.
Two obligations make it necessary. GDPR Art. 5(1)(e) and LGPD Art. 15 require that personal data be
kept in identifiable form no longer than the purpose requires. GDPR Art. 13(2)(a) and Art. 30(1)(f)
require that the period, or the criteria used to determine it, be stated — to the data subject at
collection, and in the record of processing activities.
The second obligation is the reason this is a document rather than a set of constants. A period that
lives only in code is not stated to anybody.
A period this document states and nothing enforces is marked “not enforced” and names the issue
that will enforce it. A schedule that describes behaviour the system does not have is worse than
no schedule, because it is relied upon.
2. Scope
Every store that holds personal data — meaning data relating to an identified or identifiable
natural person (GDPR Art. 4(1), LGPD Art. 5 I). That is the tables in the heimdall schema, and
the stores outside it that copy or record them: the application logs and the database backups.
The two outside the schema are the easiest to forget and among the least bounded, which is why they
are rows here rather than an operational footnote. A backup in particular is a full copy of every
category below, and is subject to the same limits as the live database.
APPLICATION is out of scope: an application is a non-human identity representing another system.
Its OwnerId points at a person, but the row describes the system, not the owner, and the owner’s
own record is covered below.
3. The schedule
| Category | Where it lives | Retention period | Why that period | Enforced |
|---|
| Active identity | PERSON, GOOGLE_USER | Life of the account | Performance of the service the account exists for. An identity provider cannot authenticate an identity it has deleted | By deletion (UC-09/UC-10, UC-28/UC-29) |
| Logically deleted identity | PERSON, GOOGLE_USER where is_deleted | 30 days where the subject asked, 90 days where an administrator did — §4 | A soft delete is a restriction of processing, not an erasure, so it needs a terminal state. The shorter deadline is the law’s, not a choice; the longer one is a reversal window. Both end in anonymisation | ✅ Scheduled anonymisation (§4.4) |
| Authentication material | PERSON.password_hash, PERSON.salt, TWO_FACTOR_AUTH, TWO_FACTOR_RECOVERY_CODE | Life of the account | A security measure under GDPR Art. 32; it lives and dies with the credential it protects. Removed by the cascades of NFR-11 and the ON DELETE CASCADE foreign keys | By deletion cascade |
| Single-use tokens | PASSWORD_RESET_TOKEN, EMAIL_VERIFICATION_TOKEN, TWO_FACTOR_EMAIL_CODE, APPLICATION_CREDENTIAL (revoked or expired) | Expiry + 7 days (default, configurable) | §5 | ✅ Scheduled purge (§6) |
| Pending registrations | PENDING_REGISTRATION — the registrant’s name, email address, password hash and salt, before any account exists (UC-47) | Until the link is followed, when the row is removed, or until expiry + 7 days (default, configurable) | The registrant asked for an account and has not yet proved the address is theirs. Nothing but the link’s hash and the request itself is kept, and the row stops serving its purpose when the link is followed or expires; the grace period is §5’s | ✅ Removed on confirmation or when a scope owner discards it (UC-50); scheduled purge otherwise (§6) |
| Attempt-notice budgets | ATTEMPT_NOTICE_BUDGET — a SHA-256 digest of an address that was sent a registration-attempt notice, how many in the current day, and when that day ends (FR-SR-14) | Until the window ends + 7 days (default, configurable) | Held only to cap the notices one address receives at three a day. An ended window counts nothing, so the row has no purpose left; the grace period is §5’s, so it goes with the tokens | ✅ Scheduled purge (§6) |
| Audit trail | AUDIT_LOG | 18 months identifiable, then pseudonymised and kept — §7 | Accountability (GDPR Art. 5(2)) needs the trail for as long as a claim could be raised against it. A pseudonymised entry is no longer personal data, so storage limitation stops applying and the forensic value survives | ✅ Scheduled pseudonymisation (§7.1) |
| Hard-deletion tombstones | ERASED_SUBJECT — the PublicId of a hard-deleted person or Google User, which table it came from, and when (#195) | Until no audit entry names the id, and at least 1 day — §7.2 | Held only so the audit pass can clear the targets that still name a removed person once no row is left to say the id was a person’s. The day covers the hard delete’s own entry, written just after the row goes | ✅ Removed by the audit pass (§7.2) |
| Application logs | Serilog file sink, including every request’s client IP address | 12 months — §8 | Security detection lag, not operational debugging: these are the telemetry #105
reads, and a shorter period deletes the evidence of a breach before anyone knows to look for it | ✅ File sink retention (§8) |
| Database backups | The backup store, outside the schema | 35 days (proposed) — §9 | A backup is a full copy of every category above. Longer than the 30-day erasure deadline, which is what makes restore-time reconciliation mandatory | ⚠️ Regime proposed, reconciliation implemented — §9 |
| Network data | Rate limiter partition key (RemoteIpAddress) | The fixed window, in memory only | An IP address is personal data under both laws. As the partition key it is never persisted; the window is one minute and the key is discarded with it. The same address is also written to the application logs, and kept for their period — §8 | By construction |
| Data Protection key ring | DATA_PROTECTION_KEYS | Life of the encrypted material | Not personal data itself, but the TOTP secrets of NFR-16 are undecryptable without it. Listed so nobody purges it as housekeeping | Never purged, deliberately |
Every period is decided and every one is now enforced, with a single qualification: the backup
regime in §9 is proposed rather than observed, because the repository cannot see the backup
configuration. The mechanism that makes it safe — reconciling a restore against the erasure ledger —
is implemented and tested regardless of which retention the controller confirms. That is the rule in §1 working, not an oversight — the alternative is a
document that reads as though the system already did these things.
Backups are the one row still carrying no number, and deliberately: their period follows from a
strategy choice §9 sets out, so a figure here would prejudge it.
4. Logically deleted identities: two deadlines, because there are two reasons
Setting IsDeleted removes an identity from default queries and stops it authenticating. Under both
laws that is a restriction of processing, not an erasure — a legitimate intermediate state, and
one the system needs. It simply cannot be the terminal one, which is what it is today: nothing
anywhere removes a soft-deleted row, so a person “deleted” in 2026 is still fully identifiable in
2036.
The terminal state depends on why the record was deleted, because the two cases rest on different
legal footing.
4.1 The subject asked: 30 days
Not a period chosen here. GDPR Art. 12(3) requires the controller to act on an erasure request
without undue delay and at most one month; LGPD Art. 19 §2 sets 15 days for the confirmation and
access response that usually accompanies it. Thirty days is the outer limit, not the target — the
erasure completes as soon as it can, and the deadline is what it must not exceed.
One consequence for the request path: NFR-12 refuses to strip a scope of its last owner, so an
erasure can be blocked by a rule that needs a human to resolve, by transferring ownership. A
blocked erasure is not an extended one. The deadline keeps running, which means the pending-request
view has to surface what is overdue rather than merely what is queued.
The deadline is stored when the request attaches, and the stored value wins. UC-42 computes it
at that moment and the anonymisation pass honours it in preference to the configured window, so a
later change to the configuration cannot move an obligation already owed — forward, which would be
unlawful, or backward, which would silently mark existing requests overdue.
A blocked request is still a running one. Where the block clears, the pass suspends the record
dated from the request, not from the moment the block lifted: dating it from now would hand back
the whole deadline for a request already outstanding, which is exactly the breach the deadline
exists to prevent. A long-blocked record is therefore due immediately, which is correct — it is
already late. UC-43 is the queue that makes such a request visible before it gets there.
4.2 An administrator did: 90 days
No subject asked, so the deadline is the controller’s own and the purpose is different: a reversal
window. An administrator who deleted the wrong person needs to undo it, and the client systems in
that person’s scope need time to notice and reconcile.
Ninety days rather than the thirty most consumer services use, because a deletion here is not
confined to one product: Heimdall is the identity provider every client system authenticates
against, so one deletion propagates everywhere at once and is correspondingly more expensive to
discover late.
4.3 Both end in anonymisation, not deletion
NFR-07 requires every foreign key in the schema to still resolve after a deletion, and removing the
row breaks that. Anonymising in place does not: the identifying columns go — Name and Email
overwritten, PasswordHash and Salt zeroed, the behavioural counters reset, and for a Google User
GoogleId and ProfilePictureUrl cleared — while the row keeps its structural role. The person’s
single-use tokens and their two-factor configuration go with them, the latter taking its recovery
codes and email codes by cascade.
That satisfies erasure. GDPR Recital 26 and LGPD Art. 12 both put anonymised data outside the law’s
scope, so an anonymised row is no longer personal data being retained. Hard deletion stays available
as UC-10 for the cases that genuinely want the row gone.
4.4 How the anonymisation runs
IdentityAnonymisationService dispatches AnonymiseExpiredDeletionsCommand on the same interval as
the token purge, and the pass shares that one’s properties: bounded to a batch per run, needing no
coordination between instances, audited so each run leaves the evidence that erasure happened, and
treating a run with nothing due as a success.
Three things are specific to it.
The deadline comes from the record, not the pass. Each row carries DeletionKind, and the
cutoff applied is the one that kind names. A row with no kind — deleted before this mechanism
existed, and backfilled by the migration — is treated as administrative, because guessing that
somebody had requested erasure would invent a request that may never have been made and apply the
shorter deadline to it.
A row with no DeletedAt is skipped, not taken. A window that cannot be shown to have elapsed
is not one to act on. The migration backfills DeletedAt from UpdatedAt for rows deleted before
the column existed; that proxy is equal to or later than the true deletion, never earlier, so a
backfilled window closes on time or late and can never erase a record early.
The dependents go first, and a failure there stops the run. Anonymising the person while leaving
their two-factor secret behind would erase the label and keep the material — and the next run would
never return to it, because it selects on records not yet anonymised.
5. Single-use tokens: why the period is expiry plus a grace period
A password reset token, an email verification token, a two-factor email code, and a pending
registration (UC-47, whose link verifies an address for an account not yet created) all stop
serving their purpose the moment they expire or are consumed. Storage limitation says to remove
them, and their retention is the shortest in the schedule.
It is not zero, and the reason is UC-13. A presented token has three distinct outcomes:
| Outcome | When |
|---|
TokenInvalid | No row matches the presented value |
TokenExpired | The row exists and ExpiresAt has passed |
TokenAlreadyUsed | The row exists and Used is set |
Purging on expiry alone collapses the second and third into the first. A person following a link
from an old email would be told their token never existed, rather than that it ran out — a worse
answer, and a misleading one. The grace period keeps the truthful answer available for as long as
anyone plausibly still holds the link, and no longer.
Seven days is the default because the tokens themselves live in minutes or hours, so a week is
comfortably longer than any link is useful and far shorter than any purpose could justify keeping
them. It is configurable, and it cannot be set to zero or a negative value — the options type
refuses both and falls back to the default.
For the four token and registration tables the purge selects on ExpiresAt <= now - grace. That
single condition covers a used row and an expired-unused row alike, and it is the only condition
applicable to all four of them: neither token table records when it was consumed, and only
TWO_FACTOR_EMAIL_CODE and PENDING_REGISTRATION carry a CreatedAt. Expiry is a sound proxy in any
case, since each row is issued with a lifetime measured in minutes. Application credentials purge on
a condition of their own, below.
A live token is never at risk. The cutoff is strictly in the past, so a token that has not yet
expired cannot match however the grace period is configured.
An attempt-notice budget (FR-SR-14) is not a token either. It purges once its window ended at or
before now - grace: an ended window counts nothing, since the next notice to that address starts a
new one, so the row is a digest of an address with no purpose left.
An application credential (UC-53) is not a single-use token, but once it is revoked or has expired
it can never be accepted again, so it is kept for the same grace as a spent token and then purged by
the same pass. Its condition is revoked or expired at or before now - grace, the grace running
from whichever came first: expiry alone would never purge a revoked credential that has no expiry. A
live credential is kept for as long as it is live, since it is in use.
6. How the purge runs
A hosted service (TokenRetentionService) dispatches PurgeExpiredTokensCommand on an interval,
hourly by default. Three properties are worth stating, because each is a decision rather than an
implementation detail.
It is bounded. Each run removes at most PurgeBatchSize rows from any one table, oldest first.
SRD §6.3.2 measured the write path degrading with the size of the table under sustained insert
pressure, and a purge is sustained delete pressure on tables the login and recovery paths write
to. A backlog drains over several runs instead of one long transaction competing with live traffic.
It needs no coordination between instances. NFR-06 requires that no instance assume it is alone,
and every instance runs its own copy. Nothing is claimed or locked: a run selects a batch, and the
delete re-reads those ids and removes whatever still exists, so an instance whose rows another
already removed simply deletes fewer than it selected and reports the true count. Deleting an
already-deleted row is the only concurrent outcome this pass can have, and it is harmless. The
alternative — electing one instance to purge — fails in the worse direction, because an instance
that assumed another was purging would stop purging the moment it ran alone.
It is audited. The command is registered like every other, so each run writes one audit entry
(NFR-09). The entry is as much the point as the deletion: it is the evidence that the schedule was
enforced, and when. The run is dispatched by a scheduler rather than a caller, so it is recorded as
an anonymous write.
A run that removes nothing is a success, not a failure — most runs find nothing to do, and treating
a no-op as an error would fill the trail with refusals that never happened.
The first tick falls one whole interval after start-up, which keeps the purge clear of migrations
and seeding, and means a container restarting repeatedly never turns start-up into delete pressure.
A failed run is logged and the loop continues: the pass is maintenance, not part of serving a
request, and the next tick retries whatever was missed, because a row past its retention period
stays past it.
7. The audit trail: 18 months identifiable, then pseudonymised and kept
AUDIT_LOG is where two obligations pull hardest against each other. Accountability (GDPR Art.
5(2), Art. 32; LGPD Art. 37) is why the trail exists, why it is append-only, and why
ActorPersonId is a bare PublicId rather than a foreign key — so an entry survives a hard-deleted
person. Storage limitation says an indefinitely retained record naming a person who has been erased
is personal data processed with no remaining basis.
The period settles the first half of that, and the mechanism settles the second.
Eighteen months identifiable. Most security baselines land on twelve — PCI DSS requires a year
of audit history — and breach discovery lag routinely exceeds it, so twelve is a floor rather than a
comfortable answer. Eighteen covers a full compliance cycle plus that lag. Beyond it, the
accountability value of who acted falls away sharply while the value of what happened, when, and
how often does not — and that half survives pseudonymisation intact.
Then pseudonymised, and kept indefinitely. Once neither ActorPersonId nor a person TargetId
resolves to a natural person the entry is not personal data (Recital 26, LGPD Art. 12), so storage
limitation stops applying to it and the trail keeps its shape, ordering and correlation for as long
as it is useful.
Pseudonymised in place, and never deleted. The pass sets the attribution, and any person target,
to NULL on the entry itself; nothing else in the row changes. It can do only that because the
trigger described in §7.1 permits only that, and only once due. DELETE and TRUNCATE stay refused
outright, so no entry can be removed however old it is, and Threat Model TH-18 stays closed: the
append-only guarantee was narrowed by exactly these two clearings, not dropped or worked around.
A person target goes with the attribution. An entry names a person through its TargetId as
well as through its actor: for a command somebody runs on their own account the target is their own
PublicId, and for an administrator’s action on a person it is that person. Such an entry stays
personal data about the target for as long as their PERSON or GOOGLE_USER row is identifiable,
whoever acted. So a target naming a person or Google User is cleared on the same two grounds as the
actor — the attribution period has elapsed, or that identity has been anonymised (#184). A target
naming anything else (a scope, an application, a credential, a permission) is not personal data and
is kept as recorded.
So does the target of a hard-deleted person (#195). A hard delete (UC-05’s members, UC-10,
UC-29) removes the row, but not the id: the entries about that person, and the hard delete’s own
entry, still carry it, and anything holding a copy of the old row — a backup inside its window, an
export, a log line — can still resolve it. So it stays personal data. With the row gone, though,
nothing in the database could tell the id was a person’s, and the trigger refused to clear it as
“names no person”. Each hard delete therefore writes the id to ERASED_SUBJECT before the row goes,
and a target whose id is there and which no PERSON or GOOGLE_USER row has any more is cleared on
the pass’s next run, at any age — the person is gone, which is the outcome an anonymisation only
approximates. A tombstone whose row is still there (a delete that failed after writing it) is no
ground at all. An id that resolves to nobody and was never tombstoned — a hard-deleted scope’s, or a
person’s removed before tombstones existed — is still kept, because nothing says it was ever a
person’s; the ids of persons hard-deleted before #195 were never recorded and cannot be cleared.
Erasure does not wait for the clock. When a data subject is erased under §4, the entries they
produced lose their attribution, and the entries about them lose their target, on the pass’s next
run — the same mechanism, triggered by the erasure rather than by eighteen months elapsing.
One consequence worth stating plainly rather than discovering later: an active person’s entries,
and the entries about them, are pseudonymised at eighteen months while they are still an active
person. That caps how far back a current account can be investigated. It is the intended trade-off of storage limitation, and it is
recorded here so it is a decision rather than a surprise.
7.1 How the pseudonymisation runs, and why the database enforces it
AuditRetentionService dispatches PseudonymiseAuditActorsCommand on the same interval as the
other passes, selecting entries whose attribution period has elapsed or whose actor has been
anonymised, and on the same two grounds, entries whose target names a person or Google User — past
the period, or that identity anonymised — and, at any age, entries whose target is a tombstoned id
that no PERSON or GOOGLE_USER row has any more. Each entry has only what is due cleared: a
recent entry by a live administrator about an erased person loses its target and keeps its actor.
The run reports both counts, and the tombstones it removed (§7.2). It shares the other passes'
properties: bounded per run, needing no coordination between instances, audited, and treating an
empty run as a success.
What is different is where the rule lives. The pass does not decide what it is allowed to write —
the database does, through the trigger installed by AllowClearingAuditActor and widened by
AllowClearingAuditTarget and AllowClearingErasedSubjectTarget:
| Operation | Permitted |
|---|
DELETE, TRUNCATE | Never |
UPDATE changing any column other than the attribution and the target | Never |
UPDATE setting the attribution to a different identity | Never |
UPDATE clearing the attribution, entry older than 30 days | Yes |
UPDATE clearing the attribution, actor anonymised | Yes, at any age |
UPDATE clearing the attribution, recent entry, live actor | Never |
UPDATE setting the target to a different value, or setting one where there was none | Never |
UPDATE clearing a target that names no PERSON or GOOGLE_USER row and is not in ERASED_SUBJECT | Never |
UPDATE clearing a target that names no PERSON or GOOGLE_USER row and is in ERASED_SUBJECT (a hard-deleted person) | Yes, at any age |
UPDATE clearing a person target, entry older than 30 days | Yes |
UPDATE clearing a person target, that person anonymised | Yes, at any age |
UPDATE clearing a person target, recent entry, live person | Never |
Each part is judged on its own, and an unchanged part is accepted, so the pass can clear one without
the other; a statement clearing both succeeds only if both are due. The application cannot clear an
attribution or a target early, reassign either, or alter anything else, because the database refuses
all three whatever the code asks. That matters more here than elsewhere: this is
the one table whose immutability was a security control (Threat Model TH-18), and a control the
application could talk its way around would not be one.
The thirty-day floor is an anti-tamper backstop, not a retention period. The retention period is
eighteen months and lives in configuration; the floor exists so that somebody who has just acted
cannot immediately clear their own attribution, or the person they acted on, to cover it. The
erasure branch carries no age condition, for the actor or the target, because a subject-requested
erasure completes thirty days after the request and the entries it produced, or that concern the
person, days earlier have to go with it.
What this narrows, stated honestly. Before, nothing could change in this table at all. Now two
things can: the attribution, and a target that names a person — on record, or tombstoned by a hard
delete. The guarantee that remains is
narrower but still precise: an action can never be denied, and who took it, or which person it was
taken on, can never be reassigned — only forgotten. Non-repudiation of the act survives
intact; attribution, and the naming of a person it was taken on, is what becomes time-limited, which
is exactly what storage limitation requires of it.
7.2 Tombstones of hard-deleted identities
ERASED_SUBJECT holds one row per hard-deleted person or Google User: the PublicId, which of the
two it was, and when the row was written. Nothing else about the person. It is a processing record
of the deletion, held for one purpose — to let §7.1 clear the targets that still name the id — and
it goes once that purpose is spent.
Written first. UC-10, UC-29 and UC-05 (for each member person and Google User it removes) write
the tombstone before they delete anything. If the delete then fails, the tombstone names a live row,
which neither the pass nor the trigger acts on. The write is idempotent: a retried hard delete
records the id again without error, keeping one row and moving its created_at to the retry. The
table accepts a row only for a person or Google User that exists when it is written (#195, R1), so a
tombstone cannot be planted for any other id to make its targets clearable; the Threat Model, §6.1,
gives the reasoning.
Removed by the audit pass, on two conditions. After clearing, the same run removes each
tombstone that is older than one day and that no audit entry names as its target any more,
bounded per run like the clearing. The day covers the hard delete’s own audit entry, which the
auditing decorator writes just after the handler returns: until it lands, nothing names the
tombstone, and removing it then would leave that entry naming the person for good. A tombstone named
by an entry the run’s batch did not reach waits for a later run. In practice a tombstone lives a day
or a little more.
8. Application logs: why 12 months, and on what condition
Twelve months rather than the ninety days an operational-debugging period would justify, because
debugging is not what these logs are for. The audit trail is the record of what the API did; the
application logs are the telemetry a breach is detected from, and
#105
makes that explicit by reading them.
Breach discovery is routinely measured in months rather than weeks. A ninety-day period would
therefore delete the evidence of an incident before anyone knew to look for it, which is the one
outcome a security log must not have — and GDPR Art. 33’s seventy-two-hour clock starts at
awareness, so a log that expired before awareness never contributed to meeting it.
The condition, and how it was met. Twelve months is defensible for logs that identify a person
only by PublicId. It was not defensible for the logs as they stood, which carried raw email
addresses — MailgunSender wrote the recipient on every verification, reset and 2FA email, and
DatabaseSeeder wrote the master administrator’s address at every start-up. Quadrupling the life of
a file full of addresses would have been a larger exposure than the shorter period it replaced, not
a smaller one.
So the two halves were ordered, and the order was not negotiable: the redaction landed with the
retention limit, in one change. No log statement writes an address now. Where a line needs to
refer to one so an operator can correlate — whether three failures are one address retrying or three
people — it writes a stable, non-reversible reference instead (LogSafeEmail). That is
pseudonymisation rather than anonymisation, and the honest limit is that somebody who already
suspects a particular address can hash it and look; what it defeats is bulk harvesting.
IP addresses are the exception, and a deliberate one. Every request is logged with its client IP
address, in clear, since Privacy Notice 1.2. It is the one identifier the logs carry that is not a
PublicId or a reference, and it is there because it is the security telemetry: the source of a
credential-stuffing run, or the address a compromised account was used from, is what a breach
investigation asks first, and a reference could not be matched against a block list or a provider’s
abuse report. It is kept for the logs’ twelve months and removed with them, on legitimate interests
(the Data Protection Document’s network row). Behind a reverse proxy it is the caller’s address only
when HEIMDALL_TRUSTED_PROXIES names the proxy; otherwise it is the proxy’s, and identifies nobody.
The same middleware also tags the address on the request’s trace activity (client.address). Nothing
exports traces, so that copy never leaves memory and ends with the request; adding a trace exporter
would change that, and needs this schedule and the Privacy Notice updated first.
The seeder simply stopped logging the address. It came from the environment the operator set, so the
line told them nothing they did not know, and it was written on every start-up for the life of the
deployment.
The sink shape had to change for the limit to mean anything. It was wrapped in WriteTo.Map
keyed per month, which creates a new sink per month — a retention limit bounds files within one
sink, so it bounded each month’s directory and never removed a month. Files from the first month a
deployment ran were still on disk years later. It is now a flat rolling sink with
retainedFileTimeLimit, which is the shape where the limit applies. The year/month directory layout
is gone as a result.
9. Database backups: why the period is bounded, not chosen
A backup is a full copy of every category in §3, so it inherits all of their limits at once. It also
creates the one failure that makes every other row in this document theoretical:
An erasure that the next restore silently undoes is not an erasure.
Backups are not edited. Editing them destroys the integrity that is their entire purpose, and no
backup regime worth running permits it. That left exactly two workable answers:
- Backup retention shorter than the shortest erasure deadline, which §4 now fixes at 30
days. An erased record cannot survive in a backup past the deadline, because the backup holding
it is gone first. Simple, and it needs no reconciliation — but it caps disaster recovery at 30
days, which for most operators is far too short.
- A restore re-applies every erasure completed since the backup was taken. Keeps the backups as
long as recovery needs, at the cost of a step that must run on every restore and must be tested
like any other part of the recovery procedure.
Option 2 was taken, with a proposed 35-day retention. The direction of the constraint is why:
a regime keeping backups longer than 30 days — which is nearly every regime — makes the
re-application step mandatory rather than optional, and choosing option 1 by default and discovering
later that recovery needs ninety days is how an erasure quietly comes back. Committing to the
reconciliation up front means the retention can be whatever recovery actually needs.
The mechanism is in
§0.1 of the Operations & Infrastructure Document
,
along with the runbook step. In short: the anonymisation pass logs the identifiers it anonymised —
a ledger that survives outside the database, which is what a restore cannot roll back — and a
System Admin replays it afterwards. Only backups predating a request need it; where the backup was
taken after, the restored row still carries its deadline and the scheduled pass finishes the job
unaided.
Backup encryption belongs to the same issue and is not restated here.
10. Configuration
| Variable | Default | Accepted range | Meaning |
|---|
HEIMDALL_RETENTION_TOKEN_GRACE_DAYS | 7 | more than 0, up to 3650 | Days a single-use token is kept past expiry |
HEIMDALL_RETENTION_PURGE_INTERVAL_MINUTES | 60 | 1 to 10080 (7 days) | Interval between purge runs |
HEIMDALL_RETENTION_PURGE_BATCH_SIZE | 500 | any positive integer | Most rows removed from one table per run |
HEIMDALL_RETENTION_PURGE_ENABLED | true | true / false | Set false to stop scheduling the purge |
HEIMDALL_RETENTION_ERASURE_DEADLINE_DAYS | 30 | more than 0, up to 30 | Days before a requested erasure is anonymised |
HEIMDALL_RETENTION_DELETION_WINDOW_DAYS | 90 | more than 0, up to 730 | Days before an administrative deletion is anonymised |
HEIMDALL_RETENTION_ANONYMISATION_ENABLED | true | true / false | Set false to stop scheduling the anonymisation |
HEIMDALL_RETENTION_AUDIT_ACTOR_DAYS | 548 (18 months) | 30 to 3650 | Days an audit entry stays attributed |
HEIMDALL_RETENTION_AUDIT_PSEUDONYMISATION_ENABLED | true | true / false | Set false to stop scheduling it |
HEIMDALL_RETENTION_LOG_DAYS | 365 (12 months) | 1 to 3650 | Days a log file is kept before the sink removes it |
The erasure deadline is the one setting with a ceiling that is not a sanity check: 30 days is the
statutory limit, so a larger value is not a policy choice but a compliance failure, and it is
refused rather than applied.
The other ranges are enforced too, and each bound is there for a reason. A grace period beyond ten years is a
stray unit rather than a policy. An interval below a minute turns the purge into continuous delete
pressure on the tables the login path writes to; one above seven days is past what PeriodicTimer
accepts, and the exception it would throw inside the hosted service stops the host by default — so
without the ceiling, a stray digit in a retention setting would take the API down.
Every value has a default that is safe to run with, because a deployment that sets none of these
must still enforce a period: an unset variable meaning “keep forever” is precisely the state NFR-19
exists to end.
A malformed or out-of-range value falls back to the default and is named in a start-up warning,
rather than failing start-up. Refusing to start would turn a typo in a retention setting into an
outage of the identity provider every client system authenticates against — a far worse failure than
running on the documented default for one interval. Falling back silently would be worse than
either, since an operator would believe a setting was applied, so the warning names each variable it
ignored.
Switching the purge off is a deliberate deployment decision and is logged as a warning, because it
leaves personal data in place past its retention period.
11. Reviewing this document
The schedule is reviewed when:
- a migration adds or removes a column holding personal data;
- a new category of data subject or recipient appears;
- one of the “not yet enforced” rows is implemented, at which point its row states the period and
the mechanism and drops the marker;
- the record of processing activities
is reviewed, since GDPR Art.
30(1)(f) requires the periods to appear there too — that document satisfies it by reference to this
one, so the two are reviewed together;
- a new store outside the schema starts holding personal data, or an existing one changes what it
holds — §8’s twelve months rests on the logs no longer carrying addresses, and a change that put
them back would invalidate the period without touching a single table;
- the backup or recovery regime changes, since §9’s two answers trade against the recovery window
and a longer window can turn the reconciliation step from optional into mandatory.
7 - Data Protection Document
Who controls what, on what basis, shared with whom, and across which borders — the record LGPD Art. 37 and GDPR Art. 30 require.
Data Protection Document — Heimdall API
1. Purpose
This is the record of processing activities LGPD Art. 37 and GDPR Art. 30 require, together with
the two things that record depends on and that nothing else in this repository states: who controls
the data, and where it goes.
It is kept here rather than in a filing cabinet because the schema is the source of truth and the
schema changes. Keeping the record next to the requirements means it is reviewed in the same pull
request as the migration that outdates it.
Retention periods are not repeated here. They live in the
Data Retention Schedule
, which Art. 30(1)(f) is
satisfied by reference to.
2. Controller and Encarregado
LGPD Art. 41 §1 requires the Encarregado’s identity and contact to be publicly disclosed. That is
what §2 of the Privacy Notice
does, and this row is its source.
The controller and the Encarregado are the same person. That is permitted, and for a project of this
size it is the honest arrangement rather than an invented separation — but it is worth naming the
consequence: there is no independent check on the controller’s own decisions. Where the project
takes on a client whose scale makes that unacceptable, the Encarregado should be somebody else.
On small processing agents. ANPD Resolution CD/ANPD nº 2/2022 gives agentes de tratamento de
pequeno porte a simplified regime — notably, appointing an Encarregado is optional provided a
communication channel is published. This project appoints one anyway, because the channel has to
exist either way and naming a person is clearer than naming an inbox. Whether the project qualifies
as a small processing agent is a determination for the controller, and it does not change anything
in this document.
3. Controller and processor, per scope
Heimdall is multi-tenant. Each Scope belongs to a different client system, and that client decides
why its users’ data is processed. The allocation follows from that and has to be stated, because
almost every downstream obligation depends on which side of it a given row falls.
| Data | Role | Why |
|---|
| Persons and Google Users within a tenant scope | Heimdall is processor; the tenant organisation is controller | The tenant decides the purpose — its own users, its own service. Heimdall stores and authenticates them on the tenant’s instructions |
| System Admin accounts | Heimdall is controller | They exist to operate Heimdall itself; no tenant determines their purpose |
Audit trail (AUDIT_LOG) | Heimdall is controller | Kept for Heimdall’s own accountability under LGPD Art. 37 and GDPR Art. 5(2). A processor may hold records it needs to demonstrate its own compliance, and this is one |
| Application logs, security telemetry | Heimdall is controller | Same reasoning: operating and defending the service is Heimdall’s own purpose, not a tenant’s instruction |
Two consequences that are easy to miss:
- A data subject request from a tenant’s user reaches a processor, not their controller. Heimdall
answers it under UC-41/UC-42 where it can, and the Data Processing Agreement sets out when and how
it must instead be routed to the tenant.
- Heimdall must notify the tenant of a breach affecting their scope, without undue delay (GDPR
Art. 33(2), LGPD Art. 48). That obligation is owed to the tenant, not the public.
4. Categories of data subject
| Category | Where |
|---|
Persons — User, ScopeAdmin, SystemAdmin | PERSON |
| Google-authenticated users | GOOGLE_USER |
APPLICATION rows are out of scope: an application is a non-human identity representing another
system. Its OwnerId names a person, but the row describes the system.
APPLICATION_CREDENTIAL rows (UC-53) are out of scope for the same reason. Each holds an application reference, the SHA-256 of a random secret, and four instants (issued, expiring, revoked, last used). None of them relates to a person, and the audit entries of credential operations name the acting administrator as every audit entry does (§5.1 AUDIT_LOG).
5. Categories of personal data, purposes, and legal basis
| Category | Fields | Purpose | LGPD basis | GDPR basis |
|---|
| Identity | Name, Email | Identify the account and address it | Art. 7 V (execução de contrato) | Art. 6(1)(b) contract |
| Authentication material | PasswordHash, Salt, SecurityStamp, TwoFactorAuth.TotpSecretEncrypted, recovery code hashes | Verify that the account holder is present | Art. 7 V, Art. 46 (segurança) | Art. 6(1)(b), Art. 32 |
| Third-party identifiers | GoogleUser.GoogleId, ProfilePictureUrl | Resolve a Google sign-in to a stored identity | Art. 7 V | Art. 6(1)(b) |
| Behavioural / security | FailedLoginAttempts, LockedOutUntil, the second-factor and re-authentication budgets, AUDIT_LOG | Defend accounts against brute force; demonstrate what the system did | Art. 7 IX (legítimo interesse), Art. 37 | Art. 6(1)(f) legitimate interests, Art. 5(2) |
| Deletion and erasure state | DeletedAt, DeletionKind, AnonymisedAt, ErasureRequestedAt, ErasureDueAt, ErasureBlockedReason | Honour erasure within the statutory deadline and show that it was honoured | Art. 7 II (obrigação legal) | Art. 6(1)(c) legal obligation |
| Network | Client IP address: the rate limiter’s partition key (in memory), and every request’s entry in the application logs (12 months) | Bound anonymous request rates; detect and investigate attacks on accounts | Art. 7 IX, Art. 46 | Art. 6(1)(f), Art. 32 |
| Delivery | Recipient address passed to Mailgun | Send verification, reset and second-factor emails, and the notice of an email change to the previous address (FR-PE-15) | Art. 7 V | Art. 6(1)(b) |
Consent is not a basis anywhere in this table, and that is deliberate. Every processing operation
here is necessary to run an identity service the account holder is a party to, or is required of the
controller by law. Consent would be the wrong basis for all of it: it is withdrawable, and an
identity provider that stopped verifying passwords on withdrawal would simply be broken.
It is also not selectable, not merely unused. A scope that declared consent would be recording a
basis this system cannot honour — GDPR Art. 7(3) requires withdrawal to be as easy as giving it, and
there is no withdrawal path. The validators refuse the value and the recorder refuses it again on
the way out, because a guard in one direction only is one migration from being bypassed.
The basis is recorded per identity at creation, with the privacy notice version then in force
(NFR-23). The version is stored rather than looked up later: what a person was told is a fact about
a moment, and reading the current notice would answer a different question. A scope may declare the
basis for identities within it, since the tenant is usually their controller (§3); where it declares
none, contract performance applies.
Identities created before NFR-23 read Unrecorded. Contract performance almost certainly applied
to all of them, and the migration deliberately did not write it: asserting in this record something
nobody checked for those rows would demonstrate that a migration ran, not that a basis applied.
They are visibly a thing to fix rather than silently indistinguishable from a row recorded properly.
No special categories of personal data (GDPR Art. 9, LGPD Art. 5 II) are processed. There is no
automated decision-making producing legal effects (GDPR Art. 22, LGPD Art. 20).
5.1 Every column, and why it is held
§5 groups the data by category. This is the same data at the granularity a minimisation review needs
(GDPR Art. 5(1)(c) and Art. 25, LGPD Art. 6 III): one row per column that holds personal data, each
with the purpose that justifies holding it. A column with no purpose is a column to remove.
PERSON
| Column | Purpose |
|---|
PublicId | The identifier every other table and every token refers to. Random, so it discloses nothing by itself |
Name | Addressing the person in email, and downstream profile provisioning by client systems |
Email | The address the account is reached at, and the identifier a person signs in with |
PasswordHash, Salt | Verifying the person is present. Never reversible to the password |
IsDeleted | Suspending an identity without destroying it |
EmailVerified | Whether the address has been proven reachable (FR-EV-01) |
FailedLoginAttempts, LockedOutUntil | Bounding credential guessing per account (FR-AU-09) |
FailedSecondFactorAttempts, SecondFactorLockedOutUntil, SecondFactorLockouts | Bounding second-factor guessing at sign-in per account, across challenges (FR-AU-12). Cleared by a successful second factor and by anonymisation (NFR-20) |
FailedReauthenticationAttempts, ReauthenticationWindowStartedAt, ReauthenticationPausedUntil | Bounding password and code guessing under a bearer token per account (FR-AU-13). Cleared by a successful re-authentication and by anonymisation (NFR-20) |
AccountEmailsSent, AccountEmailWindowEndsAt, AccountEmailLastSentAt | Bounding the account emails the person’s own requests can have sent — a verification link asked for again, a two-factor setup code, the notice an own email change sends to the previous address — to one per cooldown and five per day-long window (FR-PE-16), so an account cannot be used to flood an address. Returned in the data export; cleared by anonymisation (NFR-20) |
SecurityStamp | Revoking the person’s tokens when a credential changes (FR-AU-11). A random value with no meaning of its own, replaced on each change |
RoleId, ScopeId | Authorisation, and the per-scope uniqueness index FR-PE-09 needs |
CreatedAt, UpdatedAt | Ordinary record keeping; UpdatedAt is why DeletedAt had to exist separately |
DeletedAt, DeletionKind, AnonymisedAt | Measuring and enforcing the retention window (NFR-20) |
ErasureRequestedAt, ErasureDueAt, ErasureBlockedReason | Honouring an erasure request within its deadline, and showing that it was honoured (UC-42, UC-43) |
LegalBasis, PrivacyNoticeVersion, BasisRecordedAt | Demonstrating the lawful basis and what the person was told when the account was created (NFR-23). Not data about the person so much as about the processing of them, and held for the same accountability duty that requires this document |
ProcessingRestrictedAt, RestrictionGround, RestrictionLiftNotifiedAt | Honouring a restriction of processing and evidencing that Art. 18(3)’s notification happened before it was lifted (NFR-24) |
GOOGLE_USER
| Column | Purpose |
|---|
PublicId, ScopeId, IsDeleted, CreatedAt, UpdatedAt | As above |
GoogleId | Google’s sub. The only stable way to resolve a returning sign-in to a stored identity (FR-GO-08) |
Name, Email, EmailVerified | As on PERSON |
ProfilePictureUrl | Downstream profile provisioning — see the decision below |
DeletedAt, DeletionKind, AnonymisedAt, ErasureRequestedAt, ErasureDueAt, ErasureBlockedReason, LegalBasis, PrivacyNoticeVersion, BasisRecordedAt, ProcessingRestrictedAt, RestrictionGround, RestrictionLiftNotifiedAt | As above |
AUDIT_LOG
| Column | Purpose |
|---|
ActorPersonId, ActorRole | Attributing an action, for as long as NFR-21 permits and no longer |
TargetId | What the action was taken on. When that is a person or Google User — their own account for a self-service command, or the person an administrator acted on — the entry is personal data about them whoever acted, so NFR-21 clears it on the same schedule as the actor: past the attribution period, or as soon as that identity is anonymised (#184). Any other target (a scope, an application, a credential, a permission) names no person and is kept. A person target whose row was hard-deleted (UC-05, UC-10, UC-29) is cleared too, on the next pass and at any age: the id still names the person to anything holding a copy of the old row, so it stays personal data, and the hard delete tombstones it in ERASED_SUBJECT before the row goes so the pass and the trigger can tell it was a person’s (#195). An id that resolves to nobody and was never tombstoned — a hard-deleted scope’s, or a person’s removed before tombstones existed — is kept |
Action, Succeeded, FailureReason, CreatedAt, PublicId | What happened. Not personal data once the attribution and any person target are cleared |
Readable through the API by System Admins (UC-48). GET /api/audit-log shows each entry as
stored: an attribution or person target already cleared reads as null and is never reconstructed,
whether by a join to PERSON or from neighbouring entries. The endpoint widens who can read the trail — from whoever
can query the database to System Admins — without widening what it says about anyone. No column was
added, so this table is unchanged.
This table is why NFR-23’s columns are here. They were added after the table existed, and
PersonalDataPurposeTests failed the build until each was given a purpose — which is the control
working rather than a formality. SCOPE.DefaultLegalBasis and SCOPE.PrivacyNoticeUri are absent
deliberately: a scope is an organisation, not a person, so neither is personal data.
ERASED_SUBJECT
| Column | Purpose |
|---|
PublicId, Kind, CreatedAt | A short-lived processing record of a hard delete (#195): the removed person’s or Google User’s id, which of the two it was, and when. Held only so NFR-21 can clear the audit targets that still name the id — the hard delete’s own entry among them — once no row is left to say the id was a person’s. Removed by the first run of the same pass after its day of grace, once no entry names it. Nothing else about the person is kept, it is never read through the API, and the database accepts a row only for a person or Google User that exists when it is written (#195, R1) |
PENDING_REGISTRATION
| Column | Purpose |
|---|
PublicId | The administrative reference a scope’s owners list and discard a registration by (UC-49, UC-50). Random, so it discloses nothing by itself; the registrant never sees it |
ScopeId | The scope the registrant asked to join — and so whose owners may see and discard the registration |
Name, Email | What the account will be created with once the address is confirmed; the address is also where the confirmation link goes |
PasswordHash, Salt | The password chosen at sign-up, so the account can be created with it and confirmation can require it (FR-SR-09). Never reversible to the password |
TokenHash | Recognising the mailed link without storing it (Threat Model TH-14) |
ExpiresAt, CreatedAt | Bounding how long the link works, and when the row is purged (Data Retention Schedule) |
FailedAttempts | Bounding guesses at the sign-up password through a leaked link (FR-SR-09, NFR-27) |
A scope’s owners and System Admins can read the id, name, address, creation and expiry times, the
remaining guesses and whether it has expired through the API (UC-49) — the same name and address they
would see in the scope’s user list once the registration is confirmed. The hash, salt and token
hash are never returned.
ATTEMPT_NOTICE_BUDGET
| Column | Purpose |
|---|
AddressHash | Which address a registration-attempt notice went to, so the address gets at most three a day however many scopes or source addresses the attempts come from (FR-SR-14). A SHA-256 digest of the trimmed, lower-cased address, never the address itself — pseudonymisation rather than anonymisation, since whoever already suspects an address can hash it and find the row |
NoticesSent, WindowEndsAt | How many notices went out in the current one-day window and when it ends; the first notice after it ends starts a new one. Purged with the single-use tokens once the window has ended and the grace period passed (Data Retention Schedule) |
The row is written when a notice is sent, which is to say when someone registered with an address that
already holds an account; it records that much about the holder and nothing else. It is never read
through the API.
It is linked to no person or Google User — the digest is of an address, which several accounts in
different scopes may share — so erasure, anonymisation and a hard delete do not reach it, and the data
export (UC-41) does not include it: there is no account to attach it to, and finding it would mean
hashing the subject’s address to look it up, which is processing the row exists to avoid. What bounds
it instead is its retention: it lasts at most the one-day window plus the grace period, and is then
purged whether or not anyone asked.
Two decisions this review settled
ProfilePictureUrl stays, and it is the weakest case here. The issue that prompted this review
proposed dropping it unless a consumer could be identified. One can: the same downstream
profile-provisioning purpose that puts DisplayName in the auth token. A client system rendering a
signed-in user wants a name and an avatar, and serving both from the identity provider is why the
identity provider holds them.
It is recorded as the weakest case because the reasoning is thinner than for any other column. It is
a URL to a photograph of a person, held for a convenience a client system could satisfy by asking
Google itself, and required by FR-GO-05 rather than by anything the API does with it. If no client
system reads it, it should be dropped — that is a question about deployments rather than about
code, and it belongs to the controller. Removing it would change FR-GO-05 and break the two Google
User read endpoints’ contract, which is why this review does not do it unilaterally.
The display name in the auth token is confirmed, with its consequence stated. A JWT travels
further than a response body: client systems store it, put it in headers, and log it. Putting a name
in one spreads that name into places this system does not control and cannot clean up. The purpose —
letting a client provision a profile without a second call — is real, and the alternative of a
profile endpoint would mean every client making an extra request on every sign-in. The decision
stands; the consequence is recorded so that a future proposal to add anything else to the token is
weighed against the same test.
6. Recipients
| Recipient | What reaches them | Why |
|---|
| Mailgun (Sinch) | Recipient email address, message body | Delivering verification, password reset and second-factor emails |
| Tenant scope organisations | The identities within their own scope, and the self-registrations awaiting confirmation in it (UC-49: name, address, timestamps, guesses left) | They are the controller for those; see §3 |
| Hosting provider | Everything, at rest | Running the database and the API |
The current sub-processor list is maintained in the
Data Processing Agreement
§7, which is also where the
notification commitment for changing it lives.
6.1 Google is a source, not a recipient
An earlier version of this document listed Google as a recipient. That was wrong, and the
correction matters because it removes an international transfer that was recorded as needing a
mechanism.
GoogleIdTokenVerifier calls GoogleJsonWebSignature.ValidateAsync, which validates the token
offline: it verifies the signature against Google’s published certificates, which the library
caches and refreshes hourly, and checks the issuer, audience and expiry locally. The token is
never sent to Google. The only outbound request is for Google’s public certificates, which carry
no personal data.
The direction of travel is the opposite of what a recipient means. Google issues the token to the
person’s browser, which passes it to a client system, which presents it here — so Google is where
GoogleId, Email, Name and ProfilePictureUrl come from, and GDPR Art. 14 and LGPD Art. 9
make that something the data subject is told. It is disclosed in the Privacy Notice and in every
UC-41 export’s sources.
The person’s own sign-in at Google is a separate matter, between them and Google under Google’s own
terms. Enabling Google Sign-In for a scope makes it possible; it does not make Heimdall the party
transferring anything.
7. International transfers
| Leg | From | To | Governed by | Status |
|---|
| Email delivery | Brazil | United States (Mailgun) | LGPD Art. 33 IX + Art. 7 V | ✅ Ground established — §7.1 |
| Email delivery, for EEA subjects if any | EEA | United States (Mailgun) | GDPR Art. 46(2)(c) | ✅ Sinch’s SCCs — §7.1 |
| Direct sign-up by an EEA subject | — | Brazil (hosting) | Not a Chapter V transfer | ✅ No exporter — §7.2 |
| An EEA tenant’s users | EEA (tenant) | Brazil (hosting) | GDPR Art. 46(2)(c) | ✅ SCCs Module Two in the DPA — §7.2 |
| Daily backups | Brazil | Europe or an Art. 45-adequate country (MEGA) | LGPD Art. 33 IX + Art. 7 V | ✅ Ground established — §7.3 |
| Daily backups, for EEA subjects in the copy | EEA | Europe or an Art. 45-adequate country (MEGA) | GDPR Art. 45 | ✅ Adequacy — §7.3 |
Google ID token verification | — | — | — | Not a transfer — §6.1 |
Two outbound flows carry personal data: Mailgun and the backups. They are not comparable in
scale. Mailgun receives one address at a time, at the moment a person asks for something; the backup
is a full copy of every category in §3, every day. The Google leg was recorded here in error and is
corrected in §6.1: the token is validated offline against cached public certificates and is never
sent to Google.
7.1 Email delivery to Mailgun
LGPD: Art. 33 IX, resting on Art. 7 V. Art. 33 IX permits a transfer “quando necessário para
atender as hipóteses previstas nos incisos II, V e VI do art. 7º desta Lei”, and Art. 7 V is
“quando necessário para a execução de contrato ou de procedimentos preliminares relacionados a
contrato do qual seja parte o titular, a pedido do titular dos dados”.
Every email this API sends is one of exactly three things: the verification that activates an
account somebody asked for, the reset that restores access to it, or the second factor that lets
them in. Each is necessary to perform the service the person is party to, and each is sent at their
own request. The address reaching a mail provider is not incidental to that service — it is the
service being performed.
This is the same basis §5 already records for the underlying processing, which is what makes it
coherent rather than opportunistic: the transfer rests on the ground the processing itself rests on,
not on a different one found for the occasion.
Why not Art. 33 II. The cláusulas-padrão contratuais route would require the ANPD’s own
clauses (Resolution CD/ANPD nº 19 of 23 August 2024) in the Mailgun contract. Sinch’s published DPA
is automatically incorporated and carries the EU Standard Contractual Clauses, with a Brazil
annex that references the LGPD — but not the ANPD clauses. The ANPD may recognise foreign clauses as
equivalent, and doing so requires a published Resolution of its Conselho Diretor; absent one for the
EU SCCs, Art. 33 II would rest on an equivalence nobody has declared. The transition period for
incorporating the ANPD clauses closed on 23 August 2025.
Art. 33 IX is a self-standing statutory ground that needs no counterparty agreement, which is why it
is the one relied on.
GDPR, if it applies at all (§7.2). Sinch’s Data Processing Agreement forms part of the services
agreement automatically, with no separate signature, and incorporates the EU Standard Contractual
Clauses (Commission Decision 2021/914/EU, Modules 2 and 3) with Sinch as data importer. The EEA → US
leg is therefore covered by Art. 46(2)(c) safeguards already in force.
Nothing needs to be signed. Neither ground requires negotiating or executing an agreement:
Art. 33 IX is statutory, and Sinch’s SCCs are incorporated by their own terms.
The Mailgun region remains a configuration choice. Mailgun offers an EU region; moving to it
would not remove the transfer, since the data still leaves Brazil. It would change which second
jurisdiction is involved. Recorded next to MAILGUN_API_KEY in the operations guide so the choice
is made deliberately rather than inherited.
Review trigger. If the ANPD publishes a Resolution recognising the EU SCCs as equivalent, Art.
33 II becomes available through Sinch’s existing DPA with nothing to sign, and is the stronger
ground. Worth checking when this document is next reviewed.
7.2 GDPR applies — determined 10 September 2026
The controller has determined that the service is open to EEA data subjects. Nothing restricts
access by geography once it is published, so GDPR is treated as applying under Art. 3(2). Recorded
here with its date, and revisited if the service’s reach changes.
That settles the question this section was opened for. What follows is the consequence, and it is
narrower than it first appears.
Hosting in Brazil is not, by itself, a restricted transfer
Brazil holds no adequacy decision, so the instinct is that storing EEA subjects’ data here needs
Art. 46 safeguards. For the people who sign up directly, it does not.
EDPB Guidelines 05/2021 set three cumulative criteria for a Chapter V transfer: an exporter subject
to the GDPR, disclosing to a separate importer, in a third country. The guidelines are explicit that
“there will not be a transfer when the data are disclosed directly by the data subject to the
recipient as there will be no exporter”. A person in the EEA creating an account here is sending
their own data; they are not an exporter, and the first criterion fails.
So GDPR applies to that processing in full under Art. 3(2) — every right, every obligation in this
document — but Chapter V does not engage, and no safeguard is required for the hosting itself.
A tenant established in the EEA is an exporter
Where a tenant organisation subject to the GDPR uses a scope for its own users, all three criteria
are met: the tenant is the exporter and controller, Heimdall is the importer and processor, and the
importer is in a third country. That is a restricted transfer and needs Art. 46 safeguards.
The safeguard belongs in the Data Processing Agreement
,
which is Heimdall’s own template rather than a third party’s, so it requires no negotiation with
anybody: §7.1 of that agreement now incorporates the EU Standard Contractual Clauses, Module Two
(controller to processor), with the tenant as exporter and Heimdall as importer. An EEA tenant
executing the DPA receives the safeguard by doing so.
A transfer impact assessment remains the exporting tenant’s to make — it is an assessment of the
importer’s jurisdiction from the exporter’s position, and the exporter is the only party who can
make it. §7.2 of the DPA sets out what Heimdall supplies to support one.
Consequences elsewhere
- The Brazil → United States email flow (§7.1) is unaffected: Art. 33 IX carries it under the
LGPD, and Sinch’s own DPA carries the EU leg under Art. 46(2)(c).
- The Incident Response Document
§4 no longer needs its
provisional instruction to assume GDPR applies. It does apply, and the shorter of the two
notification clocks governs.
The risks this processing creates for the people in it, rated for them rather than for the
system, are the Data Protection Impact Assessment
.
7.3 Daily backups to MEGA
The backups are held by MEGA, encrypted on this side before MEGA receives them. The factual
basis — which countries, what MEGA can and cannot see, and why the facility cannot be pinned — is in
§0.1 of the Operations & Infrastructure Document
.
This section records the grounds.
This is the largest transfer the system performs, and it is worth stating plainly rather than
letting it sit in a table row. Everything in §3 leaves the country daily: every name, every address,
every audit entry, every hash. The email flow’s exposure is one address at the moment somebody asks
for a reset. These are different in kind, and the ground had to be established with that in mind.
LGPD: Art. 33 IX, resting on Art. 7 V — the same construction §7.1 uses for Mailgun, and for the
same reason. A backup is not a convenience separable from the service: an identity provider that
cannot be restored is one that loses every account it holds on a single disk failure, and the
contract the subject is party to is precisely that their identity will still exist tomorrow.
Art. 7 V’s “necessário para a execução de contrato” covers the durability of the thing being
executed, not only its moment-to-moment operation.
Why not Art. 33 I, despite the adequacy decisions. MEGA stores in Europe or in countries the
European Commission has ruled adequate under GDPR Art. 45. That is a European finding and the LGPD
does not import it. Art. 33 I requires a country recognised as adequate by the ANPD, and the
ANPD has recognised none — the list does not exist yet. Relying on Art. 33 I here would mean citing
one regulator’s decision to satisfy another’s statute.
Why not Art. 33 II. The same obstacle as §7.1, and a firmer one: MEGA’s terms are not negotiated
per customer, so the ANPD’s standard contractual clauses could only enter by MEGA choosing to adopt
them. Nothing suggests they have.
GDPR: Art. 45 adequacy. Where the copy contains EEA subjects’ data — from an EEA tenant, per
§7.2 — the destination is either inside the EEA or a country covered by a subsisting adequacy
decision. New Zealand’s is Commission Implementing Decision 2013/65/EU, which the Commission
confirmed remains in force in its January 2024 review. Art. 45 transfers require no further
authorisation, so nothing needs to be signed for this leg either.
And a supplementary measure that would carry it without adequacy. The backup is encrypted before
it leaves and MEGA holds no key that decrypts it, which is EDPB Recommendations 01/2020 Use Case
1 almost exactly: encrypted storage in a third country, for backup, where the importer never needs
the data in the clear and the keys stay with the exporter. This is recorded because it is what makes
the analysis robust to the thing most likely to change — an adequacy decision being suspended or
annulled, as one has been before. If that happened, the encryption is the argument that survives it,
and the review trigger below exists to make somebody check.
Review trigger. If any adequacy decision MEGA relies on is suspended or annulled, the GDPR leg
falls back on the encryption as its supplementary measure and needs re-documenting under Art.
46 — not re-engineering. If the ANPD publishes an adequacy list, Art. 33 I becomes available for
whichever destinations appear on it, and is the stronger ground.
The residual exposure is the account password, not the jurisdiction. Both grounds above assume
MEGA cannot read the copy, and that assumption rests entirely on the account credentials. It is
recorded as an operational control in §0.1 of the Operations document rather than left implicit
here, because it is the one thing in this section that can fail quietly.
8. Security measures
Not restated here. LGPD Art. 46 and GDPR Art. 32 are addressed by the controls the
Threat Model
documents and NFR-02, NFR-03, NFR-13, NFR-15, NFR-16
and NFR-17 require. What that model deliberately does not cover — encryption at rest, backups, and
breach detection — is
#106
and
#105
.
9. Data subject rights
| Right | LGPD | GDPR | Where |
|---|
| Confirmation and access | Art. 18 I–II | Art. 15 | ✅ UC-41, self-service |
| Portability | Art. 18 V | Art. 20 | ✅ UC-41 — structured JSON, machine-readable |
| Correction | Art. 18 III | Art. 16 | ✅ UC-08, self-service |
| Erasure / elimination | Art. 18 VI | Art. 17 | ✅ UC-42, completed by NFR-20 |
| Restriction of processing | Art. 18 IV | Art. 18 | ✅ UC-44, self-service |
| Information about sharing | Art. 18 VII | Art. 15(1)(c) | ✅ §6 of this document, and in every UC-41 export |
| Objection | Art. 18 § | Art. 21 | ✅ UC-44 with the ObjectionPending ground, which restricts processing while the objection is weighed |
Deadlines: LGPD Art. 19 §2 — 15 days for confirmation and access; GDPR Art. 12(3) — one
month. The shorter one governs where both apply.
10. Reviewing this document
Reviewed when a migration adds or removes a column holding personal data; when a recipient or
sub-processor changes; when the hosting region changes; when a transfer mechanism is executed or
lapses; and when §7.2’s EEA determination is made or changes.
§5.1 is enforced rather than trusted. PersonalDataPurposeTests reflects over the entities that
hold personal data and fails if a property is missing from the table above. A record of processing
that silently falls behind the schema is worse than none, because it is relied on; this makes a new
column arrive with a stated purpose or not arrive at all.
8 - Privacy Notice
What Heimdall does with personal data, told to the people it belongs to — LGPD Art. 9, GDPR Art. 13 and 14.
Privacy Notice — Heimdall API
Version 1.2 — 5 October 2026
1. What this is
Heimdall is a centralised identity service. If you have an account with a system that uses it, this
notice tells you what it holds about you, why, for how long, and what you can make it do.
It is written to be read by the person it is about. The formal record behind it — legal bases,
recipients, transfer mechanisms — is the
Data Protection Document
.
The version matters. This notice is versioned, and the version in force when your account was
created is recorded against it, so it is always possible to say what you were actually told rather
than what you would be told today.
2. Who is responsible
| |
|---|
| Controller | Artur Rios |
| Encarregado (Data Protection Officer) | Artur Rios |
| Contact for anything in this notice | arturdev@duck.com |
Published here because LGPD Art. 41 §1 requires the Encarregado’s identity and contact to be
publicly disclosed.
If your account belongs to another organisation’s system, that organisation decides why your
data is held, and it is the controller for it — Heimdall processes it on their instructions. You can
raise anything with the address above, and it will be answered or passed to them; the
Data Processing Agreement
sets out which.
3. What is held about you
| |
|---|
| Your name and email address | To identify your account and to write to you about it |
| Your password, stored only as a hash | To check it is you signing in. The password itself is never stored and cannot be recovered from what is |
| Two-factor settings and codes | If you turn two-factor authentication on. The secret is encrypted; recovery codes are stored only as hashes |
| Your Google account identifier, name, address and profile picture | Only if you sign in with Google, and only what Google gives us |
| Failed sign-in attempts and lockout state | To stop somebody guessing your password |
| A record of actions taken on your account | To be able to say what happened, and when, if it is ever disputed |
Your IP address is recorded, with the time, in the service’s logs for every request you make, so
that attempts to break into accounts can be detected and investigated. It is also used briefly, in
memory, to limit how many sign-in attempts can come from one place in a minute.
Nothing here is sold, and nothing is used for advertising or profiling. No decision affecting
you is made automatically.
4. Why, and on what basis
Almost everything here is held because it is necessary to run an account you asked for — LGPD
Art. 7 V, GDPR Art. 6(1)(b). The security data is held on legitimate interests (LGPD Art. 7 IX,
GDPR Art. 6(1)(f)): defending accounts against people trying to break into them. The erasure records
are held because the law requires it (LGPD Art. 7 II, GDPR Art. 6(1)(c)).
We do not ask you to consent to any of it, deliberately. Consent can be withdrawn at any moment, and
an identity service that stopped checking passwords when you withdrew it would not be protecting
you — it would be broken. Basing it on necessity instead is more honest about what is actually
happening.
5. Who else sees it
| |
|---|
| Mailgun (United States) | Receives your email address to deliver verification, password reset and two-factor messages |
| The organisation whose system you use | Sees the accounts in its own scope |
| The hosting provider (Brazil) | Stores the database |
Data is held in Brazil, and your email address is sent to the United States to deliver the
messages above. That is an international transfer, made because sending you the email that activates
or restores your account is part of running the account you asked for — LGPD Art. 33 IX with Art. 7
V, and where it applies, the standard contractual clauses in Sinch’s own terms. The detail is in
§7 of the Data Protection Document
.
If you sign in with Google, nothing about you is sent to Google by us. Google is where your name,
address and picture came from — the token it issues when you sign in is checked here against
Google’s published certificates, on this server. Your sign-in at Google itself is between you and
Google, under their terms.
6. How long it is kept
The full schedule is the Data Retention Schedule
. The
short version:
| |
|---|
| Your account | While it exists |
| If you ask to be erased | Anonymised within 30 days |
| If an administrator deletes your account | Anonymised after 90 days, so a mistake can be undone |
| Password reset and verification links | A week after they expire |
| Records of actions on your account | 18 months, then stripped of anything identifying you |
| Logs of your requests, including your IP address | 12 months, then deleted |
“Anonymised” means the name, address and credentials are overwritten and cannot be recovered. The
row itself stays, because other records point at it, but nothing in it refers to you any more.
7. What you can do
| You can | How | By when |
|---|
| Be erased | POST /api/auth/erasure-request — you will be asked for your password, or a fresh Google sign-in, because it cannot be undone | 30 days |
| Correct your details | Update your own record | Immediately |
| Get a copy of your data | POST /api/auth/data-export — everything held about you, plus who else sees it and how long it is kept | Immediately |
| Have processing restricted, or object | POST /api/auth/processing-restriction — suspends your account without deleting anything, while something about it is disputed | Immediately |
| Ask anything, or complain | arturdev@duck.com | 15 days |
One of those is honestly marked unavailable. Publishing a notice that promised rights the system
cannot yet deliver would be worse than admitting the gap: it is the kind of claim a regulator checks.
You can also complain to a supervisory authority. In Brazil that is the
ANPD
. If you are in the EEA, it is your national data protection
authority — the GDPR applies to this service, and the rights in this notice are yours under it as
well as under the LGPD.
If you ask to be erased and you are the last administrator of an organisation’s scope, the request
is still recorded and the clock still runs, but it cannot be completed until that organisation
appoints another administrator. You will be told if that applies to you.
8. Changes
This notice is versioned. Material changes raise the version, and the version you were shown is
recorded against your account.
| Version | Date | Change |
|---|
| 1.2 | 5 October 2026 | Your IP address is now recorded in the request logs, kept for 12 months |
| 1.1 | 10 September 2026 | Confirmed the GDPR applies; corrected Google’s role from recipient to source |
| 1.0 | 9 September 2026 | First published |
9 - Data Processing Agreement
The terms on which Heimdall processes a tenant’s users’ data — LGPD Art. 39, GDPR Art. 28.
Data Processing Agreement — Heimdall API
Template version 1.0 — 9 September 2026
0. How to read this
This is the agreement between Heimdall’s controller (Artur Rios — the Processor below) and an
organisation whose client system uses a Heimdall scope (the Controller below), covering the
personal data of that organisation’s users.
It is a template. Executing it means naming the organisation, the scope or scopes, and the date, and
signing it. Everything else is drafted to match what Heimdall actually does — the clauses cite the
mechanisms in this repository rather than describing a system in general terms, so a clause that
stops being true becomes a failing document rather than a comfortable one.
GDPR Art. 28(3) lists what such an agreement must contain; LGPD Art. 39 requires the processor to
follow the controller’s instructions. Sections 2 to 9 map onto Art. 28(3)(a) to (h) in order.
1. Parties, subject matter, duration
| |
|---|
| Controller | [organisation name] |
| Processor | Artur Rios — arturdev@duck.com |
| Scope(s) | [scope name(s) and PublicId(s)] |
| Subject matter | Identity management, authentication and authorisation for the Controller’s users |
| Duration | For as long as the Controller has a live scope, and through the deletion steps in §9 |
| Nature and purpose | Storing identities; verifying credentials; issuing tokens; sending transactional email; recording an audit trail |
| Categories of data subject | The Controller’s end users, and its scope administrators |
| Categories of personal data | Name, email address, password hash and salt, two-factor material, Google account identifier and profile picture, sign-in failure counters, audit records |
| Special categories | None |
2. Processing only on documented instructions — Art. 28(3)(a)
2.1 The Processor processes the Controller’s personal data only on the Controller’s documented
instructions. The instructions are the API: each endpoint the Controller calls is an instruction,
and the API Reference
with the
Use Case Specification
is the documented set of them.
Anything outside that set requires a written instruction.
2.2 The Processor additionally performs, without a per-instance instruction, the processing this
agreement itself provides for: the retention passes in §6, the audit trail in §4.3, and the security
measures in §4.
2.3 If the Processor considers an instruction to infringe the LGPD, the GDPR or another applicable
law, it will say so before acting on it (GDPR Art. 28(3), second paragraph).
2.4 The Processor does not use the Controller’s data for its own purposes. It is not sold, not
used for advertising, not used to train anything, and not used to build any product other than the
service provided to the Controller.
3. Confidentiality — Art. 28(3)(b)
3.1 Everybody authorised to process the Controller’s data is bound to confidentiality. At present
that is the Processor alone; adding anyone brings them within this clause before they are given
access.
3.2 Administrative access to production data is limited to what an operational task requires, and
every write is recorded by the audit trail (§4.3).
4. Security measures — Art. 28(3)(c), Art. 32, LGPD Art. 46
4.1 The Processor maintains the measures set out in the
Threat Model
and required by NFR-02, NFR-03, NFR-13, NFR-15,
NFR-16 and NFR-17. Specifically:
- passwords stored as Argon2id hashes with a per-person salt;
- TOTP secrets encrypted at rest; recovery codes stored only as hashes;
- password reset and email verification tokens stored only as SHA-256 digests;
- Google ID tokens verified for signature, issuer, audience and expiry before any claim is trusted;
- internal database identifiers never exposed; only random
PublicId values cross the boundary; - per-account lockout and per-IP rate limiting against credential guessing;
- TLS on inbound connections.
4.2 Known gaps are disclosed rather than glossed. As at this template version:
- Encryption at rest for the database and its backups is required and documented (NFR-25) but
not verified from the repository — it is a property of the hosting, and the Controller should
ask the Processor to evidence it before executing.
- The database connection warns at start-up when it does not require TLS, rather than refusing to
start. Confirming TLS and turning that into a refusal is outstanding.
- Breach detection is in place (NFR-26) and writes matchable signals to the log, but where those
alerts are routed is the Processor’s operational choice and should be confirmed: a signal
nobody receives is a signal that has not been detected. The incident procedure, the deadlines and
the register are the Incident Response Document
.
4.3 Every attempted write produces an audit entry recording the actor, the operation, the outcome and
— on a refusal — the reason (NFR-09). The table is append-only, enforced by database triggers.
5. Sub-processors — Art. 28(2) and 28(3)(d)
5.1 The Controller gives general authorisation for the sub-processors listed in §7.
5.2 The Processor will give the Controller at least 30 days’ notice before adding or replacing a
sub-processor, during which the Controller may object. If the objection cannot be resolved, the
Controller may terminate and take the deletion or return in §9.
5.3 Each sub-processor is bound by terms no less protective than these, and the Processor remains
fully liable to the Controller for their performance.
6. Assisting the Controller — Art. 28(3)(e) and (f)
6.1 Data subject rights. Where a request reaches the Processor directly, it is handled as
follows:
| Request | Handled by |
|---|
| Erasure | The Processor, through UC-42, completed automatically within 30 days |
| Correction | The Controller’s own users, through UC-08 |
| Access and portability | ⚠️ Not yet available — #90
. Until it lands, the Processor extracts the data manually on request |
| Restriction and objection | ⚠️ Not yet available — #93
. Referred to the Controller |
6.2 A request the Processor cannot satisfy is forwarded to the Controller within 3 working days,
leaving the Controller the remainder of the 15-day (LGPD Art. 19 §2) or one-month (GDPR Art. 12(3))
period to answer. The Processor does not answer a data subject on the Controller’s behalf without
instruction.
6.3 Retention is enforced automatically, on the schedule in the
Data Retention Schedule
, and the Controller cannot
lengthen it beyond what that schedule permits. Shortening it for a scope is available on written
instruction.
6.4 The Processor assists the Controller with data protection impact assessments and prior
consultation (Art. 35, 36) by supplying the documentation in this repository, which is public.
7. Sub-processor list
| Sub-processor | Purpose | Location | Transfer mechanism |
|---|
| Mailgun (Sinch) | Transactional email delivery | United States | LGPD Art. 33 IX with Art. 7 V; EU SCCs incorporated by Sinch’s own DPA — Data Protection Document §7.1 |
| Hosting provider | Database and API hosting | Brazil | No transfer; data remains in Brazil |
| MEGA (Mega Cloud Services Limited) | Encrypted off-server backups, daily | Europe or an Art. 45-adequate country; never the US | LGPD Art. 33 IX with Art. 7 V; GDPR Art. 45 adequacy, with client-side encryption as a supplementary measure — Data Protection Document §7.3 |
MEGA cannot read what it holds. The backup is encrypted before it is uploaded and MEGA has no
key that decrypts it, so the sub-processor listed above receives ciphertext and metadata. It is
listed anyway, and named: a controller assessing this chain is entitled to know who holds a copy of
their users’ data even when that holder cannot read it, and objecting to a sub-processor is a right
that requires knowing there is one.
Google is not a sub-processor. It was listed as one until the transfer assessment was corrected:
the ID token is validated offline against Google’s cached public certificates and nothing about the
data subject is ever sent to Google. It is a source of personal data, not a recipient — see Data
Protection Document §6.1.
Personal data is stored in Brazil. One outbound flow leaves Brazil — email delivery — and its
ground is recorded in §7 of the Data Protection Document
, which
is the live version of this table.
7.1 Transfers from the EEA — Standard Contractual Clauses
Brazil holds no EU adequacy decision. Where the Controller is subject to the GDPR, its
disclosure of personal data to the Processor in Brazil is a restricted transfer under Chapter V and
requires Art. 46 safeguards.
The European Commission’s Standard Contractual Clauses (Implementing Decision (EU) 2021/914),
Module Two — controller to processor — are incorporated into this agreement by reference, with:
| |
|---|
| Data exporter | The Controller named in §1 |
| Data importer | The Processor named in §1 |
| Docking clause (18) | Applies |
| Clause 9 — sub-processors | Option 2, general authorisation, with the 30 days’ notice §5.2 sets |
| Clause 11 — redress | The optional independent dispute resolution body is not selected |
| Clause 17 — governing law | The law of an EEA Member State allowing third-party beneficiary rights; Ireland, absent another choice by the parties |
| Clause 18(b) — forum | The courts of that Member State |
| Annexes I, II, III | §1 (parties, subject matter, categories), §4 (technical and organisational measures), and §7’s sub-processor list respectively |
Where §11’s choice of Brazilian law would otherwise conflict, the Clauses prevail for the transfer
they govern — Clause 5 requires that, and the Clauses cannot be varied in a way that contradicts
them.
No separate signature is needed. Executing this agreement executes the Clauses, which is the
point of incorporating them here rather than sending them separately.
7.2 The transfer impact assessment is the Controller’s
Schrems II requires the exporter to assess whether the importer’s jurisdiction undermines the
Clauses in practice. That assessment is the Controller’s to make: it is an evaluation of Brazil
from the exporter’s position, and the exporter is the only party who can make it.
The Processor supplies, on request and without charge, what an assessment needs: the location of
processing and of backups, the sub-processors and their locations, the technical measures in §4, and
a record of any government access request received. To the date of this template version, no
government or law enforcement authority has requested access to any personal data processed under
this agreement.
7.3 Direct sign-up is not a transfer
Where a person in the EEA creates an account with Heimdall themselves, there is no restricted
transfer: EDPB Guidelines 05/2021 require an exporter subject to the GDPR, and a data subject
sending their own data is not one. GDPR still applies to that processing under Art. 3(2) — every
right in this agreement and in the Privacy Notice — but Chapter V does not engage, and §7.1’s
Clauses are not needed for it.
This matters for a Controller deciding whether §7.1 applies to them: it turns on whether the
Controller is subject to the GDPR and discloses its users’ data to the Processor, not on where the
users happen to live.
8. Breach notification — Art. 28(3)(f), Art. 33(2), LGPD Art. 48
8.1 The Processor notifies the Controller of a personal data breach affecting the Controller’s data
without undue delay and in any event within 24 hours of becoming aware of it. What counts as a
breach, and when awareness is taken to begin, are defined in the
Incident Response Document
§3 and §4.
8.2 The 24 hours is deliberately shorter than the Controller’s own deadline. The Controller has 72
hours under GDPR Art. 33 and 3 working days under ANPD Resolution CD/ANPD nº 15/2024, both counted
from the Controller’s awareness — which a notification from the Processor is what creates. A
processor deadline equal to the controller’s would leave the controller no time at all.
8.3 The notification describes what is known: the nature of the breach, the categories and
approximate number of data subjects and records, the likely consequences, and the measures taken.
Where not all of it is available at once, it is given in phases rather than withheld until complete.
8.4 The Processor does not notify a supervisory authority or the data subjects on the Controller’s
behalf unless instructed in writing.
9. Deletion or return at the end — Art. 28(3)(g)
9.1 On termination, at the Controller’s election:
- Return — the Processor exports the scope’s identities and provides them to the Controller; or
- Delete — the Processor hard-deletes the scope, which cascades to its users, Google Users,
applications and permissions (NFR-08, NFR-14).
9.2 The election is made within 30 days of termination. Absent an election, the Processor
deletes, because retaining personal data with no controller and no purpose is the worse default.
9.3 Two things are retained after deletion, and both are disclosed rather than assumed:
- Audit entries naming the scope’s operations, for which the Processor is controller (Data
Protection Document §3), retained for 18 months and then stripped of anything identifying a
person.
- Backups taken before termination, until they age out of the backup retention period —
proposed at 35 days. A restore is followed by re-applying every erasure completed since the backup
was taken, from a ledger kept outside the database; the mechanism is implemented and tested
(NFR-25), and the retention figure awaits the Controller’s confirmation.
10. Audit — Art. 28(3)(h)
10.1 The Processor makes available the information necessary to demonstrate compliance with this
agreement. Most of it is already public: this repository holds the requirements, the threat model,
the retention schedule, the record of processing, and the test suite that enforces them.
10.2 The Controller may audit no more than once a year, on 30 days’ notice, at its own cost, unless
an audit follows a breach — in which case neither the frequency limit nor the cost allocation
applies.
11. Governing law
Brazilian law, with the courts of the Processor’s domicile, without prejudice to a data subject’s
right to bring proceedings where they are entitled to under LGPD Art. 22 or GDPR Art. 79.
12. Signatures
| Controller | Processor |
|---|
| Name | | Artur Rios |
| Title | | Controller / Encarregado |
| Date | | |
| Signature | | |
10 - Incident Response Document
What counts as a personal data breach, who declares one, the clocks that start, and who has to be told.
Incident Response Document — Heimdall API
1. Purpose
GDPR Art. 33 requires a personal data breach to be notified to the supervisory authority within 72
hours of the controller becoming aware of it, and Art. 34 requires the affected data subjects be
told without undue delay where the risk to them is high. LGPD Art. 48 requires communication to the
ANPD and the subjects, which ANPD Resolution CD/ANPD nº 15 of 24 April 2024 fixes at 3 working
days. Art. 33(5) requires every breach be documented, whether or not it was notifiable.
None of that is possible without deciding, in advance and in writing, what counts and who decides.
That is what this document is.
2. Who decides
| Role | Who | What they do |
|---|
| Incident lead | Artur Rios (controller) | Declares an incident, decides whether it is a personal data breach, decides whether it is notifiable, and signs the register entry |
| Encarregado | Artur Rios | Notifies the ANPD, and the data subjects where required |
| Processor contact | Artur Rios | Notifies tenant controllers whose scopes are affected |
All three are the same person, which for a project this size is honest rather than a gap to paper
over — see §2 of the Data Protection Document
. The consequence
worth naming: there is nobody to escalate to, so the deadlines in §4 are the only forcing function,
and they are why they are written down.
3. What counts as a personal data breach
GDPR Art. 4(12): a breach of security leading to accidental or unlawful destruction, loss,
alteration, unauthorised disclosure of, or access to personal data. Not only theft — losing data
irrecoverably is a breach, and so is altering it without authority.
Concretely, for this system:
| Is a breach | Is not, by itself |
|---|
| The database or a backup copied by somebody unauthorised | A failed sign-in, however many |
| An identity acting outside its authorisation and reaching another tenant’s data | A refused authorisation attempt — that is the control working |
| Password hashes, TOTP secrets or tokens disclosed | An expired token being rejected |
| An erasure that did not happen, or was undone by a restore and not reconciled | A scheduled pass failing once and succeeding on the next tick |
| Log files containing personal data leaving the host unencrypted | Logs reaching the operator’s own collector as designed |
| A backup restored over current data, losing records irrecoverably | A restore that was reconciled per NFR-25 |
A signal from §6 is not a breach. It is a reason to look. Treating detection as declaration
would put the 72-hour clock in the hands of a threshold.
4. The clocks, and when they start
Both run from awareness, not from the incident. Awareness is when the incident lead has enough
information to conclude, on reasonable investigation, that personal data was probably affected — not
the moment of certainty, and not the moment a log line was written that nobody read.
| Obligation | Deadline | From |
|---|
| Notify the ANPD (LGPD Art. 48, Res. 15/2024) | 3 working days | Awareness |
| Notify the supervisory authority (GDPR Art. 33) | 72 hours | Awareness |
| Notify affected data subjects (Art. 34, LGPD Art. 48) | Without undue delay, where risk is high | Awareness |
| Notify a tenant controller whose scope is affected | 24 hours — DPA §8 | Awareness |
The 24 hours to tenants is deliberately the shortest. A tenant’s own 72-hour clock starts when they
become aware, which our notification is what creates; a processor deadline equal to the controller’s
would leave the controller no time at all.
GDPR applies. The determination was made on 10 September 2026 and is recorded in §7.2 of the
Data Protection Document: the service is open to EEA data subjects. Both sets of deadlines are
therefore live, and the shorter of the two governs — three working days to the ANPD, and, where
a supervisory authority is also owed notice, seventy-two hours.
5. The procedure
- Contain. Stop the bleeding before investigating. Revoke the signing secret
(
HEIMDALL_AUTH_TOKEN_SECRET, rotating through _PREVIOUS), restrict or suspend the identities
involved (UC-44), and if a scope is implicated, consider disabling its Google sign-in. - Record the time of awareness. In the register, before anything else. Every deadline in §4
counts from this timestamp, and reconstructing it afterwards from memory is how a 72-hour
obligation becomes a missed one.
- Establish scope. Which identities, which scopes, which categories of data, and over what
period. The audit trail is the primary source; the application logs are the second.
- Decide: is it a personal data breach? Against §3. Record the reasoning either way.
- Decide: is it notifiable? A breach is notifiable unless it is unlikely to result in a risk to
the rights and freedoms of the data subjects. Record the reasoning either way — this is the
part regulators ask about, and a register full of entries reading “not notifiable” with no
reasoning is worse than no register.
- Notify, per §4.
- Complete the register entry, per §7.
- Review. What made it possible, what made it detectable, what delayed either. Feed the answers
into the Threat Model
and the periodic review in the
Development Workflow
.
6. Detection
NFR-26. The system produced strong signals from the beginning and nothing consumed any of them — the
audit trail recorded every refused write with its reason, the lockout counters tracked per-account
brute force, and the hash gate shed under load. All written down; none read. A trail nobody reads
does not make anyone aware of anything, and awareness is what starts every clock in §4.
SecurityMonitoringService now reads them on a schedule and writes what it finds to the log at
warning level, each line carrying the marker SECURITY_SIGNAL and a stable Kind:
| Kind | Raised when | Why it matters |
|---|
REPEATED_REFUSALS | One identity has more refused writes than the threshold, within the window | Sustained refusals are what somebody probing for what they may do looks like |
LOCKOUT_SPIKE | More accounts are locked out at once than the threshold | One is ordinary; many together is credential stuffing against a list of addresses |
CREDENTIAL_VERIFICATION_SHEDDING | The hash gate refuses more often than the threshold | The process is at its concurrent-derivation limit (TH-03) — a load condition, and also a lever |
Every threshold is a rate, not a total. Twenty refusals over a year is somebody who forgets their
password; twenty in a quarter of an hour is somebody trying things. A total alerts on the first and
never notices the second.
Signals repeat while the condition persists. There is no suppression, deliberately: an attack
that continues is more interesting on its tenth tick than its first, and a detector that goes quiet
while the thing it detects is still happening is worse than none.
Where the alerts go is the operator’s choice. This API has no opinion about pagers or SIEMs, and
building an integration for one would be guessing. What it does is make the signal unmistakable and
machine-matchable where the operator already collects — the logs. Match on SECURITY_SIGNAL and the
Kind, not the prose, which is written for whoever reads the line afterwards.
A signal is a warning-level line in the log, on stdout and in the log directory:
SECURITY_SIGNAL REPEATED_REFUSALS: One identity had 23 writes refused in the last
00:15:00. ... (count 23, actor 3f2a91c4-7d55-4e1a-9b2f-08c7d1e6a934)
What collects them
scripts/security_signals.py, on a schedule. It reads the log files, finds the signals it has not
reported before, and mails them to one address through the Mailgun account the API already uses.
Setup — the environment variables, the cron line, and what to do about the log directory living in a
Docker volume — is in the operations guide, under Security signal detection. This section records
what the job means for §4.
It is the thing that makes somebody aware, which is the moment every deadline in §4 starts. The
alert says so in its own body, because the timestamp that goes in the register is when a person read
the message — not when the signal was raised, and not when the job ran.
Three properties are worth knowing before an incident rather than during one:
- Each signal is reported once. The job keeps a watermark. Signals repeat on every monitoring
tick while a condition persists — deliberately, per above — and without a watermark every run
would resend the history until the alerts were filtered into a folder, which is the same
non-detection this closes, wearing a different hat.
- A delivery failure is loud. The job exits non-zero and prints the signals it was holding to
stderr, so a broken collector announces itself. Give cron a
MAILTO, or the failure of the thing
that catches failures is itself uncaught. - The watermark only advances after delivery succeeds. A Mailgun outage delays signals; it does
not consume them.
What is in the alert. The marker, the kind, the count, the timestamp and the actor’s PublicId.
A PublicId is a pseudonym — it names an account to somebody holding the database and to nobody
else — so the alert can be read on a phone without carrying anybody’s address into a mailbox. NFR-22
keeps addresses out of the logs in the first place; the alert inherits that rather than re-deciding
it.
The residual is the mailbox. Delivery is verifiable and being read is not. Nothing here can
tell whether the message was opened, which is why R-08 in the DPIA
is Low rather than closed. A signal that arrives and sits unread is still a signal nobody received.
Detection is off only if HEIMDALL_MONITORING_ENABLED=false, which logs a warning saying what has
been given up.
7. The breach register
Art. 33(5) requires documentation of every breach, including those judged not notifiable. Keep
it outside this repository — it will contain details of real incidents — and use this shape:
| Field | Notes |
|---|
| Reference | Sequential |
| Time of awareness | The timestamp from step 2. Every deadline counts from here |
| Time of the incident | Where known; often it is not, and “unknown” is a legitimate entry |
| How it came to attention | A SECURITY_SIGNAL, a report, a routine review — this is what tells you whether detection works |
| Nature | Destruction, loss, alteration, disclosure, or unauthorised access |
| Categories and approximate number of data subjects | Approximate is what Art. 33(3)(a) asks for |
| Categories and approximate number of records | |
| Likely consequences | Art. 33(3)(c) |
| Measures taken | Containment, remediation, mitigation of adverse effects |
| Notifiable? With reasoning | Both answers need reasoning. “No” needs it most |
| Authority notified, when | Or why not |
| Subjects notified, when | Or why not |
| Tenant controllers notified, when | Per DPA §8 |
| Signed off by | The incident lead |
A register entry is written for an incident judged not to be a breach at all, too, with that
reasoning. The judgement is the evidence that the question was asked.
8. Reviewing this document
Reviewed after every incident, and on the cadence in the
Development Workflow
— whichever comes first. An untested
incident procedure is a document, not a capability.
11 - Testing Specification Document
How each use case is tested — the unit and functional standards and the required coverage.
Testing Specification Document — Heimdall API
1. Purpose
This document defines how a use case is tested once it has been implemented. It is a standard
to be followed by any human or agent that builds tests for this project. Following it guarantees
that every use case (UC-01 … UC-35 in the Use Case Specification Document
)
receives the same shape of testing, with the same tools, naming, and structure.
The rule is simple:
After a use case is developed, tests are built for it in the same change — before the use case
is considered done. A use case without its tests is an incomplete use case.
2. Testing philosophy
- Behavior-driven. Tests describe behavior, not implementation. Every test is written in the
Given / When / Then style and is named accordingly.
- Test at the right layer. Business logic (Command/Query handlers and any Domain class that
implements behavior) is covered by unit tests. The Presentation layer (the Web API) is
covered by functional (end-to-end) tests.
- Isolation in unit tests. A unit test exercises one class. Every external dependency of that
class is replaced by a test double (fake/mock). Only the behavior of the method under test is
asserted.
- Realism in functional tests. A functional test exercises the API exactly as a client would —
over HTTP, against a real PostgreSQL database provisioned on the fly with Testcontainers.
Both the API response and the resulting database state are asserted.
- Same pattern every time. The workflow in §9 is applied identically to every use case, so the
test suite stays predictable as the system grows.
2.1 The coverage floor
Merged line coverage must stay at or above 90%. scripts/coverage.py enforces it, in CI and on
a developer machine alike, and the build fails below it. It was measured at 96.7% when the floor was
introduced, so the number is a floor with room under it rather than one the suite has to be nursed
against.
That gap is deliberate. A threshold set just under the current figure fails on the next honest
refactor and teaches everybody to lower it; one set well under catches the thing worth catching,
which is a subsystem arriving with no tests at all, not a method. Coverage is a floor here and
nothing more — §3 is what actually decides whether a use case is tested, and a change can hold 96%
while testing none of the behavior that matters.
Branch coverage is reported but not gated. It was 77.4% at the same measurement, so gating it at 90%
would have failed the build on the day it was added, and a threshold chosen to be whatever passes
measures nothing. Raising it is a decision to take deliberately, by writing the missing tests first.
3. What to test for each use case
When a use case is implemented, walk this decision list and produce every applicable test:
| Artifact produced by the use case | Test kind | Test project |
|---|
A Command handler (*CommandHandler) | Unit | *.Command.Tests |
A Query handler (*QueryHandler) | Unit | *.Query.Tests |
Input validators (*Validator) | Unit (alongside the handler that uses them) | *.Command.Tests / *.Query.Tests |
| A Domain class that implements behavior (methods with logic, invariants, calculations) | Unit | *.Domain.Tests |
| A Controller endpoint exposing the use case | Functional | *.WebApi.Tests |
Notes:
- Anemic domain entities (plain data holders with only properties and navigation collections,
e.g.
Scope
) carry no
behavior and therefore get no unit tests — their behavior is observed through the handlers and
functional tests. A domain class earns its own unit tests the moment it gains a method that makes
a decision (guard clause, state transition, hashing, token validity, etc.). - Every use case that reaches the API must have functional coverage, even when its handler is
already unit-tested. The two layers verify different things (see §6 and §7).
4. Solution & project layout
Tests live under a top-level tests/ directory that mirrors the src/ layer folders. Each
production project has exactly one corresponding test project, named by appending .Tests to
the project name. Each production class has one corresponding test class, named by appending
Tests to the class name.
src/
Application/
ArturRios.Heimdall.Command/ → CreateScopeCommandHandler.cs
ArturRios.Heimdall.Query/ → GetScopeByIdQueryHandler.cs
ArturRios.Heimdall.Shared/ → ScopeOwnershipChecker.cs
Domain/
ArturRios.Heimdall.Domain/
Infrastructure/
ArturRios.Heimdall.Data/ → Seeding/MasterUserOptions.cs
Presentation/
ArturRios.Heimdall.WebApi/ → ScopeController.cs
tests/
Application/
ArturRios.Heimdall.Command.Tests/ → CreateScopeCommandHandlerTests.cs
ArturRios.Heimdall.Query.Tests/ → GetScopeByIdQueryHandlerTests.cs
ArturRios.Heimdall.Shared.Tests/ → ScopeOwnershipCheckerTests.cs
Domain/
ArturRios.Heimdall.Domain.Tests/
Infrastructure/
ArturRios.Heimdall.Data.Tests/ → Seeding/MasterUserOptionsTests.cs
Presentation/
ArturRios.Heimdall.WebApi.Tests/ → ScopeControllerCreateTests.cs (functional)
Rules:
- One test project per production project,
<ProjectName>.Tests. - One test class per production class under test,
<ClassName>Tests, in a namespace that mirrors
the production namespace. - The test project must reference the production project it tests via
<ProjectReference>
(a functional test project references the Web API project; a unit test project references the
Application/Domain project it covers). - Test projects set
<IsPackable>false</IsPackable> and are added to the solution under the matching
Tests solution folder.
Every test project uses the same stack: xUnit v3 as the test framework, coverlet.collector for coverage, ArturRios.Util.Test for shared helpers & test doubles, Moq for mocking, Bogus for test-data generation, and Testcontainers (Testcontainers.PostgreSql) for the functional database.
The canonical list of these testing technologies and their pinned versions lives in the Technology Stack Document
§7. This section covers how to use them; see that document for what (packages and versions).
From ArturRios.Util.Test, always prefer the provided building blocks instead of rolling your own:
- Test attributes (they stamp a
Category trait so suites can be filtered):[UnitFact] / [UnitTheory] → Category=Unit[FunctionalFact] / [FunctionalTheory] → Category=Functional- Each accepts an optional list of
EnvironmentTypes in which the test must not run, and an
optional skip condition.
WebApiTest<TEntryPoint> — base class for functional tests. Spins up an in-memory host via
WebApplicationFactory<T>, and exposes:Gateway — an HttpGateway for issuing HTTP requests.AuthenticateAsync, Authorize, AuthenticateAndAuthorizeAsync — for authenticated calls.
FakeRepository<T, long> — in-memory repository test double for unit tests (seed it in Given).FakeScheduler — simulates delayed command/query dispatch.CustomAssert — extra assertions (NullOrEmpty, NotNullOrEmpty, NullOrWhiteSpace, …).
Beyond the ArturRios.Util.Test helpers:
- Moq is the standard mocking library — use it for every mock/stub of a collaborator that
isn’t already covered by a purpose-built fake from
ArturRios.Util.Test. Do not introduce a second
mocking framework. - Bogus is the standard way to generate test data — use it whenever a test needs populated
entities, commands, or DTOs, instead of hand-writing large object literals or relying on shared
mutable fixtures.
Use dotnet test --filter "Category=Unit" or "Category=Functional" to run one kind at a time.
5.1 Every run records a .trx
tests/Directory.Build.props points every test project at tests/default.runsettings, which enables
the trx logger. An ordinary dotnet test therefore writes a result file per project under
TestResults/ (git-ignored) and prints its path.
This exists because a console summary is not evidence. A functional test failed once in a
full-solution run, the summary was the only record, and the failing test’s name was lost — the
failure never reproduced in 23 subsequent runs, so there was nothing left to investigate. A .trx
costs nothing per run and means the next intermittent failure identifies itself.
To chase a failure that only appears occasionally, repeat the run and let the harness collect the
names:
python scripts/flake_hunt.py --runs 25
It runs the whole solution in a loop, stops at the first failing run (--keep-going measures a rate
instead), and prints every test any run recorded as failed. Hunt with the whole solution rather
than a single project: a failure that only appears under the CPU contention of parallel test
projects will not reproduce from one project alone.
5.2 The Command tests use a patient password-hash gate
Production Argon2id is 600 MB and 16 lanes, so the Command tests derive through
PasswordHashGate.Shared, which bounds concurrency at four. A module initializer
(Support/CommandTestGate.cs) replaces it for the whole assembly with a gate of the same permit
count and a two-minute wait, so a run under CPU or memory pressure queues instead of failing on the
production ten-second refusal. PasswordHashGateTests still pins the production defaults.
6. Unit testing standard (Commands, Queries, Domain behavior)
6.1 Scope of a unit test
A unit test instantiates the class under test directly, passing test doubles for every
constructor dependency, invokes one method, and asserts the outcome. No web host, no database, no
network, no real time.
Handlers in this project return an ArturRios.Output.DataOutput<T> and report failures as errors
on that output rather than by throwing (see
CreateScopeCommandHandler
).
Unit tests therefore assert on output.Success, output.Errors, output.Messages, and
output.Data — not on exceptions (except where the code is genuinely expected to throw).
6.2 Test doubles
- Replace repository dependencies (
IAsyncReadOnlyRepository<T, long>, IAsyncRepository<T, long>) with
FakeRepository<T, long> from ArturRios.Util.Test, seeded in the Given step to represent the
database state the scenario assumes. - Replace other collaborators (validators, mediators, e-mail/token/clock services) with a Moq
mock configured to return the value the scenario needs (
new Mock<IValidator<T>>(),
mock.Setup(...).ReturnsAsync(...), and mock.Object as the dependency). Moq is the single
mocking library for the project — don’t hand-roll one-off fakes when a purpose-built fake from
ArturRios.Util.Test doesn’t already exist, and don’t mix in another mocking framework. - Generate any entities, commands, or DTOs a scenario needs with Bogus (
Faker<T>), rather
than large inline object literals or shared mutable fixtures. Set only the fields the behavior under
test actually depends on; let Bogus fill the rest so tests stay focused and independent. - The goal is always the same: pin every collaborator to a known result so the only variable is the
logic of the method under test.
6.3 Structure & naming
- Test method name:
GivenSomeCondition_WhenSomeAction_ThenSomeOutput. - Method body is split into three commented sections in order:
// Given, // When, // Then. - One logical behavior per test. Use
[UnitTheory] with [InlineData]/[MemberData] for the same
behavior across multiple inputs.
6.4 Coverage per handler
For each Command/Query handler, cover:
- The main (success) flow of the use case.
- Every alternative/exception flow defined for that use case (the
AF-xx rows in the Use Case
Specification). Authorization flows enforced by the framework/middleware (e.g. 403 Forbidden
via [RoleRequirement]) are verified in the functional tests instead, not here.
Example — UC-01 (Create Scope) has main flow + AF-01a (name exists), AF-01b (invalid input /
no owner), AF-01d (owner is not a valid ScopeAdmin). AF-01c (not System Admin) is a functional
concern. That yields unit tests such as:
using ArturRios.Util.Test.Attributes;
using ArturRios.Util.Test.Mock;
using Bogus;
using FluentValidation;
using FluentValidation.Results;
using Moq;
namespace ArturRios.Heimdall.Command.Tests;
public class CreateScopeCommandHandlerTests
{
[UnitFact]
public async Task GivenUniqueNameAndValidOwners_WhenHandlingCreateScope_ThenScopeIsCreated()
{
// Given
var scopeReader = new FakeRepository<Scope, long>();
var scopeWriter = scopeReader; // same in-memory store
var roleReader = new FakeRepository<Role, long>();
var scopeAdminRole = new Role { Name = nameof(Roles.ScopeAdmin) };
roleReader.Create(scopeAdminRole);
// Bogus builds the collaborator data; set only the fields the behavior depends on.
var owner = new Faker<Person>()
.RuleFor(p => p.PublicId, _ => Guid.NewGuid())
.RuleFor(p => p.RoleId, _ => scopeAdminRole.Id)
.RuleFor(p => p.IsDeleted, _ => false)
.Generate();
var personReader = new FakeRepository<Person, long>();
personReader.Create(owner);
// Moq stubs the non-repository collaborator (the validator).
var validator = new Mock<IValidator<CreateScopeCommand>>();
validator
.Setup(v => v.ValidateAsync(It.IsAny<CreateScopeCommand>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new ValidationResult()); // no failures = valid
var handler = new CreateScopeCommandHandler(
validator.Object, scopeReader, personReader, roleReader, scopeWriter);
var command = new CreateScopeCommand { Name = "Acme", OwnerIds = [owner.PublicId] };
// When
var output = await handler.HandleAsync(command);
// Then
Assert.True(output.Success);
Assert.NotNull(output.Data);
Assert.Equal("Acme", output.Data!.Name);
Assert.Contains(ScopeMessages.ScopeCreatedSuccessfully, output.Messages);
}
[UnitFact]
public async Task GivenScopeNameAlreadyExists_WhenHandlingCreateScope_ThenReturnsNameAlreadyExistsError()
{
// Given a store already containing a scope named "Acme" …
// When handling a CreateScopeCommand for "Acme" …
// Then
Assert.False(output.Success);
Assert.Contains(ScopeMessages.NameAlreadyExists, output.Errors);
}
// GivenInvalidInput_… (AF-01b), GivenOwnerIsNotScopeAdmin_… (AF-01d), etc.
}
The example shows the pattern: construct the handler with fakes, drive one behavior, assert on
DataOutput. Adapt the fakes to the collaborators of the handler you are testing.
6.5 Domain behavior tests
When a Domain class gains real behavior, unit-test that behavior in *.Domain.Tests, same GWT
pattern, no infrastructure. Test the method’s contract: valid inputs produce the expected result;
invalid inputs are rejected/guarded; boundaries and invariants hold.
7. Functional testing standard (Web API)
7.1 Scope of a functional test
A functional test is an end-to-end test of a use case through the API. It:
- Starts the Web API host (via
WebApiTest<Program>). - Points the app at a real PostgreSQL database running in a throwaway Testcontainers container.
- Issues real HTTP requests through
Gateway. - Asserts both:
- the API response (HTTP status code, body/
DataOutput payload, messages/errors), and - the database state after the operation (row created/updated/deleted, join rows present,
IsDeleted toggled, etc.).
This is where authorization, routing, model binding, validation wiring, the mediator, EF Core
mapping, and the database schema are all verified together. The existing
HealthCheckTests
is the reference for the request/response half of this pattern:
public class HealthCheckTests(EnvironmentType environment = EnvironmentType.Local)
: WebApiTest<Program>(environment)
{
[FunctionalFact]
public async Task GivenApiWorking_WhenHealthCheckEndpointCalled_ThenEndpointReturnsOk()
{
var output = await Gateway.GetAsync<DataOutput<string>>("/api/healthcheck");
Assert.Equal(HttpStatusCode.OK, output.StatusCode);
Assert.Equal("Hello world!", output.Body?.Data);
}
}
7.2 The database: Testcontainers PostgreSQL
Functional tests must not use an in-memory EF provider or a developer’s local database. They run
against a real PostgreSQL instance created by Testcontainers at test time, so the environment closely
matches production (the app uses the PostgreSQL provider — see
Startup.AddDependencies
).
The app reads its connection from environment variables (HEIMDALL_DATA_CONNECTIONSTRING,
HEIMDALL_DATA_DATABASETYPE) via AddDataConfigFromEnvironment<AppDbContext>. The functional
suite therefore:
Starts a PostgreSqlContainer once for the whole functional suite (an xUnit
ICollectionFixture, so the container isn’t recreated per test class).
Applies the schema/migrations to that container’s database.
Exports the container’s connection string into HEIMDALL_DATA_CONNECTIONSTRING (and
HEIMDALL_DATA_DATABASETYPE=PostgreSql) before the host is built, so
WebApiTest<Program> boots against the container.
Keeps tests independent by making their data unique, not by resetting the database. The
container’s database accumulates rows for the whole run; nothing is truncated between tests and
no test runs in a rolled-back transaction. Independence comes from every test seeding its own
rows under identifiers no other test can produce — Guid.NewGuid() public identifiers,
scope-{Guid:N} names, user-{Guid:N}@test.local addresses — and asserting only about those
rows.
This is a deliberate choice, not an omission. Truncation would delete the roles and the master
system administrator that DatabaseSeeder writes at startup, which SeedingTests and the login
tests legitimately depend on, and a per-test transaction cannot wrap work done by the API’s own
DbContext inside the host.
What this asks of a new test: never assert on a global count or an unfiltered query — the
only two in the suite (Roles == 3, and the master user counted by e-mail) are safe because
nothing else can create those rows. Filter every listing by something unique to the test, the way
ScopeControllerViewTests filters by the scope name it just generated. A test that counts all
scopes passes alone and fails the moment another class seeds one.
This shared container/fixture is built once as test infrastructure; individual use-case tests
just derive from the functional base class and use it.
// Shared once for the whole functional suite.
public sealed class PostgresFixture : IAsyncLifetime
{
private readonly PostgreSqlContainer _container = new PostgreSqlBuilder()
.WithImage("postgres:18-alpine")
.Build();
public string ConnectionString => _container.GetConnectionString();
public async ValueTask InitializeAsync()
{
await _container.StartAsync();
Environment.SetEnvironmentVariable("HEIMDALL_DATA_CONNECTIONSTRING", ConnectionString);
Environment.SetEnvironmentVariable("HEIMDALL_DATA_DATABASETYPE", "PostgreSql");
// Create the schema by applying the real EF Core migrations — see §10.1.
await using var context = new AppDbContext(/* options bound to ConnectionString */);
await context.Database.MigrateAsync();
}
public ValueTask DisposeAsync() => _container.DisposeAsync();
}
[CollectionDefinition(nameof(FunctionalCollection))]
public sealed class FunctionalCollection : ICollectionFixture<PostgresFixture>;
7.3 Structure, naming, and assertions
- Functional test classes derive from
WebApiTest<Program>, join the functional collection, and use
[FunctionalFact] / [FunctionalTheory]. - Same GWT naming and
// Given / // When / // Then sections as unit tests. - Authenticate with
AuthenticateAndAuthorizeAsync when the endpoint requires a role, and add
explicit tests for the authorization alternative flows (403 Forbidden, 401 Unauthorized). - In Then, assert the response and open the database (through the fixture / a scoped
AppDbContext) to assert the persisted state.
[Collection(nameof(FunctionalCollection))]
public class ScopeControllerTests : WebApiTest<Program>
{
public ScopeControllerTests(PostgresFixture db) : base(EnvironmentType.Local) { /* seed as needed */ }
[FunctionalFact]
public async Task GivenSystemAdminAndValidPayload_WhenPostScopes_ThenScopeIsCreatedAndReturned()
{
// Given an authenticated System Admin and an existing ScopeAdmin owner …
// When
var response = await Gateway.PostAsync<DataOutput<CreateScopeCommandOutput>>(
"/api/scopes", new CreateScopeCommand { Name = "Acme", OwnerIds = [ownerPublicId] });
// Then — response
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
Assert.Equal("Acme", response.Body?.Data?.Name);
// Then — database state
// load the Scope + its SCOPE_OWNER rows from the DB and assert they exist as expected
}
// GivenDuplicateName_… → 409 (AF-01a)
// GivenInvalidPayload_… → 400 (AF-01b)
// GivenNonSystemAdmin_… → 403 (AF-01c)
// GivenOwnerNotScopeAdmin_… → 400 (AF-01d)
}
7.4 Coverage per endpoint
For each controller endpoint of the use case, cover the main flow and every alternative flow
from the Use Case Specification, including the authorization flows that unit tests deliberately skip.
8. Naming & style conventions (quick reference)
| Item | Convention | Example |
|---|
| Test project | <Project>.Tests | ArturRios.Heimdall.Command.Tests |
| Test class | <ClassUnderTest>Tests | CreateScopeCommandHandlerTests |
| Test method | Given…_When…_Then… | GivenScopeNameAlreadyExists_WhenHandlingCreateScope_ThenReturnsNameAlreadyExistsError |
| Unit fact/theory | [UnitFact] / [UnitTheory] | — |
| Functional fact/theory | [FunctionalFact] / [FunctionalTheory] | — |
| Body sections | // Given → // When → // Then | — |
9. Per-use-case workflow (apply every time)
After implementing a use case, do the following before considering it complete:
- Identify the artifacts the use case produced (handlers, validators, domain behavior, endpoints)
using the table in §3.
- Create/locate the test projects that mirror those artifacts’ projects (§4), each referencing
the project under test, with the standard package set (§5).
- Unit-test each Command/Query handler: main flow + every applicable
AF-xx alternative flow,
with all collaborators faked/mocked (§6). - Unit-test any new Domain behavior (§6.5). Skip anemic entities.
- Functional-test each endpoint end-to-end against the Testcontainers PostgreSQL database,
asserting both response and database state, covering main flow + every
AF-xx, including
authorization flows (§7). - Name everything per §8 and write every test in Given/When/Then form.
- Run the suite (
dotnet test) and confirm both Category=Unit and Category=Functional pass. - Only then is the use case done.
10. Current test inventory
Every test project uses the standard package set from §5, references the project it tests, and is
registered in the solution under the Tests folder. Six projects mirror the src/ tree:
| Test project | References | Test classes |
|---|
tests/Application/ArturRios.Heimdall.Command.Tests | …Command | Handler tests for every command — CreateScope, UpdateScope, DeleteScope, HardDeleteScope, CreateAdmin, CreateUser, CreateScopeOwner, AddScopeOwner, RemoveScopeOwner, PromoteScopeUser, SetGoogleSignIn, GoogleSignIn, GoogleSignOut, DeleteGoogleUser, HardDeleteGoogleUser, UpdatePerson, DeletePerson, HardDeletePerson, Login, PasswordRecovery, ResetPassword, VerifyEmail, ResendVerificationEmail, CreateApplication, UpdateApplication, DeleteApplication, HardDeleteApplication, CreateScopePermission, UpdateScopePermission, DeleteScopePermission, HardDeleteScopePermission — plus the validators that guard them, EmailVerificationServiceTests, and PasswordResetServiceTests |
tests/Application/ArturRios.Heimdall.Query.Tests | …Query | GetScopeByIdQueryHandlerTests, ListScopesQueryHandlerTests, GetPersonByIdQueryHandlerTests, ListScopePersonsQueryHandlerTests, ListScopeOwnersQueryHandlerTests, GetApplicationByIdQueryHandlerTests, ListScopeApplicationsQueryHandlerTests, GetGoogleUserByIdQueryHandlerTests, ListScopeGoogleUsersQueryHandlerTests, GetScopePermissionByIdQueryHandlerTests, ListScopePermissionsQueryHandlerTests, GetDetailedHealthQueryHandlerTests, DatabaseHealthCheckTests |
tests/Application/ArturRios.Heimdall.Shared.Tests | …Shared | ScopeOwnershipCheckerTests — the scope-authorization rule shared by UC-06 AF-06e and UC-07 AF-07b |
tests/Domain/ArturRios.Heimdall.Domain.Tests | …Domain | Empty by design — every entity is still anemic, so §3’s rule gives them no unit tests |
tests/Infrastructure/ArturRios.Heimdall.Data.Tests | …Data | Seeding/MasterUserOptionsTests |
tests/Presentation/ArturRios.Heimdall.WebApi.Tests | …WebApi | One functional class per endpoint group (ScopeController*, PersonController*, AuthControllerLogin, AuthControllerPasswordRecovery, AuthControllerPasswordReset, |
AuthControllerVerifyEmail, AuthControllerResendVerification, AuthControllerGoogleSignIn, AuthControllerGoogleSignOut, GoogleUserControllerView, GoogleUserControllerDelete, GoogleUserControllerHardDelete, ApplicationController*, ScopePermissionController*, AuthControllerScopePermissionClaim, HealthCheck), plus SchemaTests, SeedingTests, the unit-tested IdentityUserMapperTests and MailgunSenderTests, and Support/ (PostgresFixture, FunctionalCollection, TestTokens, TestGoogleTokens) | | |
Suite totals as of UC-35: 520 unit and 428 functional tests, all passing. Run them separately
with --filter "Category=Unit" / "Category=Functional" (see CONTRIBUTING.md).
UC-18 added UpdateApplicationCommandHandlerTests and UpdateApplicationCommandValidatorTests to
the Command.Tests project, and ApplicationControllerUpdateTests to the functional suite.
UC-25 added GoogleSignInCommandHandlerTests and AuthControllerGoogleSignInTests, 14 tests each,
covering both halves of the main flow and AF-25a…AF-25d. Its functional half also added
Support/TestGoogleTokens, and is worth reading before writing tests for UC-26…UC-29, because it
solves a problem those use cases inherit: how to test an endpoint whose input is verified by a
third party.
WebApiTest<T> keeps its WebApplicationFactory private and its Gateway protected-readonly, so a
functional test cannot replace a DI registration — every substitution in this suite is chosen at
start-up from the environment, as Startup.AddEmailSenders is. PostgresFixture therefore publishes
HEIMDALL_GOOGLE_TEST_SIGNING_SECRET, which makes the host under test resolve
LocalGoogleIdTokenVerifier, and TestGoogleTokens mints ID tokens signed with the same secret.
Without it only AF-25a and AF-25b would be reachable: the main flow, AF-25c, and AF-25d all sit
behind token verification, and §7.4 asks for every alternative flow.
UC-26 added GoogleSignOutCommandHandlerTests (4) and AuthControllerGoogleSignOutTests (8),
covering the main flow and all four shapes AF-26a takes. It is the suite’s first endpoint whose
success writes nothing, so one test on each side asserts the Google User row is unchanged
afterwards — without it, a handler that deleted the account instead of ending its session would pass
every other assertion.
Its functional half also consumes what UC-25 built: one test signs in through POST /api/auth/google
with a TestGoogleTokens ID token and then signs out with the application token that call returned,
which is the only way to prove UC-26’s precondition end to end rather than assume it.
UC-27 added GetGoogleUserByIdQueryHandlerTests (9), ListScopeGoogleUsersQueryHandlerTests (8),
and GoogleUserControllerViewTests (19). The functional half deliberately covers both endpoints
in one class rather than the usual one-class-per-endpoint split: the thing worth pinning is the
asymmetry between them — a Google User may read their own record but may never list a scope — and
splitting the class would put the two halves of that single rule in two files where neither states
it.
UC-28 added DeleteGoogleUserCommandHandlerTests (7) and GoogleUserControllerDeleteTests (10).
Two of the functional tests deliberately reach past the endpoint under test, because a flag
nothing honours is not a deletion: one confirms UC-27’s default read stops returning the record
(FR-GO-17), and one signs an account up through UC-25, deletes it, and confirms the same GoogleId is
refused a fresh sign-in (AF-25d) without a duplicate row appearing to route around the deletion.
Asserting only IsDeleted == true would have passed against an implementation nothing downstream
respected.
UC-29 added HardDeleteGoogleUserCommandHandlerTests (5) and
GoogleUserControllerHardDeleteTests (9), completing the Google Sign-In milestone. The unit half
has no authorization test, deliberately: UC-29’s only actor is the System Admin, the endpoint’s
RoleRequirement settles it entirely, and the command carries no acting person — so there is no
rule at that level to test, and the functional half proves the endpoint enforces it instead. That
includes an owning Scope Admin being refused, which is the whole difference between UC-28 and
UC-29.
One functional test asserts the scope and its other Google Users survive the deletion. The foreign
key points from the Google User to the scope, so a cascade in the wrong direction would take
everything in it — and would be silent in a suite that only checked the deleted row was gone.
The substitute is deliberately not a trust-anything stub — a token still needs a valid HS256
signature, the expected issuer and audience, and an unexpired lifetime, so the suite exercises the
same verification path a real deployment does and only the signing authority differs. Two guards keep
it out of production: Startup never registers it in the Production environment, and never without
that variable explicitly set. No .env file carries it.
UC-19 added DeleteApplicationCommandHandlerTests to the Command.Tests project and
ApplicationControllerDeleteTests to the functional suite. It has no validator test class: the
command carries no body, so there is no validator to guard — the same shape DeleteScopeCommand and
DeletePersonCommand already have.
UC-20 added HardDeleteApplicationCommandHandlerTests to the Command.Tests project and
ApplicationControllerHardDeleteTests to the functional suite, and likewise has no validator test
class. Its unit tests cover only the main flow and AF-20a: UC-20’s single actor means authorization is
settled by [RoleRequirement] alone and the command carries no acting person, so every refusal —
including the Scope Admin who owns the application — is a functional test. The pair also pins the two
deletions apart: ApplicationControllerDeleteTests asserts a repeated call is an idempotent 200,
while ApplicationControllerHardDeleteTests asserts it is a 404.
UC-21 added AddScopeOwnerCommandHandlerTests to the Command.Tests project and
PersonControllerAddScopeOwnerTests to the functional suite, and has no validator test class — both
identifiers are route values, so there is no body to guard. It is the first use case whose main flow
and idempotent alternative flow answer with different status codes: AF-21d returns 200 where the
main flow returns 201, so the functional pair asserts the two statuses and that a second call
leaves exactly one scope_owner row. Contrast UC-19, where AF-19b shares the main flow’s 200 and
only the AlreadyDeleted flag separates them (§10.2 covers the harder case, where even the message
matches).
Its unit tests also pin an ordering guarantee rather than a flow: an actor the ownership checker
rejects, naming a person who does not exist, must be refused with AF-21c and never AF-21b — the
authorization answer must not depend on data the caller is not allowed to learn.
UC-22 added RemoveScopeOwnerCommandHandlerTests to the Command.Tests project and
PersonControllerRemoveScopeOwnerTests to the functional suite, and likewise has no validator test
class. It repeats UC-21’s ordering test on the inverse operation and adds two shapes of its own:
- Every refusal asserts the
scope_owner row survives. A removal endpoint that answered the
right status while still deleting the row would pass a status-only test, so each 403/404/409/401
case reads the join row back — from the fake repository at the unit layer, from PostgreSQL at the
functional one. - The last-owner guard is tested from both sides of “live”.
GivenSoleOwner_… covers the plain
NFR-12 case, and GivenOnlyCoOwnerIsLogicallyDeleted_… covers the one that makes the guard’s
!IsDeleted filter a tested claim: a deleted co-owner does not keep a scope owned, so removing the
only live owner is still refused. The mirror case,
GivenLogicallyDeletedTargetWithLiveCoOwner_…, pins that a deleted target is nonetheless
removable — the stale row this endpoint exists to clear.
It also contrasts with UC-21 on repetition a third way: PersonControllerAddScopeOwnerTests asserts
a second call is an idempotent 200 and PersonControllerPromoteScopeUserTests a 409, while
PersonControllerRemoveScopeOwnerTests asserts a 404 — the row is gone, so the repeat meets
AF-22a rather than finding anything to answer for.
UC-23 added PromoteScopeUserCommandHandlerTests to the Command.Tests project and
PersonControllerPromoteScopeUserTests to the functional suite, and likewise has no validator test
class. It repeats UC-21’s ordering test and adds two of its own kinds:
- Ordering between two alternative flows, not just between a flow and authorization. AF-23d
(already a
ScopeAdmin, 409) is checked before AF-23b (not a User of this scope, 400), because
every person AF-23d describes also satisfies AF-23b. GivenPersonAlreadyScopeAdmin_… would still
pass against a handler that answered 400, so it asserts the 409 and that the ownership rows the
person already held are untouched. - A flow whose condition is easy to state and easy to get wrong. Promotion moves the address from
the scope’s
User namespace into the system-wide admin one, so AF-23e refuses it when a live
administrator already holds that address (FR-PE-09). Three tests pin the boundary the flow’s wording
implies but does not spell out: a live admin holding the address refuses the promotion, a
logically deleted admin does not, and a User of another scope holding it does not either — the
last is what makes “the two namespaces are independent” a tested claim rather than a comment.
The pair also contrasts with UC-21 on repetition: PersonControllerAddScopeOwnerTests asserts a
second call is an idempotent 200, while PersonControllerPromoteScopeUserTests asserts it is a
409 — the first promotion changed the role, so the repeat meets AF-23d rather than finding nothing
to do.
UC-24 added SetGoogleSignInCommandHandlerTests and SetGoogleSignInCommandValidatorTests to the
Command.Tests project and ScopeControllerSetGoogleSignInTests to the functional suite. It is the
first use case whose command carries a single field and still earns a validator test class, and
the reason is the point of the coverage: Enabled is a bool? because a plain bool would bind a
body that omits the field to false and silently disable Google Sign-In (AF-24c). Three tests pin that —
the validator rejecting null, the handler refusing the validation failure, and
GivenEmptyBody_WhenPutGoogleSignIn_ThenBadRequestAndFlagIsUnchanged, which sends {} against an
enabled scope so a regression shows up as a flipped row rather than only a changed status code.
Two smaller shapes it contributes:
- A toggle is tested in both directions. The main flow is not one test but two — enable and
disable — at both layers, because “Enable/Disable” is one endpoint doing opposite things and a
handler that ignored the requested value would still pass the enable-only half.
- Every refusal asserts the persisted flag, not just the status. AF-24b’s whole content is that
an unauthorized actor changes nothing, so each 403/404/400/401 test reads
google_sign_in_enabled back from the database. The functional helper sends the SRD’s wire body
({ enabled }) as an anonymous object rather than a serialized command, so the test pins the
contract a client uses.
UC-17 also carried a specification correction — application ownership was restricted to a
ScopeAdmin who owns the application’s scope — so the UC-16 tests were rewritten rather than merely
added to. CreateApplicationCommandHandlerTests lost its User-actor and SCOPE_USER-owner cases
and gained the Scope Admin equivalents; ApplicationControllerCreateTests gained the User-role
403 and the User-as-owner 400. A use-case correction is expected to update the tests that
pinned the old behaviour, in the same change.
UC-31…UC-35 added 75 unit and 64 functional tests: CreateScopePermission,
UpdateScopePermission, DeleteScopePermission, and HardDeleteScopePermission handler tests plus
the two validator classes (72 in Command.Tests and Query.Tests), three more cases in
IdentityUserMapperTests, and one functional class per endpoint —
ScopePermissionControllerCreate, …List, …GetById, …Update, …Delete, …HardDelete — with
AuthControllerScopePermissionClaimTests alongside them. UC-35’s unit half has no authorization
test, for the reason UC-20 and UC-29 don’t either: its only actor is the System Admin, so
[RoleRequirement] settles it and the functional half proves it, including an owning Scope Admin
being refused. Three shapes are new here:
- The claim is tested where it is read, not only where it is written.
IncludeAsJwtClaim is a
column, and a column nothing reads is not a feature — the same argument UC-28 makes about a
deletion flag. AuthControllerScopePermissionClaimTests therefore logs in through
POST /api/auth/login and decodes the issued token, covering all four halves of FR-AU-08: a User
gets their scope’s flagged names and not the unflagged ones, a Scope Admin gets the union over the
scopes they own, a flagged-but-deleted permission is absent, and a System Admin carries no such
claim at all. Asserting the flag round-trips through the CRUD endpoints would have passed against
an issuer that never consulted it. - A scope-qualified route is tested for the wrong scope, not just the wrong id. Every read and
write has a
GivenScopePermissionOfAnotherScope_…ThenNotFound case. The permission exists and the
caller may be entitled to it in its own scope, so a handler that ignored the route’s scopeId
would answer 200 and leak a resource across a boundary while every unknown-id test still passed. - A permission’s deletion state is independent of its scope’s. UC-04 does not cascade to
permissions, so both delete endpoints seed a permission inside a logically deleted scope and assert
the handler answers from the permission’s own flag —
200 with AlreadyDeleted for UC-34, a
successful purge for UC-35. The pair also repeats the UC-19/UC-20 contrast on a second call: UC-34
is an idempotent 200, UC-35 a 404.
One create test pins an absence rather than a rule: GivenDuplicateName_…ThenBothAreCreated. No
FR-SP requirement makes a permission name unique within its scope, so the test states that on
purpose — if uniqueness is ever wanted, it is a specification change and this test is what fails
first.
10.1 Functional database
PostgresFixture starts a postgres:18-alpine container, the PostgreSQL major the deployed
environments run. It points
HEIMDALL_DATA_CONNECTIONSTRING / …_DATABASETYPE at it before the host is built — so the
suite never touches a developer’s local .env.local database — and creates the schema by applying
the real EF Core migrations (context.Database.MigrateAsync()). Migrations were chosen over
EnsureCreated deliberately: the API refuses to start with migrations pending (see
DatabaseSeeder), and applying them here means the functional suite exercises the same schema
production gets. SchemaTests asserts the result — every table under the heimdall schema,
named in singular snake_case.
SeedingTests covers the other half of startup: DatabaseSeeder writes the three Roles rows and
the master system administrator, so functional tests can authenticate as a System Admin.
10.2 Testing an endpoint that answers the same way twice
UC-12 is the first use case whose alternative flow is indistinguishable from its main flow by
design — AF-12a returns the same 200 and the same message as a successful request, so response
assertions alone cannot tell them apart. Its tests handle that in two ways, and later
anti-enumeration work should follow the same shape:
- Assert the side effect, not the response. Every functional test opens the database and asserts
whether a
password_reset_token row exists for the person. That row is the only difference
between the two paths, so it is the only thing worth asserting. - Compare the two responses directly. One test issues both requests and asserts the status,
messages, and errors are equal. Each other test pins its own path; this one pins the property
AF-12a actually states.
The senders are unit-tested with a stub IEmailService rather than reached over the network. The
functional suite leaves the Mailgun variables unset, so the API registers its logging senders and
never makes an outbound call.
11. Verifying the non-functional requirements
Every use case in this document has a stated way of being tested. The non-functional requirements
had none: of the eighteen, exactly one — NFR-12 — was named anywhere in this specification, and the
rest were assertions about the system that nothing was obliged to check. Several were true and
tested by accident. At least three were not true as written, and that only became visible when
somebody tried to verify them.
An NFR is not always a test. Three verification methods are used here, and naming which one applies
is half the point: a requirement verified by inspection is not weaker than one verified by a test,
but a requirement whose method is “none” is not a requirement yet.
- Test — executed by the suite. Fails the build when the property stops holding.
- Inspection — established by reading the code or configuration, and re-established when it
changes. Used where executing the property would prove less than reading it.
- Measurement — a number produced by a run and published. Not pass/fail: the figure is the
output, and the requirement quotes it.
The Threat Model Document
uses these same three methods, and the same
rule about “none”, for the threats behind the security requirements.
11.1 Method and status per requirement
| ID | Method | Where | Status |
|---|
| NFR-01 (technology) | Inspection | Directory.Packages.props, project files | Verified |
| NFR-02 (password hashing) | Test | LoginCommandHandlerTests, ResetPasswordCommandHandlerTests — a stored hash never equals the password, and a reset re-salts | Verified |
| NFR-03 (signed tokens, configurable expiry) | Test | AuthControllerLoginTests reads expiresAt; IdentityUserMapperTests covers the claim round trip | Verified |
| NFR-04 (authentication required) | Test | Every controller suite has a no-token case; ActorLivenessTests covers the liveness exemption | Verified |
| NFR-05 (response time) | Measurement + regression ceilings | ResponseTimeMeasurementTests for one caller; scripts/load_test.py for the 128-caller condition | Measured; see SRD §6.1 and §6.3 |
| NFR-06 (horizontal scaling) | Test + inspection | DataProtectionKeyRingTests for the key ring; inspection for session-free authentication | Verified — and a defect found, see SRD §6.2 |
| NFR-07 (integrity under logical deletion) | Test | NonFunctionalRequirementTests walks every foreign key in the catalogue after UC-04’s cascade | Verified |
| NFR-08 (hard delete cascades a scope) | Test | HardDeleteScopeCommandHandlerTests, ScopeControllerHardDeleteTests | Verified |
| NFR-09 (audit trail) | Test | AuditLoggingTests, AuditingCommandHandlerTests — both outcomes | Verified |
| NFR-10 (input validation) | Test | Every *ValidatorTests class, plus the 400 cases in the controller suites | Verified |
| NFR-11 (hard delete cascades a person) | Test | HardDeletePersonCommandHandlerTests | Verified |
| NFR-12 (a scope keeps an owner) | Test | UC-09, UC-10 and UC-22 suites | Verified |
| NFR-13 (Google token verification) | Test + inspection | GoogleIdTokenValidationSettingsTests pins the audience constraint this application supplies; LocalGoogleIdTokenVerifierTests requires the suite’s substitute verifier to refuse a wrong signature, issuer, audience or expiry; inspection for Google’s own validator | Partly verified — see 11.2 |
| NFR-14 (hard delete cascades Google Users) | Test | HardDeleteScopeCommandHandlerTests | Verified |
| NFR-15 (no internal ids exposed) | Test | OpenApiContractTests over the published document; SchemaTests on the shape | Verified |
| NFR-16 (secrets at rest) | Test | AuthControllerEnableTwoFactorAuthTests — the secret is stored encrypted and never returned twice; recovery codes stored as hashes | Verified |
| NFR-17 (challenge token) | Test | NonFunctionalRequirementTests — lifetime, the pending claim, rejection elsewhere, and that the token a login issues expires with the FR-2F-03 code it was issued with rather than before it; TwoFactorLifetimesTests pins the same agreement at its source; AuthControllerVerifyTwoFactorAuthTests pins the window’s two edges and redeems a code in its final seconds | Verified |
| NFR-18 (password verification cost) | Measurement | ResponseTimeMeasurementTests | Measured; see SRD §6.1 |
| NFR-30 (token issuer and audience) | Test | TokenIssuerAndAudienceTests — a correctly signed token under another issuer or audience is refused, and Heimdall’s own is accepted, on the bearer path, the subject-rights endpoints, the challenge and UC-57; TokenOriginTests, PersonTokenAudienceTests and JwtApplicationTokenServiceTests for each reader | Verified |
| NFR-31 (signing secrets) | Test | TokenSigningSecretsTests — length, ASCII, the empty previous secret, and messages that never carry the value; TokenOriginTests for HS384 and HS512 refused | Verified |
11.2 What this does not cover
Naming the gaps is part of the method; a table of green ticks that hides them is worth less than a
shorter one that does not.
- NFR-13’s last quarter is still inspection. Signature, issuer and expiry are checked by
GoogleJsonWebSignature.ValidateAsync against certificates fetched from Google, and reaching that
code in a test would mean contacting Google or holding Google’s signing key. What was closed is the
part this application decides. The audience is supplied by us, and Google reads a null Audience
as “do not check the audience” — so dropping the constraint was a one-line change no test could
see; GoogleIdTokenValidationSettingsTests now sees it. And the substitute verifier the functional
suite reaches UC-25 through is itself attacked by LocalGoogleIdTokenVerifierTests, because a
substitute that waved anything through would make every UC-25 test a report on a system no
deployment has. What remains unexecuted is Google’s own check, and it stays that way by choice. - NFR-05 and NFR-18 are measured on a developer machine, not on production hardware, and
server-side only. They describe the API’s own cost, not what a caller experiences across a network.
NFR-18 in particular will be slower wherever there are fewer cores, since Argon2id is configured to
use them. The figures also move with load on the measuring machine — a write triples between an
idle run and a full-suite one (SRD §6.1) — so a single reading is not a promise, which is why two
are published and the budget is set above both.
- The load run is not in CI and is not pass/fail on a threshold.
scripts/load_test.py needs a
deployment to point at and takes minutes, so nothing in the pipeline runs it and no merge is gated
on it; SRD §6.3 is a reading somebody took, not a figure a build re-establishes. It is also a
closed loop, which makes its latencies optimistic against a fixed-rate arrival, and it is driven
over loopback from the same machine that hosts the API, so part of what it measures is the load
generator. Compare its runs with each other; do not read them as a service level. The tool does
fail the run when a request faults or answers 5xx, which is the part worth being strict about. - The load run covers four endpoints, and its write scenario is off by default. A liveness check,
a paginated read, a scope creation and a login — enough to separate the read path from the write
path and to reach the rate limiter, and nowhere near the whole surface. The write scenario needs
--write because it is the only part that leaves rows behind, and it leaves as many as the API can
absorb; the figures it produces are also only valid for the row count it started from (SRD §6.3.2).
Nothing here load-tests two-factor verification, Google sign-in, or any deletion. - The rate limiter makes login unmeasurable as throughput. Ten requests a minute per IP get
through, so a load run reports login’s capacity honestly (SRD §6.3.1) and its throughput not at
all. Raising the limit to measure it would measure a deployment nobody runs.
- NFR-06 is verified for the state the API keeps, not by running two instances. The key ring
test builds two providers sharing only the database, which is the isolation two containers have,
but nothing in CI starts a second replica behind a load balancer.
11.3 Adding a non-functional requirement
The same rule as a use case: it does not exist until something checks it. A new NFR needs a method
chosen from 11.1 and an entry in that table before it is merged. If the method is measurement, the
requirement quotes the figure and names where it was produced; if none of the three fits, that is a
sign the requirement is not yet stated in a way anybody could disagree with, and it needs rewriting
rather than exempting.
12 - Threat Model Document
What the security controls defend against, organised by trust boundary, and what remains after them.
Threat Model Document — Heimdall API
1. Purpose
The System Requirements Document
states the security controls
this API implements. It does not state what they defend against, and a control without a stated
threat cannot be judged: there is no way to tell whether it is sufficient, whether it is the right
control, or whether removing it would matter.
This document states the threats. Each one names the asset at risk, what an attacker must already be
able to do, the control that addresses it — cited as an existing FR or NFR rather than restated — and
how anybody knows the control works. Where nothing addresses a threat, that is written down as a
finding rather than left out.
It uses the same three verification methods as the Testing Specification
Document
§11, for the same reason:
- Test — executed by the suite. Fails the build when the property stops holding.
- Inspection — established by reading the code or configuration, and re-established when it
changes.
- Measurement — a number produced by a run and published.
And the same rule: a threat whose verification method is “none” is a finding. §11 collects them.
This is a design-level review. It is not a penetration test, and nothing here was established by
attacking a deployment.
2. Method
Threats are numbered TH-nn and grouped by the trust boundary they cross, not by the feature they
affect. Feature-organised threat models miss the crossings, and the crossings are where the defects
are: two features can each be correct and still combine into a boundary that leaks.
Residual risk is what is left after the control, judged on this codebase rather than in the
abstract:
- Low — the control addresses the threat and something checks the control.
- Medium — the control addresses the threat, but its coverage has a stated limit, or nothing
checks it.
- High — no control, or the control does not cover the case described.
3. Trust boundaries
flowchart LR
Anon[Anonymous caller]
Auth[Authenticated caller]
API[Heimdall API]
DB[(PostgreSQL)]
Google[Google identity]
Ops[Operator / environment]
Anon -->|TB-1| API
Auth -->|TB-2| API
API -->|TB-3| DB
API -->|TB-4| Google
Ops -->|TB-5| API| Boundary | Crossing | What is on the other side |
|---|
| TB-1 | Anonymous internet → the authentication endpoints | Credentials, tokens, and every account in the system |
| TB-2 | An authenticated caller → data belonging to a tenant | Every other tenant’s persons, applications and permissions |
| TB-3 | The API → its database | Password hashes, TOTP secrets, reset tokens, the key ring |
| TB-4 | The API → Google | Whoever a Google ID token claims to be |
| TB-5 | An operator → configuration, logs and images; a service holding the signing key → the API | The signing secret, the master account, delivered tokens |
4. TB-1 — Anonymous internet to the authentication endpoints
Every endpoint here is AllowAnonymous by necessity (NFR-04): a caller cannot hold a token yet. They
are therefore the only endpoints reachable by someone with nothing at all, and they are where the
expensive work is.
| ID | Threat | Control | Verification | Residual |
|---|
| TH-01 | Password guessing against a known address | Per-account lockout after 10 failures for 15 minutes (FR-AU-09), each attempt counted before its password is derived so a parallel burst cannot outrun it (NFR-27); per-IP fixed window of 10 requests a minute | Test | Low |
| TH-02 | Enumerating which addresses and scopes exist, from the response or its timing | One message for every rejection, and a decoy Argon2id verification when no person matches (FR-AU-10). Password recovery answers before it looks anybody up — the lookup, the token and the email run in the background — so a registered address no longer answers a Mailgun round trip later than an unknown one (FR-PR-02) | Test + measurement (SRD §6.1) | Low |
| TH-03 | Exhausting the API’s memory through login — or, by keeping PasswordHashGate full, denying password sign-in to everyone | PasswordHashGate bounds concurrent Argon2id derivations process-wide and sheds with 503, behind the per-IP rate limiter. An IPv6 caller is one partition per /56, the prefix a subscriber is routinely handed, so one subscriber cannot spread a flood over 256 /64 partitions. The three endpoints that create a person with a password — reachable with an administrator’s token alone — are limited to thirty a minute per acting person and thirty a minute per source address, so neither one account nor the accounts it can create (an owner can create co-owners) can keep the gate full from one address. Only an administrator’s person access token spends the address bound — not a request without a token, nor one with a User’s, a two-factor challenge or a subject-rights token — so such requests from an address administrators share cannot use it up. Deliberately no global budget ahead of the gate: whoever spent it would deny every caller as surely as the flood does | Test + measurement (SRD §6.3.1) | Medium: accepted residual — a flood spread across many unrelated networks (about seventy IPv4 addresses, or as many separate /56s, at ten a minute each) keeps the gate full, and legitimate password checks wait ten seconds and get 503 for as long as it lasts. Nothing inside one instance can tell those callers from real ones; the bound belongs at the edge, and the operations guide gives the Traefik configuration for it. Account creation has the same shape: each address has its own thirty a minute, and every account an owner creates is an actor with thirty of its own, so an administrator working from many addresses multiplies the creation bound by the addresses used. That is the same distributed case, for the same edge |
| TH-04 | Guessing a 6-digit second-factor code | Per challenge: five attempts per email code, hashed at rest, short lifetime, and at most three reissues per challenge (FR-2F-13, FR-2F-16); five app-code or recovery-code guesses per challenge, after which the challenge is retired (FR-2F-17). Per account, across challenges: the tenth failed second factor locks second-factor verification for fifteen minutes, twice as long at each repeat, up to a day, and only a second factor that checks out resets the count — a correct password does not (FR-AU-12). While locked, the right code is refused exactly as a wrong one is. Every budget is charged before the comparison, so parallel guesses get no more (NFR-27) | Test | Low: once the locks reach a day, a holder of the password gets ten guesses a day, about one chance in a hundred a year against an authenticator app’s three valid codes in a million. They can also keep the owner’s second factor locked, a day at a time, until the password is changed |
| TH-05 | Guessing a password-reset or verification token | 48 characters from a CSPRNG, single-use, expiring | Inspection | Low |
| TH-06 | Using an MFA-pending challenge token as a full token, at Heimdall or at a consumer | Distinct claim and distinct audience: HEIMDALL_AUTH_TOKEN_AUDIENCE plus .2fa-challenge, so a consumer that pins the person audience refuses it without knowing the claim. 10-minute fixed lifetime matching the email code it is issued with. Inside Heimdall, a token must also name Heimdall’s issuer and one of its audiences (NFR-30), IdentityUserMapper reads mfaPending only under that audience, and the token is rejected everywhere but second-factor verification (NFR-17) | Test | Low: a consumer that does not validate the audience still accepts it |
| TH-25 | Telling a taken address from a free one by registering, then signing in (UC-47) | Registering creates no Person, only a PendingRegistration that nothing can sign in with, so a sign-in before the link is followed answers the 401 a wrong password gets (UC-11 AF-11a). The registration’s own answer is one message whether the address was free or taken (FR-SR-06) | Test | Low |
| TH-26 | Registering an address one does not own, to hold it before its owner does (squatting) | No account exists until the mailed link is followed (FR-SR-07), so an unconfirmed registration holds nothing: confirmation re-checks that the address is still free, and the others for the same address are removed. What remained was the owner confirming a registration made by someone else, and so inheriting the squatter’s password; confirmation now requires the password chosen at sign-up (FR-SR-09), which only the squatter knows, so that case is closed. A scanner or a mistaken click on the link creates nothing | Test | Low |
| TH-27 | Flooding the holder of a taken address with registration-attempt notices | At most three notices per address a day, whichever scopes the attempts named and whatever source addresses they came from, charged by one atomic upsert before the notice is sent (FR-SR-14, NFR-27); the count is kept under a digest of the address. The notice is sent after the response, so the caller cannot tell whether one went out — or was withheld | Test | Low: three unwanted notices a day per address |
| TH-28 | Using registration to send verification mail to addresses of third parties | At most three live pending registrations per address in a scope, so three confirmation mails per scope until they expire (FR-SR-13); a request beyond that stores and mails nothing and is answered as any other. Per-IP rate limit. The mail says that ignoring it creates nothing, and nothing is created unless the link is followed with the password chosen at sign-up (FR-SR-07, FR-SR-09) | Test | Low: an address can be sent three links per open scope per link lifetime (a day by default), so the bound grows with the number of scopes open to self-registration |
| TH-38 | Flooding an address with password-reset mail by requesting recovery for it anonymously (UC-12) | One reset link per person per HEIMDALL_ADMIN_EMAIL_COOLDOWN_IN_SECONDS (five minutes by default, and never longer than the link’s own lifetime, so the owner always holds a working link), counting every reset link issued for them, whoever asked (FR-PR-07). A request inside it sends nothing and is answered exactly as any other, so the bound reveals nothing about the address. Deliberately no daily cap: recovery is silent, so whoever knows an address could spend the cap and deny its owner recovery for the day. Per-IP rate limit | Test | Medium: about three hundred links a day per account to its registered address — which is the owner’s own, unless the account’s holder changed it to someone else’s (TH-37), when that person receives them |
| TH-39 | Following a password-reset or verification link after the account it was mailed for has changed: the person restricted, deleted or anonymised, their scope deleted, or their address replaced | When a link is followed the person is re-checked by UC-12’s rule and an ineligible one is answered exactly as an unknown token, with nothing written (FR-PR-08, FR-EV-08, NFR-24). An address change, whoever makes it, retires every live reset and verification link, before the new address is saved and again immediately after (FR-PE-17). The password and the verified flag are written by conditional writes that repeat the checks and require the address the person was read with, so a reset or verification that read the person before the change cannot write after it, and a refused reset changes nothing about the person, its security stamp included. Once the row exists the password hash and salt are written only by targeted writes, so a whole-row save of a copy read before a reset cannot restore the old password | Test | Low |
| TH-29 | Filling the registration queue so that later requests are lost | The queue holds 1024 and drops further requests silently, as UC-12’s does (accepted): until it drains, every registrant’s request is lost, not only the attacker’s, and each is left looking for an email that never comes | Inspection | Medium |
| TH-30 | A person following a link they never requested, and ending with an account whose password someone else chose | Closed by FR-SR-09: following the link creates nothing without the password chosen at sign-up, so a scanner, a mistaken click or the wrong one of two identical emails cannot hand anyone an account whose password they do not know. The link is also single-use and expires with the email-verification token (FR-SR-08), and the email says that ignoring it creates nothing | Test | Low |
| TH-31 | A leaked registration link (a forwarded email, a proxy log) used to guess the sign-up password | The link alone confirms nothing (FR-SR-09), and each guess is charged atomically before the comparison (NFR-27), so parallel requests cannot exceed the budget: the 10th wrong password discards the registration, the login lockout’s threshold, and the link then answers “Invalid token”. The guesses also pass the per-IP rate limit and PasswordHashGate. Whoever holds the link without the password has ten tries at it | Test | Low |
| TH-32 | A leaked trusted caller key (HEIMDALL_TRUSTED_CALLER_KEYS) lets its holder choose the rate-limit bucket their AuthAnonymous requests are counted in, by naming any address in X-Heimdall-Client-Address | The key grants nothing but that choice: it authenticates nobody and opens no endpoint, and a wrong or missing key or an unusable address silently falls back to the connection address. A named address is counted in its own caller: namespace, apart from direct connections, so a key holder cannot spend the budget of a real caller by naming their address. On the credential endpoints the bucket is not the only bound: the per-account lockouts (FR-AU-09, FR-SR-09) and the process-wide PasswordHashGate (TH-03) do not depend on it, so a leaked key alone buys more guesses spread over invented addresses, each still limited to 10 requests a minute (a rejection answers 429 with a Retry-After header). Keys are at least 32 characters, compared by SHA-256 digest in constant time against every configured key, held in the deployment secret store and never logged (only their count is); two may be listed at once so one can be rotated without downtime. The same policy also guards the authenticated 2fa/enable, 2fa/confirm, 2fa/disable, 2fa/recovery-codes/regenerate, erasure-request and PUT /api/persons/{id} (whose own email change verifies the password, FR-PE-14), and resend-verification, which mails the account’s address (TH-37). The six that check a password or a code now have a per-account budget of their own (FR-AU-13): five failures within fifteen minutes pause them for that account for fifteen minutes, whatever addresses the guesses come from, and a wrong password, a wrong code and a pause all answer the same 401. resend-verification checks no credential; it is bounded per person by the account-email budget instead (TH-37). Accepted residual risk: a holder of a stolen bearer token — with or without a leaked key — gets about twenty guesses an hour at the password or a code, for the token’s lifetime, and can keep the owner’s own re-authentication paused for as long; neither the login lockout nor the second-factor budget is touched, so the owner can still sign in (TH-36) | Test | Low |
| TH-33 | A stolen application secret used to obtain tokens as that application (UC-56) | Shown once and stored only as SHA-256, so neither the database nor the logs hold it; hds_-prefixed so a scanner recognises a leak; revocable one at a time, with a second live slot so rotation needs no downtime (FR-AP-11); optional expiry; every exchange audited with the application as target and LastUsedAt stamped; AuthAnonymous rate limit; one uniform refusal (FR-AP-14). Residual: until revoked, the holder authenticates as the application | Test | Medium |
| TH-34 | A leaked application token replayed (a log, a proxy) | Ten-minute lifetime by default; UC-57’s live check refuses it as soon as its credential is revoked or expires or its application or scope is deleted (FR-AP-16), and consumers re-check before every sensitive release | Test | Low |
| TH-35 | Token confusion: an application token taken for a person, a person token for an application, or a challenge or subject-rights-only token for a full person token | A distinct, required audience (start-up refuses a missing one, or one equal to the person audience or to either audience derived from it), so a consumer pinning the person audience refuses application tokens. The challenge and subject-rights-only tokens carry audiences of their own too, derived from the person audience (.2fa-challenge, .subject-rights), so consumers must pin HEIMDALL_AUTH_TOKEN_AUDIENCE exactly and then refuse both. Inside Heimdall, person routes admit only Heimdall’s issuer and the three person audiences (NFR-30), so an application token is refused there by its audience; IdentityUserMapper also refuses any token carrying tokenKind, and an application token has no role, and it refuses a token whose audience and mfaPending or subjectRightsOnly claim disagree. UC-57 accepts only the application audience with tokenKind = application, so person, Google User, challenge and subject-rights-only tokens all fail it | Test | Low |
TH-03 was the one to read twice, and it is now closed. Each login costs a full Argon2id
verification at 600 MB and 16 threads. The rate limiter admits ten a minute per IP and releases them
together, so one address could demand roughly 6 GB of working set per minute and remain entirely
within policy — measured at 2.8 seconds per login when it happened (SRD §6.3.1). The limiter is
partitioned by remote IP and is per-instance, so callers spread across addresses multiplied that
budget rather than sharing it, and nothing behind it bounded what got through.
PasswordHashGate is that second bound, and it is deliberately not partitioned by anything: four
derivations at once for the whole process, the rest refused with 503 and a Retry-After after ten
seconds. Two properties matter and both are tested. It covers every derivation on a request path
rather than login alone — an authenticated caller creating persons in a loop reaches the same memory,
and a bound with a way around it is not a bound. And it refuses rather than queues, because an
unbounded queue is this threat with the pressure moved from memory into the thread pool.
The refusal is a load condition, not a rejection, so it answers 503 rather than 500 and says nothing
about the account: the decoy verification AF-11a spends passes through the same gate as a real check,
so saturation cannot be used to tell an existing address from an absent one.
The gate bounds one process. Two instances behind a load balancer will each permit four, so the
deployment’s total is the bound times the replica count — which is the ordinary arithmetic of
horizontal scaling (§6.2) rather than a gap, but it means the number to size against is per instance.
And that is why TH-03 is Medium again. The gate sheds the excess rather than letting it exhaust
memory, which protects the process; it does not protect the service. Four permits clear about eleven
derivations a second, roughly seven hundred a minute, and the per-partition limit admits ten a minute,
so about seventy partitions sending login attempts with made-up addresses fill it — and then a real
sign-in waits ten seconds and gets 503, for every client system that depends on Heimdall. Counting
an IPv6 caller per /56 rather than per /64 raised the price for one subscriber from a single
prefix to many; it did not change the arithmetic for many unrelated networks. The same arithmetic
held for an administrator’s token, from one address: creating persons in a loop derives a hash per
request. Those endpoints are now limited to thirty a minute per actor and thirty per address — the
second because an owner can create co-owners, each a new actor, and so multiply a per-actor bound from
one address. What is left there is the same distributed case as for sign-in.
Two fixes were considered and refused. A global anonymous budget ahead of the gate, sized below its
throughput, turns the same flood into a denial of every caller at a lower cost to the attacker:
whoever spends the budget has denied the service, and they no longer even need to fill the gate.
Reserving gate capacity for accounts that exist would make the wait itself an enumeration oracle,
which TH-02 exists to prevent. What does work is a bound applied before the request reaches the
process, where the network shape of the flood is visible and the cost of refusing it is not paid in
Argon2id: the operations guide gives the Traefik middleware for it. Until an operator applies it, this
residual is the deployment’s.
TH-04 is rated Low on a different footing from before. It was Low because a further code cost a
fresh password check. That price falls on the wrong caller: a second factor exists for the one who
already has the password, and to them a login costs nothing. The per-challenge budgets remain, but
the bound that holds is now the account’s (FR-AU-12): failures are counted across challenges, a
correct password leaves the count alone, and the tenth locks the second factor for a period that
doubles at each repeat, up to a day. The lock answers as a wrong code does and still lets the
challenge’s own five run out, so it is not visible from outside. Its cost is the residual in the
table: whoever holds the password can keep the owner locked out of the second step, which ends when
the password is changed and the last lock runs out — at most a day.
5. TB-2 — An authenticated caller to another tenant’s data
Authentication runs in ClaimsOnly mode: the caller is rebuilt from the token’s claims and no data
store is consulted, which is what keeps an ordinary request free of a per-request lookup. Everything
in this section follows from that decision — a claim is a statement about the past, and the question
each threat asks is what happens when the present disagrees with it.
| ID | Threat | Control | Verification | Residual |
|---|
| TH-07 | A Scope Admin reaching a scope they do not own | ScopeOwnershipChecker re-reads ownership from the database on every attempt, and excludes a deleted person | Test | Low |
| TH-08 | A demoted person keeping the authority they held when their token was issued | ActorLivenessFilter compares the role claim against the stored role on every request, and refuses the token when they disagree | Test | Low |
| TH-09 | A deleted or removed identity continuing to act on an unexpired token | ActorLivenessFilter re-reads both identity tables per request (FR-AU-05, FR-GO-12) | Test | Low |
| TH-10 | Inferring another tenant’s row counts from sequential identifiers | Only PublicId GUIDs cross the boundary (NFR-15) | Test | Low |
| TH-11 | Granting oneself or another person the System Admin role | UpdatePersonCommandHandler refuses any role change unless the acting role is System Admin, and supports no transition other than to System Admin (UC-08) | Test | Low — the claim it reads is now verified against the row by TH-08’s fix |
| TH-12 | Acting on a scope after being removed as its owner | ScopeOwnershipChecker re-reads, so removal takes effect immediately | Test | Low |
| TH-41 | A Scope Admin learning which other tenants’ scopes a co-owner owns, by reading that co-owner through a scope the two share | Every projection a Scope Admin reads about another person — GET /api/persons/{id}, a scope’s owners and its Users, and the person update’s response — lists only the ownerships of scopes the reader owns too (FR-PE-19), and the Scope Admin listing is projected to identifier, name and email (FR-PE-12). A System Admin and the person themselves still see every ownership | Test | Low: adding a co-owner needs no acceptance by the person added (UC-21); FR-PE-12 lists Scope Admins by design |
| TH-36 | A person’s bearer token used by someone other than the person: to change the address recovery follows, to add a second factor, or to keep acting after the person’s credentials have changed | An address change requires proof of identity, because recovery follows the address: an own email change requires the current password, and the second factor when it is active (FR-PE-14). Adding a second factor requires the current password, as removing one does (FR-2F-18). Both endpoints sit under the credential endpoints’ rate limit and the password-hash gate. Any email change notifies the previous address, without the new one or a link (FR-PE-15). Every person token — full, challenge, subject-rights-only — carries the person’s security stamp, which a password reset, an email change, a two-factor activation or removal and a recovery-code regeneration replace; ActorLivenessFilter refuses a token whose stamp is missing or out of date, from the next request on, so tokens are revoked on credential events (FR-AU-11). The stamp is replaced by a targeted write before the change it accompanies is saved — and, for a password reset or an email change, again after it, so no token issued while the save was in flight survives — and whole-row saves cannot undo it | Test | Medium: until a credential changes, a token still acts as its person for the rest of its lifetime (HEIMDALL_AUTH_TOKEN_EXPIRATION_IN_SECONDS, an hour by default), and there is no sign-out that revokes on demand. Consumers that validate person tokens offline never see the stamp, so at them a revoked token stays valid until it expires: the token lifetime is the bound there too. The password and factor checks a token can reach — an own email change, enabling, confirming and disabling two-factor authentication, regenerating recovery codes and requesting erasure — share a per-account re-authentication budget (FR-AU-13): five failures within fifteen minutes pause them for fifteen minutes, however many source addresses the guesses come from, and every refusal reads the same. The budget is deliberately not the login lockout (FR-AU-09), so a token cannot be used to lock its owner out of signing in; the price is that a token holder gets about twenty guesses an hour for the token’s lifetime, and can keep the owner’s re-authentication paused meanwhile. A System Admin’s or owning Scope Admin’s token can change another person’s email without that person’s password, as their authority allows; the notice and the revocation make that visible to the person rather than silent |
| TH-37 | An authenticated caller using Heimdall’s own mail to flood an address they set on their own account without proving it is theirs: asking for the verification link again (UC-15), starting two-factor setup with the email method (UC-36), switching their address back and forth so that every change away from it mails a notice (FR-PE-15), or turning on the email sign-in method so that every sign-in mails a code there | The email sign-in method cannot be on while the address changes (FR-PE-18): an own change is refused until it is turned off, and setting it up again mails the new address a code that must be confirmed. A sign-in code goes to the address registered on the account. The other three share one per-person account-email budget (FR-PE-16): one per HEIMDALL_ADMIN_EMAIL_COOLDOWN_IN_SECONDS (five minutes by default) and five in each day-long window from the first, reserved by one conditional write on the person row before anything is issued, changed or sent, so a burst gets exactly what is left (NFR-27); refused with 429 and a Retry-After. Two-factor setup reads it before the password and charges it only after, so a stolen token without the password cannot spend the owner’s day. The resend is also under the credential endpoints’ per-IP rate limit | Test | Medium: an account pointed at an address can still have up to ten budgeted mails sent there in any 24 hours, and — through anonymous recovery, which needs nothing but the address (TH-38) — about three hundred reset links a day; each further account multiplies both. Confirming a new address before it receives anything would remove this, and is not built |
TH-08 is the finding this section exists for, and it is now fixed. As first written the role was
carried in the roleId claim and never re-read: ActorLivenessFilter asked only whether the person
existed and was not deleted, and ScopeOwnershipChecker grants an unconditional bypass when the
acting role is SystemAdmin, taken from the claim without a lookup.
So demoting a System Admin does not take effect until their token expires — an hour by default, and
configurable upward. For that hour the demoted account keeps cross-tenant authority over every scope
in the system, and the bypass means it does so without any query that could have noticed. This is the
one case where the design’s decision to trust claims has a consequence that deletion does not: an
account that is deleted is stopped within one request, and an account that is merely demoted is
not stopped at all.
And the window is enough to make itself permanent. A role change is refused unless the acting
role is System Admin — read, like everything else, from the claim — and the one transition the
handler supports is a promotion to System Admin. A demoted account therefore spends its remaining
token lifetime able to promote itself straight back, or to promote an account it controls, and that
promotion is a database write that outlives every token involved. What looks like a bounded window of
stale privilege is a path to an unbounded one, which is why TH-11 is Medium rather than Low: its own
control is sound, and it inherits this one’s weakness through the claim it trusts.
The fix is the mechanism TH-09 and TH-12 were already Low for: ActorLivenessFilter was reading the
Person row on every request anyway, so it now selects the role out of that row instead of asking
whether it exists, and refuses the request when the claim disagrees. No extra query. A promotion is
refused on the same rule as a demotion — a check that only rejected claims above the stored role
would be the one worth writing carefully and then getting backwards.
Two things fell out of making it true. A token whose role is out of date now gets its own message
rather than the deliberately vague one deletion gets, because a role change is not an existence
question and the caller can act on it. And a functional test that had been minting a System Admin
claim for a person stored as a User stopped passing — it was relying on exactly the forgery this
closes, and now names an actor whose claim and row agree.
6. TB-3 — The API to its database
The threats here assume an attacker who can read the database and nothing else: a leaked backup, a
compromised read replica, a restored snapshot, or an injection that yields rows. The question each
one asks is what that reader can do with what they find.
| ID | Threat | Control | Verification | Residual |
|---|
| TH-13 | Recovering passwords from stolen rows | Argon2id with a per-person salt (NFR-02) | Test | Low |
| TH-14 | Taking over an account using a stolen password-reset or email-verification token | Both are stored as a SHA-256 digest and never in the form a caller presents (SingleUseTokenHash) | Test | Low |
| TH-15 | Recovering TOTP secrets from stolen rows | Encrypted at rest with ASP.NET Data Protection (NFR-16) | Test | Medium |
| TH-16 | Replaying a stolen second-factor recovery code, or recovering one from a stolen row | 80 bits from a CSPRNG, sixteen characters of Crockford’s base32 (FR-2F-20), stored as a SHA-256 digest, single-use (NFR-16) — spent by a conditional write, so two simultaneous requests cannot both redeem it (NFR-27). A digest is not reversible by search at this entropy: finding one code takes about 2^79 SHA-256 evaluations, and because the digest is unsalted a search over every stored code at once divides that by their number — at a million codes still about 2^60, years of one GPU, for one code of an account nobody chose, which is good only beside that account’s password. A slow hash would cost a derivation per unused code on every presentation for no gain at this entropy; a pepper held outside the database is the next step should the table grow by orders of magnitude. Accepted residual: codes issued before FR-2F-20 keep their earlier strength until they are used or regenerated (UC-40), and regenerating moves a person to the current format | Test | Low |
| TH-17 | Injecting SQL through a caller-supplied value | EF Core parameterises every query; no string-concatenated SQL | Inspection | Low |
| TH-18 | Denying an action that was taken | Every write produces an audit entry (NFR-09); database triggers refuse DELETE and TRUNCATE outright and permit exactly two kinds of UPDATE — clearing the actor attribution once due, and clearing a target that names a person or Google User — on record, or tombstoned by a hard delete and gone — once due (NFR-21); a trigger on ERASED_SUBJECT accepts a tombstone only for a person or Google User that exists, public_id is immutable on PERSON, GOOGLE_USER and every table whose ids the identity check trusts, no new row of those tables may take an id another holds, and a new person or Google User may not take an id an audit entry names (#199), so with DML alone no existing target that is not a person’s can be made clearable. The triggers bind every role but the tables’ owner and a superuser, so the supported deployment runs the API as a role that owns nothing (NFR-29; operations guide, “Database roles”): migrations run as a separate owner role (HEIMDALL_MIGRATIONS_CONNECTIONSTRING, or out of band), and the API’s role has row privileges only — no DELETE on audit_log, no TRUNCATE, no CREATE — so it cannot disable, drop or replace a trigger or its function. The operator creates the roles and applies the grants; nothing in the API can | Test (the triggers) + review (the grants, checked by hand against PostgreSQL 18; no committed test applies them) | Low where the two-role setup is applied — but see below on superusers and §6.1’s insert race. Medium where the API’s role owns the schema |
TH-14 was a straightforward inconsistency, and it is now fixed. PasswordResetService and
EmailVerificationService both used to write the token they had just generated straight into the
row — Token = token — while a second-factor recovery code, which is weaker as an
account-takeover primitive since it still requires the password, was stored as a hash, and a 2FA
email code hashed and salted. A reader of the password_reset_token table could complete a reset for
any account holding a live token, which did not break the Argon2id work protecting the password so
much as walk around it.
Both now store a SHA-256 through SingleUseTokenHash, and the presented token is hashed and looked
up against a unique index on the digest. Unsalted, deliberately: these are 48 characters from a
CSPRNG, so there is no precomputed table for a salt to defend against, and a per-row salt would force
a scan of every live token on every attempt — the caller presents the token alone, with nothing to
narrow the candidates by. The migration computes the digest from the column it replaces rather than
discarding rows, so links already sitting in inboxes keep working.
What it cost is worth recording, because it is the kind of loss a table of green ticks hides. Two
functional tests used to drive issue-then-spend end to end by reading the token back out of the row.
They cannot: the suite has no inbox and no seam to substitute a sender through. They now assert what
UC-12 and UC-06 stored, and the spending half is covered where the plaintext is still in the test’s
hands — the service tests capture it from the sender, the handler tests seed through the same helper.
Both halves go through SingleUseTokenHash, so they would fail together if it changed.
TH-15 is Medium rather than Low for a reason worth stating. The TOTP secrets are encrypted, and
since the key ring is persisted to the database so that a second instance can decrypt them (SRD
§6.2), the ciphertext and the keys that open it now live in the same store. That was the right call
for availability — before it, a recreated container silently locked every authenticator user out —
but it means the encryption at rest defends against a stolen table and not against a stolen
database. Naming that is the point of writing it down; moving the key ring to a separate secret
store is a real change with its own operational cost, and is not being recommended here without one.
TH-18 was Medium because the audit log was append-only by convention. AuditLog had said
“append-only: never updated or logically deleted after creation” since it was written, and nothing
enforced it: the API’s own credentials were sufficient to rewrite the trail, so it was evidence
against an ordinary caller and none at all against anyone who reached the database.
A trigger now refuses UPDATE, DELETE and TRUNCATE on the table. A trigger rather than a
permission grant alone, because a grant depends on the deployment connecting as a role that lacks
those rights, and this repository’s compose file, the functional suite and most development setups
all connect as the owner; a trigger travels with the schema and holds for every role but the
table’s owner and a superuser.
TRUNCATE needs naming separately because it skips BEFORE DELETE entirely, and the triggers fire
per statement rather than per row so that a DELETE matching nothing is refused too — otherwise the
rule would depend on what the statement happened to hit.
It does not defend against somebody who can also run DDL: a superuser, or the table’s owner, can
disable or drop the trigger, or replace its function, and then rewrite history. That is smaller
than the hole it closes — it leaves a schema change behind, where an UPDATE left nothing — but it
is not zero, which is why the row above is qualified rather than simply Low.
Who owns the tables decides what the triggers are worth. A table’s owner needs no superuser
to change its triggers, so the triggers hold against a role only while it does not own the
schema. The supported setup therefore separates the two (NFR-29). An owner role applies
migrations — the entrypoint gives the bundle HEIMDALL_MIGRATIONS_CONNECTIONSTRING when it is set,
or the operator migrates out of band — and the API connects as a role that owns nothing, with the
grants the operations guide gives (“Database roles”). audit_log keeps UPDATE at table level for
that role, because the retention pass writes the rows it clears whole; which updates are accepted
stays the triggers’ decision, and the role cannot change them.
The grants were reviewed and checked by hand against PostgreSQL 18, in a throwaway database: as the
runtime role, changing or disabling a trigger, replacing or dropping a trigger function, TRUNCATE,
DELETE FROM audit_log, CREATE in any schema and SET session_replication_role were refused,
and the API and its handlers ran their functional suite connected as it. No committed test applies
them, so a migration that needs a new privilege is caught only by that review. The setup is the
operator’s to apply, and the API cannot tell whether it has been, so the row above is rated by
deployment.
6.1 The writes the trail now permits, and why it is still Low
NFR-21 narrowed the rule. AUDIT_LOG no longer refuses every UPDATE: it permits the actor
attribution to be cleared and, since #184, a target that names a person or Google User — since #195
also one whose row was hard-deleted, recognised by its tombstone — and nothing else. That is a real
reduction in what the triggers guarantee, and it is recorded here rather than left for a reader to
discover in a migration.
Why it was necessary. actor_person_id holds a person’s PublicId. An indefinitely retained,
unalterable row naming somebody who has exercised their right to erasure is personal data processed
with no remaining basis, so a table that refused every write made erasure impossible by
construction. The two obligations were in direct conflict, and one of them had to give ground.
target_id raised the same conflict a second time: for a command somebody runs on their own account
it is their own PublicId, and for an administrator’s action on a person it is that person, so an
entry with its actor cleared could still name the person it was about.
Why the ground given is narrow. The trigger permits a write only when every column except
actor_person_id, actor_role and target_id is identical, each of those is either unchanged or
being set to NULL rather than to another value, and each clearing is due — the entry is older than
thirty days, or the identity it names has been anonymised. A target may be cleared only when it is
the PublicId of a PERSON or GOOGLE_USER row; one naming a scope, an application, a credential
or a permission, or nobody at all, stays immutable. The one exception (#195) is an id that no row
has any more but that ERASED_SUBJECT records as a hard-deleted person’s or Google User’s: that
target may be cleared at any age, as an anonymised person’s may. DELETE and TRUNCATE remain
refused outright, per statement.
The tombstone table is guarded too (#195, R1). A row in ERASED_SUBJECT is what makes a target
with no row clearable, so a table anyone could write to would have reopened what the triggers close:
insert a live scope’s id, then clear every target naming it, with the planted row removed by the
retention pass a day later. A BEFORE INSERT OR UPDATE trigger on the table therefore refuses any
row whose public_id is not, at that moment, a PERSON or GOOGLE_USER row’s. Every legitimate
write passes, because a hard delete writes its tombstones before deleting, and so does the retry’s
upsert, which only happens while the row survives. Checking UPDATE as well stops a legitimate
tombstone being rewritten to another id afterwards.
What an attacker gains. Somebody holding the application’s database credentials — those of a
runtime role that owns nothing, as above — can clear an attribution or a person target older than
thirty days, or one of any age naming an identity they have first managed to anonymise. They cannot
remove an entry, alter what it says, reassign its actor or its target to somebody else, clear a
target that is not a person’s, or clear their own attribution, or the person they acted on, for
something they did this week. Anonymising an identity through the API is itself an audited write,
and doing it to their own account destroys their access.
The tombstone ground adds one path, and the guard bounds it, stated plainly. The same credentials
can tombstone a person or Google User that exists and then delete their row with plain SQL, which
the API would have audited and SQL does not; after that, the targets naming them clear at any age.
That is the power they already had — delete a person outright, or anonymise them first and clear the
same targets — exercised on the same people, and it is destructive and visible, since the person is
gone. What the guard stops is tombstoning an id that is not a person’s, so two statements no longer
make a target naming a scope, an application or a credential clearable, as they would have without
it.
The ids are guarded too (#199). The guard, and #184’s rule before it, read a PERSON or
GOOGLE_USER row’s public_id as proof that an id is a person’s, and until #199 that proof could
be forged with DML alone: give a person or Google User row a scope’s, an application’s or a
credential’s public_id, by rewriting an existing row or by inserting a new one, then anonymise or
tombstone it and clear the targets naming that entity. Three rules now close every such path.
- Ids are immutable. A
BEFORE UPDATE trigger refuses any change to public_id on PERSON,
GOOGLE_USER, SCOPE, APPLICATION, APPLICATION_CREDENTIAL, SCOPE_PERMISSION, ROLE and
PENDING_REGISTRATION, every table the identity check trusts. Every other column stays writable,
so names, addresses, anonymisation and soft deletes are unaffected. Without it the check below
could be walked round: move a scope off its id, insert a person with the freed id, clear the
scope’s targets, move the scope back. - Ids are not shared. A
BEFORE INSERT trigger on the same eight tables refuses a public_id
that a row of any other of them holds, soft-deleted or not, or that ERASED_SUBJECT records as a
hard-deleted person’s or Google User’s; a table’s own unique index covers itself. It runs on every
table, not only the identity ones, so the order of the two inserts does not matter: a person
inserted first, or the tombstone it leaves when hard-deleted, refuses a scope inserted with its id
after it. The tombstone matters because the target rule clears, at any age, a tombstoned id that
no identity holds: without it, SQL alone could insert a person, tombstone it, delete it and put a
scope on the id, and every entry later written about that scope would be cleared (#199, R4). - An identity may not take an id the trail names. The same trigger refuses, for
PERSON and
GOOGLE_USER only, an id that an audit entry’s target_id names. After an ordinary hard delete
of a scope, an application or anything else, no row holds its id any more, and an anonymised
person planted with it would have made every entry about that entity clearable. Only the identity
tables need it, because the target rule reads only them, and the tombstones, to tell a person’s
id. It keeps nothing new, since the trail already holds the id, and once the trail no longer names
it the id is free again.
The application only ever creates fresh random ids, and writes an identity’s own entries after its
row, so no legitimate write is refused. IdentityPublicIdTests and EntityPublicIdTests pin each
rule, each table, the tombstone path, and the rename-then-reuse chain end to end.
What remains is stated as plainly. No registry of issued ids is kept, because it would keep a
removed person’s id for good, so an id that neither a row, a tombstone nor the trail holds any more
may be taken again; it then names nothing the trail could clear. The triggers guard future writes
and do not inspect rows already stored; Operations gives the queries to check them. The insert check
takes no lock, so two concurrent transactions that both choose the same id can both pass it, and an
actor controls both. That race reaches only a row the actor inserts by SQL at that moment, never one
that already exists or that the API creates, whose id cannot be known in advance; entries written
later about such a planted row could be cleared. Closing it would need one unique index across all
eight tables, which is a registry, and is out of scope. And an actor who can run DDL, or disable
triggers (ALTER TABLE … DISABLE TRIGGER, or session_replication_role as a superuser), can do
anything here, as with the audit triggers themselves. That is the tables’ owner or a superuser,
which the API’s role is not once the two-role setup under TH-18 is applied.
Residual risk: Low. The property this threat is about — that an action cannot be denied — is
intact: with DML alone, a target can be cleared only when it names an identity that has held its id
since it was created, or one the actor planted in the same moment as the entity it shares an id
with. What became time-limited is attribution, which is what storage limitation requires of it, and
the person an action was taken on is forgotten on the same terms. AuditActorClearingTests and
AuditTargetClearingTests pin every case against real PostgreSQL, each refusal by its own reason,
the tombstone cases included; ErasedSubjectsTests pins the tombstone table’s guard, and
IdentityPublicIdTests and EntityPublicIdTests the public_id triggers.
7. TB-4 — The API to Google
| ID | Threat | Control | Verification | Residual |
|---|
| TH-19 | Presenting a forged or replayed Google ID token | Signature, issuer, audience and expiry are all verified before any claim is trusted (NFR-13). A client may also send the nonce it gave Google (FR-GO-20): the token’s nonce claim must then equal it, or the token is refused as AF-25a — a client-side check that the token it presents was issued for its own sign-in request. Accepted residual: the nonce is chosen by the client rather than issued by the server; a server-issued nonce is a planned follow-up | Test (audience, nonce) + inspection (Google’s own checks) | Low |
| TH-20 | Presenting a token minted for a different OAuth client | The configured client IDs are the trusted audience, and a test requires that constraint to be present | Test | Low |
| TH-21 | Reaching an existing password account by signing in with the same address through Google | The two identity tables are never joined on email: the sign-in resolves by Google’s sub within the scope, the token names the Google User’s own PublicId, and UC-25 issues the User role unconditionally | Test | Low |
| TH-40 | Taking a place in a scope with a Google account whose address Google has not verified, so that the address’s real owner cannot be created or registered there (FR-GO-07) | A sign-up requires the verified token’s email_verified to be true (AF-25f); an absent claim is refused with a false one. It is checked before the address is compared with the scope’s, so such a token learns nothing about which addresses a scope holds either. An existing Google User still signs in whatever the claim says, and FR-GO-19 keeps its stored flag current | Test | Low |
TH-21 was the one gap this document opened against itself, and it has now been traced. The answer
is that a Google sign-in cannot reach a password account, for three independent reasons: the account
is looked up by Google’s sub within the scope and never by email, so a Person is never a candidate;
the token names the Google User’s own PublicId, which is a different GUID from any Person’s; and
UC-25 mints the User role unconditionally, which ActorLivenessFilter now enforces for any Google
identity. Two tests pin it — one that the colliding sign-in creates a separate row and leaves the
admin’s record untouched, one that the token it returns is refused by an endpoint the admin whose
address it shares could reach.
What the trace did turn up is narrower, and is recorded here as accepted rather than as a defect.
AF-25c asks whether the address is free within the scope, and asks the person half of that question
only of persons holding a SCOPE_USER row. An admin has none — UC-06 path b creates them with no
scope association — so an admin’s address does not read as taken and the sign-up proceeds. The
address then exists twice across the two tables.
That is left as it is on purpose. The alternative is that any address belonging to a system-wide
admin blocks Google sign-up in every scope, which is a worse rule than the one being avoided: it
turns a system-wide account into a veto over per-scope sign-ups, for no gain that the three
separations above do not already provide. Nothing in the system keys off an email across the two
tables — login and password recovery read Persons, Google sign-in reads Google Users — so the
duplicate is an operational surprise rather than a way in. It is written down because a reader of
FR-GO-07 would not expect it.
8. TB-5 — An operator to configuration, logs and images
| ID | Threat | Control | Verification | Residual |
|---|
| TH-22 | Minting a valid token for any identity, using the signing secret | The secret is supplied by environment and never logged, and can be rotated: HEIMDALL_AUTH_TOKEN_SECRET_PREVIOUS keeps the retired key acceptable while the new one signs. Start-up refuses a current or previous secret shorter than 32 bytes or holding a character outside ASCII, naming the variable and never the value, and an empty previous secret is no key at all (NFR-31); tokens are accepted under HS256 only (ArturRios.Jwt 1.3.0) | Test + inspection | Low |
| TH-43 | Creating a token that Heimdall accepts, by a service that holds the signing key | Every reader of a Heimdall token checks, beside the signature and lifetime, that it names Heimdall’s issuer and exactly one of the audiences that reader accepts (NFR-30) | Test | Medium: a service holding the signing key can create tokens; asymmetric signing is planned |
| TH-23 | Reading a delivered verification, reset or 2FA code — or a recipient address — out of the logs | Production fails startup when email delivery is unconfigured, rather than falling back to logging the token; and no log statement writes an address in any environment, a reference to it instead (NFR-22) | Inspection + test | Medium |
| TH-24 | Using the seeded master account | Credentials are supplied by environment and required at startup | Inspection | Medium |
| TH-42 | Reading personal data or request details from an internet-facing instance’s diagnostics: EF Core’s parameter logging and detailed errors, on in every environment but Production, and the developer exception page, on in Development and Local | On an instance whose configuration names a public host — PUBLIC_HOST, UI_HOST, the verification or reset URL, or a CORS origin naming anything but this machine — all three are off, by one decision taken at start-up, unless HEIMDALL_ALLOW_SENSITIVE_DIAGNOSTICS is true (NFR-28). Setting it is the operator’s statement, in the deployment’s own configuration, that the instance holds made-up people only (Operations & Infrastructure Document §0.2). Either way start-up logs a warning naming the hosts; nothing refuses to start | Test + inspection | Low: the API cannot check the statement, and an instance reachable from outside whose configuration names no public host is taken as private. What such an instance logs about delivered codes is TH-23’s |
TH-43 follows from symmetric signing; asymmetric signing is planned, and until then the key is
handled as TH-22 describes.
TH-22 was Medium because there was no rotation story, and it now has one. A leaked signing
secret is total — it mints any identity at any role — and the only remediation available was a hard
cutover: change the secret, and every token in flight dies at once. A remediation that is also an
outage is one nobody performs pre-emptively, which is why the secret was in practice never rotated.
HEIMDALL_AUTH_TOKEN_SECRET_PREVIOUS is the other half. Both secrets are handed to
JwtConfiguration.Keys, so a token signed with either is accepted while new tokens are signed with
the current one. Rotating is three steps and no outage: set the previous variable to the secret in
use, set the current variable to a new one, restart; then an hour later — one token lifetime — clear
the previous variable and restart again. Withdrawing a key still invalidates its tokens immediately,
which is what makes the shortened version of that procedure the right response to a leak.
No kid is written to the tokens, deliberately. The library supports it, but the identifiers here are
positional — today’s “current” is tomorrow’s “previous” — so stamping one would make every token
issued before a rotation name the wrong key afterwards and be refused, which is precisely the failure
this closes. Deriving stable identifiers from the secrets instead would publish something computed
from a signing key on every token, a poor trade for saving one HMAC against two keys. Validation
tries both.
TH-23 now covers the address as well as the token, and the two halves differ in scope. The
token control is environmental: Production refuses to start with delivery unconfigured, so the
logging senders that write a token never run there. The address control is not environmental — no
statement writes an address in any environment, and LogRedactionTests fails the build if one
reappears. That closes a gap the original entry did not name: the token was only ever logged outside
Production, but MailgunSender wrote the recipient’s address on every successful send in
Production, into files that were then kept forever.
TH-23’s token control covers Production and by design does not cover anything else. A developer or
staging deployment without Mailgun credentials logs verification tokens, reset tokens and 2FA codes
in plaintext, which is deliberate and documented: the functional suite and a local run both need
those flows to work without credentials or network. It is listed here because a staging deployment
with real users’ addresses in it turns a convenience into an account-takeover primitive for anyone
who can read a log. The development and homologation environments (the latter running as Staging) are exactly
such deployments, which is why they hold made-up people only — §0.2 of the
Operations & Infrastructure Document
. Since
NFR-28 (TH-42) such an instance, serving a public host, runs without EF Core’s sensitive logging and
the developer exception page unless HEIMDALL_ALLOW_SENSITIVE_DIAGNOSTICS is true.
8.1 Detection
Every control in this model is preventive. Until NFR-26 there was nothing that noticed a control
being tested — the audit trail recorded each refusal with its reason, the lockout counters tracked
per-account guessing, and the hash gate shed under load, and none of it reached anybody. A trail
nobody reads does not make anyone aware of anything, and awareness is what starts GDPR Art. 33’s
seventy-two hours.
SecurityMonitoringService now reads those signals on a schedule and writes what exceeds its
thresholds to the log with a matchable marker. It is deliberately not a decision procedure: what
follows a signal is the Incident Response Document
, because
automating the declaration would put the notification clock in the hands of a threshold.
9. What this model does not cover
Risk to the data subject. This model asks what an attacker can do to the system. What the
system does to the people in it — including where a control works exactly as designed and is still
a harm, as an account lockout is — is the
Data Protection Impact Assessment
. The two are
complementary and neither substitutes for the other.
No dynamic testing. Nothing here was found by attacking a running deployment. A design review
finds missing and mismatched controls; it does not find the ones that are present and broken.
Denial of service beyond TH-03. The load runs (SRD §6.3) establish what the API delivers under
concurrency, not what it does under a hostile request pattern chosen to be expensive. TH-03 now
records the one such pattern this review traced — a distributed flood keeping the hash gate full —
as an accepted residual for the edge to bound; others have not been looked for.
The infrastructure the API runs on. Container escape, host access, network position and
PostgreSQL’s own configuration are all out of scope, and the Operations & Infrastructure Document
is where they would belong.
The api-client. The published client is not modelled here; every threat above is stated from
the API’s side of the boundary.
Supply chain beyond known advisories. scripts/vulnerabilities.py fails the build on a
published advisory. It says nothing about a dependency that is malicious and not yet reported.
10. Adding a threat
The same rule as a use case and a non-functional requirement: it does not exist until something
checks it.
A change that crosses a trust boundary in §3 — a new anonymous endpoint, a new claim trusted for
authorization, a new table holding a secret, a new external identity source — needs a TH-nn entry
before it merges, with a control and a verification method. If the method is “none”, that is
permitted and it goes in §11, because an acknowledged gap is worth more than a table of green ticks
that hides one. What is not permitted is the entry being absent.
11. Findings register
Every threat above now has a control and a verification method, so this table is empty for the first
time since the document was written. That is a statement about coverage, not about safety: §9 lists
what this model does not look at, and the register can only be as complete as §4 to §8 are.
11.1 A correction
An earlier revision of this section said TH-22 could not be closed in this repository and needed a
change to ArturRios.Jwt and ArturRios.Util.WebApi first. That was wrong, and wrong in a way worth
recording rather than quietly editing out.
It was based on reading JwtMiddleware as taking a single JwtConfiguration, and concluding there
was no seam for a second key. There was: AuthenticationMiddleware runs a collection of
ITokenValidators, that interface is public, and JwtHandler.IsTokenValidAsync already took the
secret as a parameter — so this API could always have registered its own validator and rotated
without either library changing at all.
The libraries were changed anyway, because the alternative was every consumer rebuilding the same
validator, and because a rotation belongs where the tokens are signed. But the reason recorded here
for not doing it sooner was a misreading, and a threat model that cites an obstacle should be right
about the obstacle.
11.2 Closed
Kept rather than deleted, because a register that only ever lists open items gives no sense of what
this document has been worth.
| Threat | What it was | What closed it |
|---|
| TH-14 | Password-reset and email-verification tokens were stored in plaintext, while the weaker recovery codes beside them were hashed | SingleUseTokenHash: both are stored as a SHA-256 digest, the migration converts existing rows rather than dropping them, and the issue and spend paths share one helper |
| TH-08 | A demoted account kept its authority until its token expired, and could spend that window promoting itself back permanently | ActorLivenessFilter compares the role claim against the row it was already reading, and refuses a token whose role is out of date |
| TH-21 | Nobody had established what a Google sign-in does with an address that already belongs to a password account | Traced: resolution is by Google’s sub, the token names the Google User’s own PublicId, and the role is always User. Two tests state the answer |
| TH-03 | The per-IP rate limiter was the only bound on login’s memory demand, and one address could ask for 6 GB of Argon2id working set a minute within policy | PasswordHashGate: four concurrent derivations process-wide, every derivation on a request path, 503 rather than an unbounded queue. Measured at no latency cost |
| TH-18 | The audit log was append-only by convention; the API’s own credentials could rewrite the trail | Triggers refusing DELETE and TRUNCATE per statement, so an empty DELETE is refused too, and permitting only the two UPDATEs NFR-21 requires, clearing the actor attribution and clearing a person target, on record or tombstoned, with tombstones accepted only for a person or Google User that exists, public_id immutable on the identity tables and every table they are checked against, never shared by a new row of any of them, and never one the trail names on a new identity — see §6.1. Closed against the API’s credentials where the two-role setup of NFR-29 is applied (TH-18) |
| TH-22 | The signing secret could not be replaced without invalidating every token in flight, so it was never rotated | HEIMDALL_AUTH_TOKEN_SECRET_PREVIOUS: both keys are accepted while the current one signs, on ArturRios.Jwt 1.1.0 and ArturRios.Util.WebApi 3.2.0 |
| TH-35 | Finding: Heimdall’s middleware validates signature and lifetime only, not the audience (ArturRios.Jwt IsTokenValidAsync), so an application token, signed with the person tokens’ keys, would pass it | Response: IdentityUserMapper refuses any token carrying tokenKind; a functional test pins an application token’s 401 on a person endpoint. It also holds the challenge and subject-rights audiences to their claims, so a token whose audience and claims disagree is refused too |
| TH-36 | A person’s token outlived a password reset, an email change and a change of second factor, and an address change or a new second factor needed no proof of identity beyond a token | The security stamp (FR-AU-11): tokens are now revoked on credential events, the one that made the change included. An own email change needs the password and the active second factor (FR-PE-14), adding a second factor needs the password (FR-2F-18), and the previous address is told of any email change (FR-PE-15). Offline consumers remain bounded by the token lifetime |
TH-14 and TH-08 were found by reading the code while writing this document, and neither was visible
from the requirements they were supposed to follow from. TH-21 was found by this document admitting
it did not know — the case for the rule that an unanswered question is written down rather than
assumed safe. TH-03, TH-18 and TH-22 were known limits from the first draft, closed later once their
cost was understood. Two of those are worth a second look: TH-03’s fix was worth measuring rather
than reasoning about, since the obvious prediction — that bounding concurrency would slow login down
— turned out to be wrong; and TH-22 sat open partly because this document had the reason for it
wrong (§11.1).
13 - Development Workflow Document
How a use case goes from backlog to merged: branch, issue status, testing gate, pull request.
Development Workflow Document — Heimdall API
1. Purpose
This document defines how a use case moves from backlog to merged — the branch, the issue status
transitions, the testing gate, and the pull request. It is the standard every contributor (human or
agent) follows so that each use case (UC-01 … UC-29 in the
Use Case Specification Document
, plus later additions
such as UC-30) is delivered the same way.
It complements the Testing Specification Document
, which
defines how the tests themselves are written; this document defines when they happen in the
delivery flow.
One use case = one branch = one issue = one pull request.
2. Workflow at a glance
flowchart TD
A["Pick a use case<br/>(issue in Todo)"] --> B["Create branch from develop<br/>feature/uc-##-use-case-name"]
B --> C["Move issue → In Progress"]
C --> D["Implement the use case"]
D --> E["Move issue → Testing"]
E --> F["Write unit + functional tests"]
F --> G["Run tests"]
G --> H{All pass?}
H -->|No| I["Fix code / tests"]
I --> G
H -->|Yes| J["Open pull request into develop"]
J --> K["Human review"]
K --> L{Approved?}
L -->|Changes requested| I
L -->|Yes| M["Human merge to develop<br/>delete feature branch"]
M --> N["Move issue → Done and close it"]The diagram shows the default flow, where a human reviews and merges. In an authorized batch run
the agent merges its own pull request instead — every other step, including the testing gate, is
identical. See Step 7.1
.
3. Issue status lifecycle
Each use case is tracked by its GitHub issue on the project board. The Status field moves through
these columns, in order:
| Order | Status | Set when |
|---|
| 1 | Todo | The use case has not been started (default). |
| 2 | In Progress | A feature branch has been created and implementation has begun. |
| 3 | Testing | Implementation is finished; unit and functional tests are being written, run, and fixed until green. |
| 4 | Done | The pull request has been reviewed and merged to develop; the issue is then closed. |
An issue only ever moves forward during normal flow. If review requests changes, work continues
on the same branch (still linked to the same issue) until tests pass again and the PR is re-reviewed.
4. Step-by-step
Step 1 — Branch from develop
Every use case is implemented on its own branch, created from an up-to-date develop — the
integration branch. main only ever receives releases (see §5
):
git switch develop
git pull
git switch -c feature/uc-01-create-scope
Branch naming pattern:
feature/uc-##-use-case-name
## — the zero-padded use case number (01, 02, … 29).use-case-name — the use case name in lower-case kebab-case.
A change that is not a use case — a defect, a dependency, documentation — goes on a
fix/<name> or feature/<name> branch in the same way. Those two prefixes are the only ones the
Branch Policy check accepts into develop (besides Dependabot’s), and it also refuses a branch
that was cut from main instead of develop.
| Use case | Branch |
|---|
| UC-01: Create Scope | feature/uc-01-create-scope |
| UC-11: Login (Authenticate) | feature/uc-11-login |
| UC-25: Sign Up / Sign In via Google | feature/uc-25-sign-up-sign-in-via-google |
Step 2 — Move the issue to In Progress
As soon as the branch exists and work starts, set the use case’s issue Status to In Progress
on the project board.
Step 3 — Implement the use case
Implement the use case per its specification (main flow and alternative flows) and the project’s
architecture and technology stack. All commits for the use case go on its feature branch.
Step 4 — Move the issue to Testing
When the implementation is finished, set the issue Status to Testing. This signals that the
feature is code-complete and the testing gate is now in progress.
Step 4.1 — If the change touches personal data
A migration that adds a column holding personal data is not finished when it compiles. Before the
testing gate:
- State the column’s purpose in §5.1 of the
Data Protection Document
.
PersonalDataPurposeTests fails the
build if a column is missing, so this is enforced rather than remembered. - Give it a retention period in the
Data Retention Schedule
, or say which existing row
covers it. A column with no period is one kept forever, which NFR-19 forbids.
- Check whether it changes the record of processing — a new category of data, recipient, or
transfer means the Data Protection Document needs more than a table row.
The order matters: a column that cannot be given a purpose in step 1 is a column not to add. That is
what data protection by design (GDPR Art. 25, LGPD Art. 6 III) asks for, applied at the only moment
it is cheap.
Step 4.2 — The periodic security review
GDPR Art. 32(1)(d) requires a process for regularly testing and evaluating the technical and
organisational measures, not merely having them. That process is:
| When | What |
|---|
| Every quarter | Restore a backup into a scratch environment and run the erasure reconciliation against it (Operations & Infrastructure §0.1). A restore procedure that has never been run is a hypothesis |
| Every quarter | Confirm encryption at rest is still in force for the database volume and its backups, and that the database connection still requires TLS |
| Every six months | Re-read the Threat Model
against the code. A threat closed by a control that has since been refactored away is worse than one still marked open |
| On any change to processing | Revisit the DPIA
, the record of processing, and the retention schedule |
The cadence matters less than the fact that each item has one. An unscheduled review is one that
happens after an incident.
Step 5 — Test until green
Following the Testing Specification Document
:
- Write the unit tests for every Command/Query handler and any new Domain behavior (main flow +
each applicable
AF-xx alternative flow). - Write the functional tests for each endpoint (main flow + every
AF-xx, including the
authorization flows), end-to-end against Testcontainers PostgreSQL. - Run the tests (
dotnet test, and/or filtered by Category=Unit / Category=Functional). - Fix any failures — in the implementation or the tests.
- Re-run, and repeat steps 3–4 until every test passes.
A use case does not leave the Testing stage until the full suite is green.
Step 6 — Open a pull request
With all tests passing, push the branch and open a PR from feature/uc-##-… into develop. The PR
description should reference the use case and its issue (e.g. Closes #<issue-number>) so the issue
is linked to the PR.
Step 7 — Human review and merge
- The PR is reviewed by a human. Requested changes are addressed on the same branch (back to
Step 5 whenever code changes, so the suite stays green).
- Once approved, a human merges the PR into
develop. - The feature branch is deleted after the merge.
Review and merge are human actions. An agent may prepare and push the PR, but must not
self-approve or merge it. The single exception is an authorized batch run — see
Step 7.1
.
Step 7.1 — Authorized batch runs
When several use cases are delivered in one unattended run, the human approval gates would stop the
run at every use case, which defeats the point of batching them. For a batch run only, an agent
may merge its own pull requests, subject to all of the following:
- The batch was authorized up front. A human agreed to the specific use cases, in order, and was
told explicitly that the agent would merge, close the issues, and delete the branches. A general
instruction to work autonomously is not this authorization.
- The invariant still holds. One use case = one branch = one issue = one pull request. Use cases
are never batched into a shared branch or a shared pull request, so the run stays reviewable after
the fact.
- The testing gate is unchanged. The full suite is run and read for every use case, per Step 5.
A merge on an unread or failing suite is never permitted.
- No protection is bypassed. No
--admin merge, no self-approval to satisfy a required review,
no force-push, and no disabling or filtering of a test to make the suite green. - A failure stops the whole run. A red suite, a merge conflict, an ambiguous specification, or a
requirement that does not exist ends the batch. Already-merged use cases stay merged; the failing
branch and its pull request are left in place as evidence.
Outside an authorized batch run, Step 7 applies as written: a human reviews and a human merges.
Step 8 — Close the issue
After the merge, set the issue Status to Done and close it. If the PR used a
Closes #<issue-number> reference, the merge closes the issue automatically — still confirm the
board shows it in Done.
5. From develop to a release
A merged use case is on develop, not yet in production: Jenkins deploys every push to develop to
the development environment, which runs on demand — a stopped one stays stopped until
scripts/ygg.sh env start development on the VPS turns it on (the four environments are listed in
§0.2 of the Operations & Infrastructure Document
).
Releases are cut from develop as release/<major>.<minor>.<patch> branches and reach main only
through a pull request that Jenkins merges itself, after deploying the release to homologation (on
demand, like development) and then to production; it also tags the merge
v<major>.<minor>.<patch>. Nobody merges into main by hand, and a release branch carries no
commits of its own — a fix for a release lands on develop through a fix/ branch, and a new
release is cut. The full procedure, including finalizing the changelog before the release branch is cut, is
in CONTRIBUTING.md
.
6. Definition of Done
A use case is done only when all of the following hold:
7. References
14 - Operations & Infrastructure Document
Hosting, the four environments, the technical foundation and the health-check feature.
Operations & Infrastructure Document — Heimdall API
0. Hosting region
Personal data is stored in Brazil, and that is a constraint rather than an incidental fact: it
determines which transfer rules apply to everything leaving the deployment. Moving the region means
redoing the assessment in
§7 of the Data Protection Document
, because the outbound flows to
Mailgun and Google are measured from wherever the data sits.
Note also that Brazil holds no EU adequacy decision. If any tenant scope serves data subjects in the
EEA, hosting here is itself a restricted transfer under GDPR Chapter V — see §7.2 of that document,
which records this as a question the controller must answer rather than an assumption.
0.1 Encryption, backups, and restoring without undoing an erasure
NFR-25. This section states what the deployment must provide and what the API does about it. Two of
the three requirements are properties of the hosting rather than of this codebase, and are marked
accordingly: the controller must confirm them — they cannot be verified from the repository.
Encryption at rest
| |
|---|
| Database volume | ✅ Confirmed by the controller, 10 September 2026 — the data is encrypted at rest on the server |
| Backups | ✅ Confirmed, 10 September 2026 — encrypted client-side before upload, on a storage service that holds no key able to decrypt them |
| Application-level | ✅ Argon2id password hashes, encrypted TOTP secrets (NFR-16), hashed tokens and recovery codes |
The two rows were confirmed separately, and deliberately so: “encrypted on the server” establishes
the volume, and a backup written elsewhere is a different artefact with its own key management. A
regime that encrypts the live database while shipping plaintext dumps to object storage is common
enough to be worth ruling out rather than assuming away. Both are now confirmed.
The service was identified as MEGA on 10 September 2026, which settles the jurisdiction question
this section previously left open. It is answered below under Where the backups actually are, and
the transfer ground is recorded in
§7.3 of the Data Protection Document
.
The application-level protections are real and tested, and they are not a substitute. The threat
model’s TB-3 covers what an attacker recovers from stolen rows, which presupposes the rows were
read; it says nothing about a disk or a snapshot leaving the building. Everything not listed in the
third row above — every name, every address, every audit entry — is stored in clear text within the
database and is protected only by whatever the volume beneath it provides.
Encryption in transit
UseHttpsRedirection covers the inbound edge. The database connection is the other half, and the
API warns at start-up when the connection string does not require TLS.
Npgsql’s default SSL Mode is Prefer, which silently falls back to an unencrypted connection when
the server does not offer TLS — so a deployment can be sending credentials and addresses in clear
text while looking correctly configured. Set SSL Mode=Require, VerifyCA or VerifyFull in
HEIMDALL_DATA_CONNECTIONSTRING.
It warns by default rather than refusing to start, and that is a departure from how this codebase
treats other security configuration — TH-23’s email control fails start-up in Production. Failing by
default would take down a working deployment on upgrade over a setting the operator may not control
directly.
Set HEIMDALL_DATA_REQUIRE_TLS=true to turn the warning into a refusal. That is the right
setting once TLS is confirmed in place: from that point a connection string that does not ask for
encryption is a misconfiguration rather than a known state, and starting anyway would be starting in
the one condition the check exists to prevent. It is opt-in rather than the default only so that
enabling it is a decision somebody makes, not one that happens to them during an upgrade.
The backup regime
| | |
|---|
| Service | MEGA — Mega Cloud Services Limited, Auckland, New Zealand | ✅ Confirmed 10 September 2026 |
| Schedule | Daily full backup | ✅ Confirmed 10 September 2026 |
| Encryption | Client-side, zero-knowledge — MEGA holds no key that decrypts the contents | ✅ Confirmed 10 September 2026 |
| Retention | 35 days | ✅ Confirmed 10 September 2026 |
| Location | Europe, or a country the European Commission has ruled adequate under GDPR Art. 45. Never the United States | ✅ Per MEGA’s policy — but ⚠️ the individual facility is not selectable, see below |
| Restore testing | Quarterly, including the reconciliation step below | ⚠️ Proposed |
Why 35 days, since the figure is a decision rather than a default. The backup worth having is
usually the one from before the problem was noticed, and silent corruption is typically found days
or weeks late — so a short window can mean every copy held is already spoiled. Below 30 days,
recovery is capped at under a month. Above it, a backup can outlive the 30-day erasure deadline,
which is what makes the restore reconciliation below mandatory rather than optional. Thirty-five
clears the deadline with margin, at a cost already paid: the reconciliation is built and tested.
One row remains. Restore testing is what turns the reconciliation below from a procedure
into a capability. A restore that has never been run is a hypothesis, and the reconciliation step
that follows it has never had a real restore to reconcile.
35 days is chosen against the erasure deadline rather than against recovery convenience: it is
longer than the 30 days §4 of the retention schedule allows an erasure to take, which is what makes
the reconciliation step below mandatory rather than optional. A regime keeping backups for less
than 30 days would not need it — but would also cap disaster recovery at under a month, which for
most operators is unacceptable.
Where the backups actually are
The backups leave Brazil. That is an international transfer of every category in §3 at once —
the whole database, not the single address the email flow sends — so it needs a ground of its own,
which §7.3 of the Data Protection Document
records. What follows
is the factual basis that section rests on.
MEGA’s stated position. Files are held “in secure facilities in Europe or in countries (such as
New Zealand) that the European Commission has determined to have an adequate level of protection
under Article 45 of the GDPR, depending where you are based”, and “none of your files are stored
in, or made available from, the United States of America”. New Zealand’s adequacy is Commission
Implementing Decision 2013/65/EU, confirmed still in force in the Commission’s January 2024 review
of all eleven pre-GDPR decisions.
Three caveats, none of which is glossed here.
- The facility is not selectable. MEGA offers no region setting: “depending where you are
based” is its own routing decision, not the account holder’s. So the destination is a set of
jurisdictions, not one that can be named — which is why the Location row above is marked
confirmed for the policy and not for the facility. The transfer ground in §7.3 is written to hold
for any member of that set rather than for a particular country, because pinning one is not
something this deployment can do.
- Adequacy under Art. 45 is a GDPR finding, and the LGPD has no equivalent list. The ANPD has
published no adequacy decision for any country, so LGPD Art. 33 I is unavailable here no matter
how many European Commission decisions apply. The LGPD ground is a different one, and §7.3 says
which.
- Ownership is not location. MEGA is New Zealand-incorporated and majority-held by a Hong
Kong-registered company. That does not move the data, and it is recorded because a control
question is worth writing down even when the answer does not change the analysis.
What actually carries the weight is the encryption, not the geography. The backup is encrypted
on this side before MEGA receives it, and MEGA holds no key that decrypts it. That is precisely EDPB
Recommendations 01/2020 Use Case 1 — encrypted storage in a third country, for backup, where the
data is never needed in the clear at the destination and the keys stay with the exporter — which the
EDPB accepts as an effective supplementary measure. It is what makes the unpinnable facility
tolerable: an adverse jurisdiction reaching the copy reaches ciphertext.
This makes the MEGA account credentials a control on the whole database. The encryption
argument above holds only while the keys do, and MEGA derives them from the account password.
Somebody who obtains that password obtains every backup in readable form — the one path by which a
full copy of the database becomes legible to an outsider. The account must carry a unique
password and two-factor authentication, and it belongs in the same custody as the production
database credentials rather than in a personal password manager.
What MEGA can see regardless. File sizes, upload timestamps, and the account’s IP addresses are
visible to the service whatever the contents are encrypted with. That is unremarkable for backups —
but it means backup filenames must not carry personal data. heimdall-2026-09-10.dump is fine;
a filename naming a tenant or a person is a disclosure that the encryption does not cover.
Restoring without undoing an erasure
An erasure that the next restore silently undoes is not an erasure.
Backups are full copies and are never edited — editing one destroys the integrity that is its whole
purpose. So a restore reinstates whatever the database held when the backup was taken, erased people
included, and the runbook has to put them back.
Two cases, and only one needs a human.
Where the backup was taken after the subject asked, the restored row still carries its deletion
and its deadline, and NFR-20’s scheduled pass completes the erasure again by itself. Nothing is
required.
Where the backup predates the request, the row comes back with no trace of the request ever
having been made. Nothing inside the database knows to act, because the evidence was rolled back
along with everything else. This is the case the reconciliation exists for.
The erasure ledger. Every run of the anonymisation pass logs the public identifiers it
anonymised. That line is the ledger, and it works precisely because the logs are outside the
database and a restore cannot roll them back. The identifiers are safe to keep and to ship off the
host: a PublicId whose identity has been anonymised resolves to nobody, so it is not personal
data.
The runbook step. After any restore:
- Collect the anonymised identifiers from the logs, covering the period from the backup’s timestamp
to now.
POST /api/auth/erasure-reconciliation with that list, as a System Admin.- Check the response:
anonymised is what the restore had brought back, alreadyAnonymised is
what it had not, and notFound is the ordinary case for most of the ledger. - Record the run. The reconciliation is itself audited.
The step is safe to repeat — running it twice anonymises nothing extra — and it refuses an empty
list, because a reconciliation with nothing to reconcile almost always means the ledger was not
loaded, and reporting success there would let a restore be signed off with erased people back in the
database.
0.2 Environments
The API runs in four environments. Three of them share one Ubuntu VPS — one Docker engine, one
PostgreSQL instance — and are deployed by yggdrasil
’s
Jenkins; the fourth is the developer’s own machine. example.com stands for the real domain.
| Environment | Where | Deployed by | ASPNETCORE_ENVIRONMENT | Public hosts | Database |
|---|
local | The developer’s Windows machine, Docker Desktop | By hand: docker compose --env-file docker/local.env up -d --build | Development | http://localhost:8080 | heimdall_local, PostgreSQL on Windows |
development | The VPS, on demand (started only when used) | Jenkins, on every push to develop; a stopped environment stays stopped, and scripts/ygg.sh env start development on the VPS turns it on | Development | heimdall-api-dev.example.com; /api/ under heimdall-dev.example.com | heimdall_development, PostgreSQL on the VPS |
homologation | The VPS, on demand | Jenkins, on every push of a release/x.y.z branch | Staging | heimdall-api-hml.example.com; /api/ under heimdall-hml.example.com | heimdall_homologation, PostgreSQL on the VPS |
production | The VPS, always on | Jenkins, on a green release/x.y.z → main pull request, which it then merges and tags | Production | heimdall-api.example.com; /api/ under heimdall.example.com | heimdall, PostgreSQL on the VPS |
Each environment has its own database and its own database login, its own token signing secret
and its own master user, so nothing issued or stored in one is accepted or readable in another. The
env files of the three VPS environments are kept on the VPS (/etc/yggdrasil/<environment>/), filled
in from the repository’s docker/<environment>.env.example templates.
Development and homologation hold no real personal data. Development and homologation run without Mailgun, so the
API logs verification tokens, reset tokens and 2FA codes instead of sending them, and EF Core logs
parameter values outside Production — which is what TH-23 in the
Threat Model Document
accepts for non-production deployments, on the
condition that no real person’s data is in them. They are tested with made-up people, and production
data is never copied into them. Because both serve public hosts, the API runs there with EF Core’s
parameter logging and the developer exception page off (NFR-28), unless the env file sets
HEIMDALL_ALLOW_SENSITIVE_DIAGNOSTICS=true; the templates leave it empty.
The supported database setup gives each environment an owner role of its own for the migrations
(HEIMDALL_MIGRATIONS_CONNECTIONSTRING) and an API login that owns nothing and has only the row
privileges it needs (NFR-29, Threat Model TH-18). The operations guide, “Database roles”, has the
grants, the checks and the steps for a new environment and for switching one over; the operator
applies them.
The step-by-step setup of each environment is on the documentation site, under
Environments and deployment
.
1. Introduction
1.1 Purpose
This document captures cross-cutting platform concerns for the Heimdall API that fall outside the identity domain modeled in the Vision Document
, System Requirements Document
, and Use Case Specification Document
.
It covers two areas:
- The technical foundation — the project scaffolding, solution architecture, and initial data infrastructure the domain features are built on.
- Health & monitoring — the operational endpoints used to observe that the API and its dependencies are up, and the Prometheus metrics that show how it is performing.
These are functional/operational capabilities of the platform rather than the identity domain, so they are documented here to keep the domain documents focused while still tracking the work formally. The specific technologies and versions this platform is built on are defined once in the Technology Stack Document
and referenced from here rather than duplicated.
| Item | GitHub Issue | Status |
|---|
| Project scaffolding & initial infrastructure | #31 | ✅ Implemented |
| Health Check feature | #32 | ✅ Implemented |
2. Technical Foundation (Project Scaffolding & Initial Infrastructure)
Corresponds to issue #31
. Status: Implemented (delivered via PR #1 — feat/data-infrastructure — and preceding commits).
2.1 Overview
The solution is a layered (DDD-style) .NET Web API. The foundational scaffolding establishes the project structure, the Entity Framework Core data layer with the initial migration, startup seeding, and the functional test harness that the identity use cases (UC-01 … UC-29) are implemented on top of.
2.2 Solution Architecture
graph TD
subgraph Presentation
WebApi[ArturRios.Heimdall.WebApi<br/>ASP.NET Core Web API host]
end
subgraph Application
Command[ArturRios.Heimdall.Command]
Query[ArturRios.Heimdall.Query]
Shared[ArturRios.Heimdall.Shared]
end
subgraph Domain
DomainLayer[ArturRios.Heimdall.Domain<br/>Entities & data model]
end
subgraph Infrastructure
Data[ArturRios.Heimdall.Data<br/>EF Core DbContext & migrations]
DB[(Database)]
end
WebApi --> Command
WebApi --> Query
Command --> Domain
Query --> Domain
Command --> Shared
Query --> Shared
Command --> Data
Query --> Data
Data --> DomainLayer
Data --> DB2.3 Delivered Capabilities
| Area | Requirement | Status |
|---|
| IR-01 | The solution shall be organized into Domain, Application (Command / Query / Shared — CQRS split), Infrastructure/Data, and Presentation/WebApi layers | ✅ |
| IR-02 | The data layer shall use Entity Framework Core with a design-time factory and an initial migration | ✅ |
| IR-03 | Database tables shall use snake_case, singular naming, with EF diagnostics gated | ✅ |
| IR-04 | Role IDs shall be pinned to the Roles enum values | ✅ |
| IR-05 | On startup, the system shall seed the roles and the master System Admin | ✅ |
| IR-06 | A migration menu script shall be provided under scripts/ | ✅ |
| IR-07 | Environment configuration files shall be copied to the build output | ✅ |
| IR-08 | A functional test container shall apply migrations and assert the resulting schema | ✅ |
| IR-09 | The data infrastructure design spec and implementation plan shall be documented under docs/ | ✅ |
2.4 Technology Baseline
The concrete technologies, libraries, and versions behind this foundation (.NET 10 / C# 14, the ArturRios.* libraries, Entity Framework Core, PostgreSQL, and the testing tools) are defined in the Technology Stack Document
. This section intentionally does not restate them.
3. Health & Monitoring
Corresponds to issue #32
. Status: Implemented (delivered via PR #39 — feature/uc-30-check-api-health). Both the public liveness endpoint and the System Admin-only detailed health check are in place.
3.1 Overview
The API exposes health endpoints so that operators, load balancers, orchestrators, and uptime monitors can observe whether the API process is running and whether its dependencies are healthy. Two endpoints are provided:
- A basic liveness (“hello world”) endpoint — a lightweight, public check that the API is up.
- A detailed health check endpoint — a System Admin-only check that reports the status of each verified service plus an aggregate general status.
The detailed check is intentionally extensible: for now the only verified service is the database connection, but new verifications (cache, email service, external identity providers, etc.) can be added later without changing the response contract.
3.2 Functional Requirements
| ID | Requirement | Priority |
|---|
| FR-HC-01 | The system shall expose a public liveness endpoint (GET /api/healthcheck) that confirms the API process is running and responding, requiring no authentication | High |
| FR-HC-02 | The system shall expose a detailed health check endpoint (GET /api/healthcheck/detailed) accessible only to System Admins | High |
| FR-HC-03 | The detailed health check shall verify the database connection | High |
| FR-HC-04 | The detailed health check shall report the status of each verified service individually | High |
| FR-HC-05 | The detailed health check shall report an aggregate general status of Healthy when all verified services are up, or Unhealthy when one or more verified services are down | High |
| FR-HC-06 | The health check design shall be extensible, allowing new service verifications to be added without changing the response contract | Medium |
| FR-HC-07 | The detailed health check endpoint should map the aggregate status to an appropriate HTTP status (e.g., 200 OK when Healthy, 503 Service Unavailable when Unhealthy) | Medium |
3.3 Endpoints
| Method | Endpoint | Description | Auth Required |
|---|
| GET | /api/healthcheck | Basic liveness check — confirms the API is on (“hello world”) | No (Public) |
| GET | /api/healthcheck/detailed | Detailed health check — reports per-service status and an aggregate Healthy / Unhealthy general status | SystemAdmin |
3.4 Detailed Health Check — Response Contract
The detailed response reports a status (the aggregate general status) and a services array with one entry per verified service.
All services up:
{
"status": "Healthy",
"services": [
{ "name": "Database", "status": "Healthy" }
]
}
Database connection down:
{
"status": "Unhealthy",
"services": [
{ "name": "Database", "status": "Unhealthy" }
]
}
The aggregate status is Healthy only when every entry in services is healthy; if any service is unhealthy, the aggregate is Unhealthy (FR-HC-05). Adding a new verification (FR-HC-06) simply appends another entry to services and participates in the same aggregation rule.
3.5 Use Case — UC-30: Check API Health
| Field | Value |
|---|
| ID | UC-30 |
| Name | Check API Health |
| Actors | Anonymous / Monitoring System (liveness), System Admin (detailed) |
| Description | Observe whether the API is running (liveness) and whether its dependencies are healthy (detailed) |
| Preconditions | For the detailed check, the actor is authenticated with the SystemAdmin role. The liveness check has no preconditions |
| Postconditions | Health information is returned; no system state is modified |
Main Flow (liveness):
- A caller (monitor, load balancer, or anonymous user) sends
GET /api/healthcheck. - The system returns a success response indicating the API is on. No authentication is required.
Main Flow (detailed):
sequenceDiagram
actor SA as System Admin
participant API as Heimdall API
participant DB as Database
SA->>API: GET /api/healthcheck/detailed
API->>API: Authorize (SystemAdmin only)
API->>DB: Verify database connection
alt Database reachable
DB-->>API: OK
API->>API: Aggregate = Healthy
else Database unreachable
DB-->>API: Failure
API->>API: Aggregate = Unhealthy
end
API-->>SA: 200 OK / 503 { status, services[] }- A System Admin sends
GET /api/healthcheck/detailed. - The system authorizes the request; only System Admins may proceed.
- The system verifies each registered service (currently: the database connection).
- The system computes the aggregate general status (
Healthy if all up, Unhealthy otherwise). - The system returns the per-service statuses and the aggregate status.
Alternative Flows:
| ID | Condition | Outcome |
|---|
| AF-30a | Detailed check requested by a caller who is not a System Admin | 403 Forbidden |
| AF-30b | Detailed check requested with no/invalid authentication | 401 Unauthorized |
| AF-30c | One or more verified services are down | 200 OK (or 503, per FR-HC-07) with status = Unhealthy and the failing service(s) marked |
3.6 Authorization
| Action | SystemAdmin | ScopeAdmin | User | Anonymous |
|---|
Basic liveness (GET /api/healthcheck) | ✅ | ✅ | ✅ | ✅ |
Detailed health check (GET /api/healthcheck/detailed) | ✅ | ❌ | ❌ | ❌ |
3.7 Extensibility
The detailed health check is designed so that additional service verifications can be registered over time. Each new verification contributes one entry to the services array and is folded into the same aggregate rule (any unhealthy service ⇒ Unhealthy). Candidate future checks include the email delivery service, caching layer, and the Google Identity Platform integration. Adding them requires no change to the response contract or to consumers that already read status + services.
3.8 Metrics
The health endpoints answer “is it up”; metrics answer “how is it doing”. The API exposes them for Prometheus at GET /metrics, collected in-process with OpenTelemetry (see the Technology Stack Document
).
| Property | Value |
|---|
| Endpoint | GET /metrics, Prometheus text format |
| Port | 9464 — HEIMDALL_METRICS_PORT; blank means the default, 0 disables the exporter entirely |
| Authentication | None — the port is the access control, see below |
| Collected | ASP.NET Core request metrics and Kestrel connections, outbound HttpClient calls, the built-in System.Runtime meter (GC, heap, thread pool, exceptions), EF Core, and the Npgsql connection pool |
| Resource | service.name = heimdall-api |
Access control is the port, not the host name. The API runs behind Traefik, which forwards the client’s own Host header, so any check on it is a check on an attacker-chosen value. The scrape endpoint is instead answered only when the connection arrived on the metrics port (HttpContext.Connection.LocalPort), which the socket decides. Traefik forwards to 8080; Prometheus scrapes 9464 directly over a private Docker network; Compose never publishes 9464. A request for /metrics on 8080 falls through to the API, which has no such route.
The endpoint is the first branch in the request pipeline, so authentication, authorization, rate limiting and CORS never apply to it, and it is not part of the OpenAPI document.
4. Traceability
| Capability | Requirements | Use Case | Issue |
|---|
| Project scaffolding & initial infrastructure | IR-01 … IR-09 | — | #31 |
| Health & monitoring | FR-HC-01 … FR-HC-07 | UC-30 | #32 |