Heimdall is a centralized identity management API. It answers one question for many client systems
at once — who is this caller, and what are they allowed to do? — while keeping each client system’s
identities isolated from every other’s inside its own scope.
Building from source, the test suites and coverage, migrations, the OpenAPI document, branching and releasing — the repository’s CONTRIBUTING.md, rendered.
How the documents relate
The requirements documents are the specification — they say what the system must do, and every
feature traces back to a numbered requirement (FR-…, NFR-…) and a use case (UC-…). The other
sections describe what was built and how to work with it, and cite those identifiers wherever a
design decision came from one.
Single source of truth
The requirements pages on this site are the very files under
docs/requirements/
in the
repository — rendered, not copied. Editing the Markdown updates this site.
1 - Overview
The domain vocabulary — scopes, persons, roles, applications, permissions — and the rules that bind them.
Heimdall is a centralized identity management API. Several client systems delegate their
identity concerns to one deployment of it, and each of those systems gets an isolated slice of the
data called a scope.
Everything else in the model is defined relative to that boundary.
The vocabulary
Term
What it is
Scope
A logical tenant boundary that groups the owners, users, applications, Google Users, and permissions belonging to one client system.
Person
A registered human identity — name, email, password hash, role, deletion status. A person has no scope column; its relationship to scopes is derived from its role.
Role
One of SystemAdmin, ScopeAdmin, User. A closed set of three rows.
Scope Owner
A person with the ScopeAdmin role — or a SystemAdmin who chose to — who owns a scope. Many-to-many: a scope has one or more owners, an admin may own several scopes.
Scope User
A person with the User role who belongs to exactly one scope.
Application
A registered non-human identity (another system), in exactly one scope, owned by exactly one Scope Admin who owns that scope.
Google User
An identity authenticated by Google rather than a password. Always User-equivalent, always in exactly one scope, stored in its own table with a direct ScopeId.
Scope Permission
A permission defined inside a scope. When IncludeAsJwtClaim is set, its name is added as a claim on JWTs issued for that scope.
Two-Factor Auth
An opt-in second step for a person — authenticator app, email, or both — backed by ten single-use recovery codes. Not available to Google Users.
Logical deletion
Setting IsDeleted = true; the row stays.
Hard deletion
Removing the row, with defined cascades.
How a person relates to a scope
This is the single rule that most often surprises newcomers: Person has no ScopeId.
flowchart TD
P[Person] --> R{Role}
R -->|SystemAdmin| S1["Belongs to no scope<br/>Governs the whole system<br/>May own scopes via SCOPE_OWNER"]
R -->|ScopeAdmin| S2["Owns 1..n scopes<br/>via SCOPE_OWNER"]
R -->|User| S3["Belongs to exactly 1 scope<br/>via SCOPE_USER"]
An administrator with a SCOPE_USER row, or a User with two, is not representable — the join tables carry
the constraints (SCOPE_USER.PersonId is unique; SCOPE_OWNER has a composite key).
Two identifiers per entity
Every top-level entity carries both:
Id — an auto-incrementing bigint. The physical primary key and the target of every foreign
key. Never leaves the database.
PublicId — a GUID generated at creation. The identifier used in API paths, response bodies,
and token claims.
The split keeps record counts and creation order — which a sequential integer would leak — out of
anything a caller can see, while the database keeps compact integer keys for joins and indexes. This
is NFR-15; see the System Requirements Document
for the one documented and accepted exception (Person.RoleId).
Two kinds of caller, two kinds of token
Password identities
Google identities
Entity
Person
GoogleUser
Credentials
Password hash + per-person salt (Argon2id)
Delegated to Google; no hash stored
Sign-in
POST /api/auth/login
POST /api/auth/google
Roles reachable
User, ScopeAdmin, SystemAdmin
User only, always
Second factor
Optional 2FA (app / email / recovery codes)
Google’s own account security
Both produce the same kind of stateless, signed JWT. Authentication reads no database per
request — the identity is rebuilt from the token’s claims — which is why the sign-out endpoint
revokes nothing and why a token can outlive the account it names until it expires.
What the API is composed of
graph LR
subgraph Clients
C1[Client System A]
C2[Client System B]
C3[Client System N]
end
subgraph Heimdall["Heimdall API"]
AUTH[Authentication]
TFA[Two-Factor Auth]
GS[Google Sign-In]
PM[Person Management]
AM[Application Management]
SM[Scope Management]
SPM[Scope Permissions]
PR[Password Recovery]
EV[Email Verification]
AL[Audit Logging]
end
subgraph Infrastructure
DB[(PostgreSQL)]
EMAIL[Mailgun]
GOOGLE[Google Identity]
end
C1 & C2 & C3 --> AUTH & PM & AM & SM & GS
AUTH --> TFA
AUTH & PM & AM & SM & SPM & GS & TFA & AL --> DB
PR & EV & TFA --> EMAIL
GS --> GOOGLE
Every one of those write paths also produces an audit trail entry (NFR-09) — see
Audit logging
.
Where the rules are written down
Nothing on this site is the specification. The specification is the set of numbered requirements and
use cases under Requirements
:
FR-… — functional requirements, grouped by capability (FR-AU-… authentication, FR-GO-…
Google Sign-In, FR-2F-… two-factor, and so on).
NFR-… — non-functional requirements (security, data integrity, validation, logging).
UC-… — use cases, each with a main flow and numbered alternative flows (AF-11a, AF-11b, …).
The source code cites these identifiers in its comments, so a line of code can be traced back to the
flow that demanded it.
2 - Getting started
Prerequisites, configuration, migrations, and running the API locally.
Prerequisites
.NET 10 SDK
PostgreSQL — the API’s relational database.
Python 3 — only to run the migration menu script.
The pinned EF Core CLI tool. Restore it once after cloning:
dotnet tool restore
Building from source, running the test suites and the other contributor tooling are covered in
Contributing
.
Clone
The documentation site’s theme is a git submodule, so clone with submodules (or initialise them
afterwards):
Environments/.env is a tracked template listing every variable the API reads. Real values live
in per-environment files that are gitignored. Create your local one before the first run:
ASPNETCORE_ENVIRONMENT selects two files, both resolved next to the built assembly:
Environment
Variables
App settings
Local (what the launch profiles set)
Environments/.env.local
Settings/appsettings.Local.json
Development
Environments/.env.development
Settings/appsettings.Development.json
Staging
Environments/.env.staging
Settings/appsettings.Staging.json
Production
Environments/.env.production
Settings/appsettings.Production.json
Both sets are gitignored — the env files hold credentials, and the app settings are per-machine.
They are for running from source: the image carries no Environments/ file, and a container takes
its variables from the env file Compose is given instead — see Environments and deployment
,
where local and development run as Development, homologation as Staging and production as
Production.
Settings/appsettings.json is tracked and loaded first, so a missing environment file costs defaults
rather than the whole configuration.
The fallback hides a missing file
When the file for the current environment does not exist, the loader falls back to the default
environment, Local, and says so:
Could not find variables for Development environment. Loading default environment Local instead...
That line is easy to miss in a start-up log, and the run then succeeds against the wrong
configuration — which is why the launch profiles name Local outright rather than leaving
Development to fall back into it.
Required — the API will not start without these
Variable
Value
HEIMDALL_DATA_CONNECTIONSTRING
A PostgreSQL connection string
HEIMDALL_DATA_DATABASETYPE
PostgreSql
HEIMDALL_AUTH_TOKEN_SECRET
The JWT signing secret: at least 32 bytes, ASCII only. Startup throws if it is unset, shorter or holds another character, naming the variable and never the value
HEIMDALL_APPLICATION_TOKEN_AUDIENCE
The aud of application tokens (UC-56). Startup throws if it is unset, or equal to HEIMDALL_AUTH_TOKEN_AUDIENCE or to the .2fa-challenge or .subject-rights audience derived from it: a distinct audience is what stops a consumer taking an application token for a person’s. docker-compose.yml defaults it to heimdall-applications and the docker/*.env.example templates preset that value; the Web API’s Environments/.env holds only a placeholder
HEIMDALL_MASTER_USER_NAME / _EMAIL / _PASSWORD
Credentials for the first system administrator. Consulted only when the database holds no system admin yet, but the seeder refuses to start without them in that case
300 (5 minutes) — the per-person cooldown on account email: administrator-sent links, anonymous password recovery, and a person’s own resends, setup codes and email-change notices (which are also capped at five per day-long window); 0 turns the cooldown off
HEIMDALL_LOG_DIRECTORY
logs
HEIMDALL_METRICS_PORT
9464 — the Prometheus scrape port; 0 switches metrics off (see Metrics
)
HEIMDALL_CORS_ALLOWED_ORIGINS
Empty → every cross-origin request is refused. Set it to your front end’s origin (https://app.example.com, comma separated for several) or a browser client cannot call the API
HEIMDALL_TRUSTED_PROXIES
Empty → X-Forwarded-For is ignored. Behind a reverse proxy, set it to the proxy’s address or network so the rate limiter and the request log see the real caller
Optional integrations
Email delivery (Mailgun) and Google Sign-In are each configured by their own variables, and each has
a documented behaviour when left unconfigured. See Operations
for the
full table and the reasoning — including why an unconfigured Production deployment refuses to
start rather than logging tokens in plaintext.
Apply migrations
The schema is managed with EF Core migrations, applied explicitly. The API never migrates on
startup, and refuses to start when migrations are pending — so this step comes before the first
run:
python scripts/migrations.py
The script asks which environment file to load (for the connection string), then offers list,
create, and apply; apply is the one a deployment needs. See Operations
for more, and Contributing
for authoring a new migration.
Run
dotnet run --project src/Presentation/ArturRios.Heimdall.WebApi
On the first run against an empty database, the seeder creates the roles and the master system
administrator from HEIMDALL_MASTER_USER_NAME / _EMAIL / _PASSWORD. On later runs, an existing
system admin means those variables are read but unused.
The http launch profile serves on http://localhost:5177; the https profile adds
https://localhost:7235.
Swagger UI is at http://localhost:5177/swagger
, with JWT wired in: press Authorize, paste
the token from a login, and call any endpoint from the browser. It shows each controller’s own
summary and marks which endpoints need a token, because SwaggerConfiguration is applied over it.
That same class produces the document behind the API explorer
, so the two are the
same document — literally: python scripts/openapi.py writes byte-for-byte what a running instance
serves at /swagger/v1/swagger.json. Read the API without running it through the explorer; call it
through Swagger UI or api-client/.
Why Swagger sits between the two middlewares
Startup.Build() registers ExceptionMiddleware, then UseSwagger(), then
AuthenticationMiddleware — not both middlewares before Swagger. Authentication does not exempt the
Swagger routes, so registering it first answered 401 to every request for /swagger, index.html
included, which no browser can satisfy. Keeping the exception middleware ahead means a failure inside
Swagger still answers the usual JSON envelope.
Swagger is registered only in the environments the library allows, and in Production it registers
nothing at all — so this does not publish the document from a production instance.
First calls
Liveness — anonymous, no database:
curl http://localhost:5177/healthcheck
Log in as the master system administrator:
curl -X POST http://localhost:5177/api/auth/login -H "Content-Type: application/json" -d '{"email":"<master-email>","password":"<master-password>"}'
The response carries token, expiresAt, and emailVerified — the last telling you whether to
prompt the person to confirm their address. Send it as Authorization: Bearer <token> on every
other endpoint — see the API reference
for who may call what.
Two-factor
If the person has active 2FA, login answers requiresTwoFactor: true with a short-lived
challengeToken instead of a full token. Finish the login at POST /api/auth/2fa/verify — see the
two-factor flow
.
Calling the API
api-client/ holds ready-to-send requests for every operation in the OpenAPI document, in two formats — pick whichever your
editor already opens:
Directory
Format
Opens in
api-client/http/
JetBrains HTTP Client (.http)
Rider, IntelliJ IDEA, VS Code with the REST Client extension
Neither asks you to copy a GUID by hand: a response handler stores the token that login returns, and
each request that creates something stores the new id for the requests that need it. Point the
environment file at your master user’s credentials first — http/http-client.env.json or
bruno/environments/Local.bru.
On an empty database four requests have to run in order before anything else will find its subject:
Login, then Create an administrator, then Create a scope, then Create a User in a
scope. See api-client/README.md
for the whole story, including how to keep real credentials out of git.
To run the Bruno collection without installing anything:
npx @usebruno/cli run Auth --env Local
Next
Contributing
— building, testing, and the branching and release process.
Architecture
— what happens between the controller and the database.
The four layers, the CQRS mediator, the auditing decorator, and the HTTP request pipeline.
Heimdall is a layered (DDD-flavoured) solution with CQRS in the application layer. Reads and
writes travel different objects, through different mediators, into different handlers.
The layers
graph TB
subgraph P["Presentation — ArturRios.Heimdall.WebApi"]
CTRL[Controllers]
SEC[Security: token issuers, mappers, filters]
MAIL[Email senders]
end
subgraph A["Application"]
subgraph CMD["ArturRios.Heimdall.Command"]
CH[Command handlers]
CV[FluentValidation validators]
CS[Services: token, 2FA, email verification]
AUD[Auditing decorator]
end
subgraph QRY["ArturRios.Heimdall.Query"]
QH[Query handlers]
HC[Health checks]
end
subgraph SHR["ArturRios.Heimdall.Shared"]
MSG[Messages + status-code maps]
OWN[IScopeOwnershipChecker, IActorAccessor]
end
end
subgraph D["Domain — ArturRios.Heimdall.Domain"]
ENT[Entities + Roles enum]
end
subgraph I["Infrastructure — ArturRios.Heimdall.Data"]
CTX[AppDbContext]
MAPS[Entity maps]
MIG[Migrations]
SEED[DatabaseSeeder]
end
CTRL --> CMD & QRY
CTRL --> MSG
CMD & QRY --> SHR
CMD & QRY --> ENT
CMD & QRY -->|repository interfaces| I
I --> ENT
SEC -.implements.-> CS
MAIL -.implements.-> CS
The arrows only ever point inwards or sideways: Domain depends on nothing, and Application
depends on Infrastructure only through repository interfaces (IAsyncRepository<T, long>,
IAsyncReadOnlyRepository<T, long>) that Infrastructure implements. Presentation supplies the concrete
adapters for the abstractions the Application layer declares — IAuthTokenIssuer,
IEmailVerificationSender, IGoogleIdTokenVerifier — which is why swapping Mailgun for logging, or
the real Google verifier for the refusing one, is a start-up registration decision and nothing else.
Project
Responsibility
ArturRios.Heimdall.Domain
Entities and the Roles enum. No behaviour beyond data shape.
No business rule lives in a controller. The handler returns a DataOutput<T> carrying data,
messages, and errors; Util.WebApi’s ToActionResult turns that into an HTTP status using the per-area
message map — a dictionary from message text to status code — so “which error is a 404 and which
is a 409” is a single table per area rather than scattered return NotFound() calls.
Auditing by decoration
Every write handler is registered through AddAuditedCommandHandler, which registers the concrete
handler and then registers the interface as an AuditingCommandHandler wrapping it (NFR-09):
The decorator writes one AuditLog row per successful command, resolving the target from the
output’s Id/PublicId by reflection and the actor from IActorAccessor (the current request’s
identity). A failure to write the audit entry is logged as a warning and never fails the request the
user already succeeded at.
Depend on the interface
The concrete THandler registration exists only so the decorator factory can resolve it. Resolving
THandler directly silently bypasses auditing — always depend on
ICommandHandlerAsync<TCommand, TOutput>.
ToActionResult resolves the HTTP status from the envelope’s first error (or, on success, its
first message) against the map the controller passes it, falling back to 200 on success and 400 on
failure. Each use case owns a *MessageMap naming its own outcomes — InvalidCredentials is a 401,
EmailAlreadyExists a 409, and so on.
Persistence failures are not the use case’s vocabulary, and until recently they had none of their
own: a repository handed back the provider’s text, which named the violated index, the conflicting
value, or the column and its type. Nothing stable could be keyed off that, so every one of them fell
through to the 400 default — a duplicate that lost a race and a database that had gone away were
both reported as bad requests, and the message leaked schema detail to the caller.
ArturRios.Data.Relational.Core (since 4.0.0) classifies each failure into one of five fixed, caller-safe
messages instead, reading the provider text only to decide which. DataAccessMessageMap folds four
of them into every use case’s map:
Classified failure
Status
Unique violation
409 Conflict
Other integrity rule (FK, NOT NULL, CHECK)
409 Conflict
Concurrency conflict
409 Conflict
Transient — the store is unavailable
503 Service Unavailable
The fifth, the unclassified A data-access error occurred., is deliberately left unmapped and keeps
the 400 default. It covers both causes a caller can fix — a value longer than its column, where a
validator failed to bound it — and causes only an operator can, so neither 400 nor 500 fits all of
it, and 400 is the safer of the two.
A use case’s own entries always win over these, so a map assembled this way can never override the
vocabulary the use case defines.
The HTTP request pipeline
graph LR
REQ([Request]) --> FWD["Forwarded headers<br/>trusted proxies only"]
FWD --> HTTPS[HTTPS redirect]
HTTPS --> TRACE[TraceActivityMiddleware]
TRACE --> EX[ExceptionMiddleware]
EX --> CORS["CORS<br/>configured origins only"]
CORS --> AUTHN[AuthenticationMiddleware]
AUTHN --> RL[Rate limiter<br/>credential-checking endpoints only]
RL --> MFA[MfaPendingGuardFilter]
MFA --> SRO[SubjectRightsOnlyGuardFilter]
SRO --> LIVE[ActorLivenessFilter]
LIVE --> ROLE["RoleRequirement /<br/>AllowAnonymous"]
ROLE --> CTRL[Controller action]
CTRL --> MED[Mediator]
MED --> H[Handler]
H --> DB[(PostgreSQL)]
H --> RES[DataOutput]
RES --> RR["ToActionResult<br/>+ message map"]
RR --> RESP([HTTP response])
Everything from TraceActivityMiddleware to AuthenticationMiddleware is Util.WebApi’s standard
pipeline, which Startup derives from WebApiStartup to get. What it has no place for is added
around it: the metrics scrape branch (on its own port, ahead of everything — see
Operations
), forwarded headers and HTTPS redirection go in front through a startup
filter, and the rate limiter goes after it. TraceActivityMiddleware logs every request with the
caller’s IP address — the real caller’s when HEIMDALL_TRUSTED_PROXIES names the proxy in front of
the API, the proxy’s otherwise.
Six things about this pipeline are worth knowing before reading any handler:
Authentication reads no database.AddTokenAuthentication<IdentityUserMapper> is configured with
JwtValidationMode.ClaimsOnly and TokenSource.Header: the IdentityUser is rebuilt from the
token’s claims alone, and nothing is looked up while the token is validated. The library’s validator
checks the signature (HS256 only) and lifetime; PersonTokenValidator takes its place, wrapping it,
and also requires HEIMDALL_AUTH_TOKEN_ISSUER and exactly one of the three person audiences
(NFR-30), so a correctly signed token Heimdall did not issue for itself is refused like a forged
one.
But the identity it names is checked.ActorLivenessFilter runs globally, right after the
challenge-token guard, and refuses a token whose person or Google User is absent or logically
deleted (FR-AU-05, FR-GO-12). Without it, ClaimsOnly meant a token kept working for its
whole lifetime after the account behind it was deleted — and the handlers compensated unevenly:
ScopeOwnershipChecker excluded a deleted Scope Admin, while every System Admin bypass and every
“acting on yourself” branch trusted the role claim alone, leaving the protection in place for the
lesser role and absent for the greater one. It costs one indexed read per authenticated request, and
two for a Google User, since the token does not say which table its subject lives in. A restricted
identity (NFR-24) is refused the same way — except on the four actions marked
[DataSubjectRight] (export, erasure request, restriction, lifting one’s own restriction), where a
restricted or suspended subject still reaches the handler and the use case’s own flows answer.
Single-use state is spent in the database, not in memory. The repositories write whole rows —
read, change, save — so two requests that read the same recovery code, token, challenge or counter
before either saved would both act on it. IAtomicWrites (Domain, implemented in Data on
ExecuteUpdate) makes each of those a single conditional UPDATE … WHERE whose affected-row count
says who won, and charges every guess budget before the comparison it guards.
One class owns both directions of the claims.IdentityUserMapper writes the claims when a token
is issued and reads them when one is validated, so the two cannot drift. Every claim value is a
PublicId; an internal bigint never reaches a token (NFR-15).
A challenge token is inert everywhere but one endpoint.MfaPendingGuardFilter is registered
globally and rejects any request whose identity carries MfaPending with a 401 (FR-2F-10,
NFR-17). The second-factor endpoint itself is unaffected because it is [AllowAnonymous] and
takes the challenge token as a body field, never as a bearer credential. The token also carries an
audience of its own, HEIMDALL_AUTH_TOKEN_AUDIENCE + .2fa-challenge, so a consumer that pins the
person audience refuses it, and IdentityUserMapper reads mfaPending only under that audience.
A subject-rights-only token is confined to four actions. A restricted identity that signs in
(NFR-24, UC-11 AF-11i, UC-25 AF-25e) is issued a token carrying the subjectRightsOnly claim
and no scope, under its own audience, HEIMDALL_AUTH_TOKEN_AUDIENCE + .subject-rights, which
consumers pinning the person audience refuse. SubjectRightsOnlyGuardFilter, registered globally
after the challenge-token guard, answers it 403 on every action not marked [DataSubjectRight] —
on its claims alone, before the liveness check’s read. The liveness check would refuse it there too
while the restriction stands; the guard keeps it confined after the restriction is lifted, so a lift
never turns it into a full token.
Authorization
Role checks are declarative, via [RoleRequirement] on the action:
Ownership checks — “is this Scope Admin an owner of this scope?” — cannot be expressed in an
attribute, so they live behind IScopeOwnershipChecker, resolved inside the handler. A handful of
endpoints carry no role attribute on purpose: GET /api/scopes/{scopeId}/google-users/{id}, for
example, must admit the Google User themselves, whose token is User-role, so any attribute strong
enough to exclude other users would exclude the actor the use case grants.
Input validation is FluentValidation, one validator per command or query, registered alongside its
handler (NFR-10). Where a command has no validator it is a documented decision, not an omission —
the start-up registrations carry the reason inline. The two recurring reasons:
Nothing to validate. Every field is a typed route value already constrained by the route
({id:guid}), or the request carries no caller-supplied input at all because the subject comes
from the bearer token.
The rule needs the database. Which second factor is required depends on the stored
AppEnabled/EmailEnabled of the person’s configuration — a read only the handler can make.
Every paginated list query does have a validator, so page size and filters are checked before a
query reaches the database.
Persistence
AppDbContext applies one entity map per entity (PersonDbMap, ScopeDbMap, …) rather than
annotating the domain classes, keeping storage concerns out of the domain. Handlers never touch the
context: they depend on IAsyncRepository<T, long> for writes and IAsyncReadOnlyRepository<T, long> for reads,
the latter exposing Query() so a handler can compose an IQueryable and let the database do the
filtering.
EF diagnostics — sensitive data logging and detailed errors — are enabled outside Production only;
they would otherwise print parameter values including password hashes, salts, and email addresses.
Where to go next
Domain model
— entities, relationships, and deletion cascades.
Flows
— the same pipeline traced end-to-end for specific use cases.
Operations
— migrations, health checks, logging, and rate limiting.
4 - Domain model
The entities, their relationships, and the deletion cascade rules — as class diagrams.
The domain layer is intentionally anemic: entities are data holders with properties and navigation
collections. Decisions live in the command and query handlers, so the diagrams below are a map of
shape and relationship, not of behaviour.
The core model
The entities every request touches: a scope, the persons related to it through the two join tables,
the applications and Google Users it contains, and the permissions it defines.
classDiagram
direction LR
class Scope {
+Guid PublicId
+string Name
+string? Description
+bool IsDeleted
+bool GoogleSignInEnabled
}
class Person {
+Guid PublicId
+string Name
+string Email
+byte[] PasswordHash
+byte[] Salt
+bool IsDeleted
+bool EmailVerified
+long RoleId
}
class Role {
+Guid PublicId
+string Name
+string? Description
}
class ScopeOwner {
+long ScopeId
+long PersonId
}
class ScopeUser {
+long ScopeId
+long PersonId
}
class Application {
+Guid PublicId
+string Name
+bool IsDeleted
+long ScopeId
+long OwnerId
}
class GoogleUser {
+Guid PublicId
+string GoogleId
+string Name
+string Email
+bool EmailVerified
+string? ProfilePictureUrl
+bool IsDeleted
+long ScopeId
}
class ScopePermission {
+Guid PublicId
+string Name
+string? Description
+bool IncludeAsJwtClaim
+bool IsDeleted
+long ScopeId
}
Role "1" o-- "0..*" Person : classifies
Scope "1" o-- "0..*" ScopeOwner : has
Person "1" o-- "0..*" ScopeOwner : owns via
Scope "1" o-- "0..*" ScopeUser : has
Person "1" o-- "0..1" ScopeUser : belongs via
Scope "1" o-- "0..*" Application : contains
Person "1" o-- "0..*" Application : owns
Scope "1" o-- "0..*" GoogleUser : contains
Scope "1" o-- "0..*" ScopePermission : defines
Credentials, tokens and the audit trail
The entities hanging off a person: their two-factor configuration with its codes, the two kinds of
single-use token, and the append-only audit log.
classDiagram
direction LR
class Person {
+Guid PublicId
+string Email
+bool EmailVerified
+int FailedLoginAttempts
+DateTime? LockedOutUntil
+int FailedSecondFactorAttempts
+DateTime? SecondFactorLockedOutUntil
+int SecondFactorLockouts
+int FailedReauthenticationAttempts
+DateTime? ReauthenticationWindowStartedAt
+DateTime? ReauthenticationPausedUntil
+int AccountEmailsSent
+DateTime? AccountEmailWindowEndsAt
+DateTime? AccountEmailLastSentAt
+long? ScopeId
}
class TwoFactorAuth {
+long PersonId
+bool AppEnabled
+bool EmailEnabled
+byte[]? TotpSecretEncrypted
+bool IsActive
+long? LastTotpTimeStepUsed
+int EmailCodeReissueCount
+Guid? ChallengeId
+int ChallengeAttempts
}
class TwoFactorEmailCode {
+long TwoFactorAuthId
+byte[] CodeHash
+byte[] Salt
+DateTime ExpiresAt
+bool Used
+int FailedAttempts
}
class TwoFactorRecoveryCode {
+long TwoFactorAuthId
+byte[] CodeHash
+bool Used
+DateTime? UsedAt
}
class EmailVerificationToken {
+long PersonId
+string Token
+DateTime ExpiresAt
+bool Used
}
class PasswordResetToken {
+long PersonId
+string Token
+DateTime ExpiresAt
+bool Used
}
class AuditLog {
+Guid PublicId
+Guid? ActorPersonId
+int? ActorRole
+string Action
+Guid? TargetId
+DateTime CreatedAt
}
Person "1" o-- "0..1" TwoFactorAuth : configures
TwoFactorAuth "1" o-- "0..*" TwoFactorEmailCode : issues
TwoFactorAuth "1" o-- "0..*" TwoFactorRecoveryCode : issues
Person "1" o-- "0..*" EmailVerificationToken : holds
Person "1" o-- "0..*" PasswordResetToken : holds
AuditLog is deliberately unconnected. Its ActorPersonId is a bare PublicId, not a foreign
key, so an entry survives the hard deletion of the person who made the write.
Common shape
Every entity above derives from Entity<long>, which contributes the internal key, and each one that is
addressable from outside also carries timestamps:
classDiagram
direction LR
class Entity~long~ {
<<abstract>>
+long Id
}
class AddressableEntity {
<<pattern>>
+Guid PublicId
+DateTime CreatedAt
+DateTime UpdatedAt
}
Entity~long~ <|-- AddressableEntity
Scope, Role, Person, Application, GoogleUser, ScopePermission, AuditLog and
PendingRegistration follow the addressable pattern. The join rows, token rows and two-factor
rows carry only the internal Id — see the table below for why.
The right-hand column is never addressed by ID from outside:
Join rows (ScopeOwner, ScopeUser) are not independently addressable resources — they are
reached through the scope and person they connect.
Token rows are reached by their random Token string, which is already the caller-facing
reference.
Two-factor rows are reached implicitly through the authenticated person’s own identity — a
person’s configuration is never named by ID in a path.
PendingRegistration carries a PublicId for administrators only: a scope’s owners list and discard
registrations by it (UC-49, UC-50). The registrant never sees it — their reference is the mailed token.
Secrets at rest
Value
How it is stored
Ever returned?
Password
Argon2id hash + per-person random salt
Never
TOTP secret
Encrypted via ASP.NET Core Data Protection (ITotpSecretProtector)
Once, at provisioning, so the authenticator app can be enrolled
Recovery codes
SHA-256 of the canonical code, no per-code salt (80 random bits, FR-2F-20)
Once, in the response that generates them
2FA email code
Hash + per-code salt (only 10⁶ possible values, so it is treated as a user-grade secret)
Never — it is mailed
Email verification / password reset token
SHA-256 digest only (SingleUseTokenHash)
Once, by email
That split is NFR-16. The email code gets a salt and the recovery code does not for a concrete
reason: a six-digit code has a million possible values and would fall to a rainbow table without one,
while a recovery code is 80 random bits, where a plain one-way hash already makes the plaintext
unrecoverable: finding one from its digest takes about 2^79 SHA-256 evaluations, and even a search
over a million stored codes at once leaves about 2^60 (Threat Model TH-16).
Deletion cascades
flowchart TD
A[Delete request] --> B{Logical or hard?}
B -->|Logical| C[Set IsDeleted = true]
C --> C1{Entity}
C1 -->|Scope| C2["Cascades to its Users,<br/>Google Users, applications<br/>— not to its owners,<br/>not to its permissions"]
C1 -->|Person / Google User /<br/>Application / Permission| C3[Only its own flag]
C2 & C3 --> D[Row stays, excluded from<br/>default queries]
B -->|Hard| F{Entity}
F -->|Scope| G["Removes SCOPE_OWNER/SCOPE_USER rows,<br/>its Users, Google Users,<br/>applications and permissions"]
F -->|ScopeAdmin| H["Removes SCOPE_OWNER rows,<br/>owned applications,<br/>2FA config + recovery codes"]
F -->|User| I["Removes SCOPE_USER row,<br/>2FA config + recovery codes"]
F -->|Google User /<br/>Application / Permission| J[Leaf — nothing cascades]
H --> K{Would any owned scope<br/>be left ownerless?}
K -->|Yes| L["Rejected — NFR-12"]
K -->|No| M[Row removed]
G & I & J --> M
The rules that most often catch people out:
Logically deleting a scope does not touch its permissions. Applications cascade; permissions do
not. They become unreachable anyway — the listing endpoint gates on the scope’s IsDeleted, and the
JWT-claim fold at login reads only permissions of non-deleted scopes — so a restored scope recovers
its permission set unchanged. A hard delete does purge them, through the foreign key’s
ON DELETE CASCADE.
Logically deleting a scope does not touch its owners. A Scope Admin may own other, active scopes.
A scope must always retain at least one owner (NFR-12) — removing the last owner, or hard-deleting
the last owning person, is rejected. NFR-12 does not guard the other direction: hard-deleting a
scope can leave a Scope Admin owning nothing. That record is left in place on purpose (they may be
about to be given another scope), and it grants no access in the meantime, because a ScopeAdmin
with no live owned scope cannot authenticate (FR-AU-07).
Logical deletion never touches two-factor state. A restored person keeps whatever configuration
they had. Hard deletion removes it, via two_factor_recovery_code → two_factor_auth → person.
The authoritative version of all of this — including the reasoning per rule — is §8 of the
System Requirements Document
.
Persistence mapping
Each entity has a map class under ArturRios.Heimdall.Data/EntityMaps/ (PersonDbMap,
ScopeOwnerDbMap, …) applied by AppDbContext. Constraints that the C# class cannot express live
there: the composite key of ScopeOwner, the uniqueness of ScopeUser.PersonId (which is what makes
“a User belongs to exactly one scope” true rather than merely intended), the per-scope uniqueness of
GoogleUser.GoogleId and GoogleUser.Email, and the cascade behaviour above.
Email uniqueness
FR-PE-09 and FR-GO-07 make an address unique in a namespace chosen by role, and the three halves
differ in how far the database can enforce them.
Rule
Enforced by
An administrator’s address is unique system-wide among live ScopeAdmins and SystemAdmins
ux_person_admin_email, a partial unique index on LOWER(email) WHERE role_id IN (1, 2) AND is_deleted = false
A Google User’s address is unique within their scope
ix_google_user_scope_id_email, unique on (scope_id, LOWER(email))
A User’s address is unique within their scope
ux_person_scope_user_email, unique on (scope_id, LOWER(email)) where role_id = 3 AND is_deleted = false
…jointly with that scope’s Google Users
The application layer — CreateUserCommandHandler, UpdatePersonCommandHandler, GoogleSignInCommandHandler
All three indexes are over LOWER(email) because the handlers compare addresses case-insensitively;
an index over the raw column would enforce a different rule than the one the code applies, and
accept pairs a handler had already refused.
The scope a User belongs to, twice
PERSON.scope_id is a copy of the owning SCOPE_USER row’s scope, and exists for one reason: the
third rule above could not otherwise be enforced. The scope lives in SCOPE_USER and the address in
PERSON, a PostgreSQL unique index covers one table, and a trigger closes nothing — under
READ COMMITTED two concurrent inserts see the same pre-write snapshot and both find the address
free. Only a unique index serialises them, and an index needs its columns on one table.
SCOPE_USER remains the relationship (§4.6) and every read goes through it. The copy is written by
the three handlers that write the membership — UC-06 path a sets both, UC-23 and UC-08’s role change
clear both — and the index’s condition matches CreateUserCommandHandler’s check term for term, so
the rule itself did not change. Only who enforces it under concurrency did.
A caller who loses that race is answered by AF-06a, the same conflict they would have got had the
address already been taken when they asked, rather than a persistence failure: losing a race is not
something the API makes visible.
The one half still in the application
The joint rule with Google Users spans PERSON and GOOGLE_USER, so no single index can hold it —
both identity kinds would have to write into one. It remains a check-then-insert, and a Google
sign-up racing a User creation on the same address in the same scope can still produce both. The
window is far narrower than the one just closed, since it needs two different endpoints to interleave
rather than two calls to the same one.
5 - API reference
Every endpoint, the role that may call it, and the use case it implements.
Schema-level documentation — every parameter, every request and response body — is published as an
OpenAPI document and rendered by the API explorer
, which is the same document a
running instance serves through its own Swagger UI at /swagger. To call the endpoints, use that,
or the .http files or Bruno collection
in api-client/.
This page is the map — what exists, who may call it, and which use case it implements.
Reading the “Who” column
Notation
Meaning
Anonymous
[AllowAnonymous] — no bearer token required.
System Admin
[RoleRequirement(SystemAdmin)].
+ Scope Admin
The attribute admits Scope Admins too; the handler then confirms they own the addressed scope.
Any authenticated
No role attribute on purpose — the actor set the use case grants includes a User, so the attribute would have to admit them and the handler settles it.
Two rules apply everywhere and are not repeated per row:
The identifier in a path is always a PublicId (GUID), never an internal id.
A challenge token (issued by a 2FA-gated login) is rejected by every endpoint below except
POST /api/auth/2fa/verify, with a 401.
Authentication — /api/auth
Method & path
Who
Use case
POST /login
Anonymous
UC-11 — password login; answers a token, or a challenge token if 2FA is active
POST /password-recovery
Anonymous
UC-12 — request a reset link
POST /password-reset
Anonymous
UC-13 — set a new password from the token
POST /verify-email
Anonymous
UC-14 — confirm an address from the mailed token
POST /resend-verification
Authenticated (self)
UC-15 — retire outstanding links, mail a fresh one; 429 when the account-email budget refuses it (cooldown or daily limit); rate-limited
POST /google
Anonymous
UC-25 — Google sign-up/sign-in against a scope
POST /google/sign-out
Authenticated (Google User)
UC-26 — end a Google session
POST /2fa/enable
Authenticated (self)
UC-36 — begin 2FA setup; needs currentPassword (401 otherwise); with Email, 429 when the account-email budget refuses it
POST /2fa/confirm
Authenticated (self)
UC-37 — confirm setup, receive recovery codes
POST /2fa/verify
Anonymous
UC-38 — redeem the challenge token with a second factor
POST /2fa/disable
Authenticated (self)
UC-39 — turn 2FA off
POST /2fa/recovery-codes/regenerate
Authenticated (self)
UC-40 — issue a fresh set of ten
GET /2fa
Any authenticated
FR-2F-15 — the caller’s own two-factor status; 403 for a Google User
POST /data-export
Any authenticated (self)
UC-41 — a copy of everything held about you, with recipients and retention
POST /erasure-request
Any authenticated (self)
UC-42 — ask for your own erasure; requires a password or a fresh Google ID token, and lifts your own restriction if you have one (restrictionLifted)
POST /processing-restriction
Any authenticated (self)
UC-44 — suspend processing without deleting, on an Art. 18(1) ground
POST /processing-restriction/lift
Self, or System Admin for another
UC-45 — lift it; an admin must inform the subject first
GET /erasure-requests
System Admin
UC-43 — the outstanding erasure requests, soonest deadline first
Every anonymous endpoint here is rate-limited to 10 requests per minute per IP. Each of them
checks a credential, and none is protected by a token — see
Operations
for why that matters and what the limit does not replace.
The authenticated endpoints that check a password or a second factor — 2fa/enable, 2fa/confirm,
2fa/disable, 2fa/recovery-codes/regenerate, erasure-request and PUT /persons/{id} — share the
same limit.
The 2FA endpoints never name a person: the subject is always the caller, read from the bearer token,
so a caller can only ever act on their own configuration.
A token naming a restricted identity (UC-44), or one logically deleted and not yet anonymised, is
refused everywhere with a 401 — except data-export, erasure-request, processing-restriction and
processing-restriction/lift, through which the subject exercises their own rights (NFR-24). Those
answer with the use case’s own flows instead: a restricted subject can export, request erasure —
which lifts the restriction, so the erasure is not held behind it — lift their own restriction, and is
told a second restriction is a conflict (AF-44a).
A restricted identity can still sign in — login, 2fa/verify and google answer 200 with
processingRestricted: true and a subject-rights-only token (UC-11 AF-11i, UC-25 AF-25e). That
token claims no scope and only those four endpoints accept it; every other endpoint answers 403,
even after the restriction is lifted, when the subject signs in again for a full token.
Scopes — /api/scopes
Method & path
Who
Use case
POST /
System Admin
UC-01 — create a scope with one or more initial owners
GET /
System Admin; Scope Admin (the scopes they own)
UC-02 — list scopes (paginated, filterable); a Scope Admin’s page and total count their own scopes only
GET /{id}
Any authenticated
UC-02 — read one scope
PUT /{id}
System Admin
UC-03 — update name and description
DELETE /{id}
System Admin
UC-04 — logical delete (cascades to Users, Google Users, applications)
DELETE /{id}/hard
System Admin
UC-05 — hard delete (also purges permissions and join rows)
PUT /{id}/google-signin
System Admin + Scope Admin
UC-24 — turn Google Sign-In on or off
GET /{id}/pending-registrations
System Admin + Scope Admin
UC-49 — the self-registrations awaiting confirmation, newest first: name, address, expiry, guesses left; never the hash, salt or token
UC-50 — discard one; its link then answers “Invalid token” and the registrant may sign up again
Persons — /api
Method & path
Who
Use case
POST /persons
System Admin
UC-06 path b — create a ScopeAdmin or SystemAdmin with no scope
POST /scopes/{scopeId}/persons
System Admin + Scope Admin
UC-06 path a — create a User in a scope
POST /scopes/{scopeId}/owners
System Admin + Scope Admin
UC-06 path c — create a new ScopeAdmin directly as a co-owner
POST /scopes/{scopeId}/owners/{personId}
System Admin + Scope Admin
UC-21 — add an existing ScopeAdmin or SystemAdmin as an owner
POST /scopes/{scopeId}/users/{personId}/promote
System Admin + Scope Admin
UC-23 — promote a scope’s User to owner
GET /persons/{id}
Any authenticated
UC-07 — read one person (a person may read themselves)
GET /scopes/{scopeId}/persons
System Admin + Scope Admin
UC-07 — list a scope’s Users
GET /scopes/{scopeId}/owners
System Admin + Scope Admin
UC-07 — list a scope’s owners
GET /persons/scope-admins
System Admin + Scope Admin
UC-07 read d — list who may own a scope (Scope Admins, plus System Admins for a System Admin caller) for an owner picker; optionally excludes a scope’s current owners
GET /persons/system-admins
System Admin
UC-07 read e — list the System Admins: who else holds full access
PUT /persons/{id}
Any authenticated
UC-08 — update name and email; only a System Admin may change the role. Changing your own email needs currentPassword, and twoFactorCode when your 2FA is active (401 otherwise); any email change revokes the person’s tokens and notifies the previous address
DELETE /persons/{id}
System Admin + Scope Admin
UC-09 — logical delete
DELETE /persons/{id}/hard
System Admin
UC-10 — hard delete, cascading to owned applications, tokens, 2FA and join rows
POST /persons/{id}/verification-email
System Admin + Scope Admin
UC-51 — mail a fresh verification link to the person’s own address; a Scope Admin only for Users of scopes they own; 429 within the cooldown
POST /persons/{id}/password-reset
System Admin + Scope Admin
UC-52 — mail a password-reset link to the person’s own address, with UC-12’s eligibility; 429 within the cooldown
DELETE /scopes/{scopeId}/owners/{personId}
System Admin + Scope Admin
UC-22 — remove an owner (rejected if it would be the last one)
Applications — /api/scopes/{scopeId}/applications
Method & path
Who
Use case
POST /
System Admin + Scope Admin
UC-16 — register an application in the scope
GET /{id}
System Admin + Scope Admin
UC-17 — read one
GET /
System Admin + Scope Admin
UC-17 — list the scope’s applications
PUT /{id}
System Admin + Scope Admin
UC-18 — update name and owner
DELETE /{id}
System Admin + Scope Admin
UC-19 — logical delete
DELETE /{id}/hard
System Admin
UC-20 — hard delete
An application’s owner must be a ScopeAdmin or SystemAdmin who owns that scope — a User may never own one
(FR-AP-03).
UC-33 — update name, description, and the JWT-claim flag
DELETE /{id}
System Admin + Scope Admin
UC-34 — logical delete
DELETE /{id}/hard
System Admin
UC-35 — hard delete
Owning the scope is the whole of the authorization to manage its permissions — a permission has no
owner of its own. A permission with IncludeAsJwtClaim set has its name folded into the JWT of
identities acting within that scope (FR-AU-08); see
Scope permission claims
.
Google Users — /api/scopes/{scopeId}/google-users
Method & path
Who
Use case
GET /
System Admin + Scope Admin
UC-27 — list the scope’s Google Users
GET /{id}
Any authenticated
UC-27 — read one; admits the Google User themselves
The by-id read carries no role attribute for a concrete reason: a Google User’s token is User-role,
so any attribute strong enough to keep other Users out would lock out the actor UC-27 explicitly
grants. The listing admits none of them.
Both deletions take the scope’s PublicId in the path and refuse a Google User reached through the
wrong scope.
Audit trail — /api/audit-log
Method & path
Who
Use case
GET /api/audit-log
System Admin
UC-48 — the audit trail, newest first; filter by from/to (UTC, inclusive), actorId, action, targetId, succeeded
An entry is shown as stored: an actor the retention pass has cleared reads as null, so filtering by
actorId cannot find it. action is the command’s type name, matched exactly (CreateScopeCommand).
A Scope Admin is refused — the trail has no scope column to divide it by.
Health check — /api/healthcheck
Method & path
Who
Use case
GET /api/healthcheck
Anonymous
UC-30 — liveness; the process is up (no database read)
GET /api/healthcheck/detailed
System Admin
UC-30 — per-dependency status
The API also answers both at the root, /healthcheck and /healthcheck/detailed, for the container
health check and yggdrasil’s status page, which call it on its own port. Behind Traefik, only the
/api/ addresses reach it from the web UI’s host.
Response shape
Every endpoint answers one of two envelopes:
// DataOutput<T>
{"data":{/*theresource,ornull*/},"messages":["Scope created successfully"],"errors":[],"success":true}
// PaginatedOutput<T> — every list endpoint
{"data":[/*thepage*/],"pageNumber":1,"pageSize":20,"totalRecords":137,"messages":[],"errors":[],"success":true}
The HTTP status comes from the handler’s message, mapped through the area’s message map (a
dictionary from message text to status code), rather than from the controller choosing a status per
branch. Same message, same status, everywhere it is returned.
List endpoints accept pagination and filter parameters, all validated before the query runs
(NFR-10), and most accept includeDeleted=true to see logically deleted rows.
Malformed requests
A request that cannot be bound — a body that is not JSON, or a value of the wrong type in the body or
query string (?succeeded=maybe, {"name": 123}) — answers 400 with the error
The request is malformed., on every endpoint (NFR-10). The answer is a DataOutput envelope even
on list endpoints. Authentication and role checks come first, so a caller without a valid token still
receives 401, and one of the wrong role 403; refusals that depend on data, made in a handler, come
after the 400.
A query parameter that is not nullable no longer falls back to its default when it is empty or
unparseable: ?pageNumber=, ?pageNumber=abc, ?pageSize=, ?includeDeleted= and ?includeDeleted=1
are all 400. A nullable one sent empty (?succeeded=) still binds to null and is ignored. A missing
required field is not a binding failure: it keeps the field’s own message from the validator.
Of the endpoints that take a body, only POST …/credentials accepts an empty one, meaning “no expiry”.
Errors that are deliberately vague
Some responses tell the caller less than the API knows, on purpose:
POST /api/auth/login answers the same “invalid credentials” error whether the email is
unknown, the password wrong, the person logically deleted, or their scope gone (AF-11a…AF-11e). The
checks still run in the specification’s order so the code reads against UC-11 — only the answer
is uniform.
POST /api/auth/password-recovery answers identically whether or not the address belongs to
anyone — in the same time, too: it only queues the request, and the lookup, the token and the email
happen after the response. A Mailgun failure is logged rather than surfaced — an email outage must
not turn into a 500 that confirms an anonymous caller’s guess.
POST /api/auth/google answers alike for an unverifiable token, a token that does not carry
the nonce the request sent, and a Google User whose account is gone (AF-25a, AF-25g, AF-25d).
POST /api/auth/password-reset and POST /api/auth/verify-email answer “Invalid token.”
both for a token nobody issued and for one whose person has since been restricted, deleted or
anonymised, or can no longer sign in (AF-13e, AF-14d).
The specification
§5 of the System Requirements Document
lists the
endpoints as specified, and §7 is the full authorization matrix — every action against every
role, including Anonymous. The Use Case Specification Document
gives each endpoint’s main flow and every numbered alternative flow.
6 - API explorer
Swagger UI over the published OpenAPI document — every endpoint, its schema, and its authorization.
Every endpoint the API exposes, rendered from
the published OpenAPI document
. The document is generated from
the controllers themselves — the summaries below are the ones in the source — and regenerated with
python scripts/openapi.py. It is byte-for-byte what a running instance serves at
/swagger/v1/swagger.json: both come from the same SwaggerConfiguration.
This explorer does not call anything
The page has no server behind it, so Try it out and Authorize are both gone — a token entered
here could only be attached to a request, and no request can be sent. The padlocks stay, because
which endpoints need one is worth knowing. To call the endpoints, run the API and use its own Swagger
UI at /swagger — same document, with Authorize and Try it out working — or the
.http files or Bruno collection
in
api-client/. For what each endpoint means and who may call it in prose, see the
API reference
.
7 - Flows
Sequence diagrams for the paths that are worth tracing end to end.
Most endpoints are a straight line: validate, read, write, respond. The flows collected here are the
ones that are not — they cross services, branch on stored state, or deliberately answer the same way
down several different paths.
Issue a secret, exchange it for a token, and re-check that token live: what a consumer such as Cerberus has to do.
Each page cites the use case (UC-…) and alternative flows (AF-…) it implements. The authoritative
descriptions live in the
Use Case Specification Document
.
7.1 - Login
UC-11 — password authentication, the uniform rejection, and the branch into a two-factor challenge.
POST /api/auth/login — anonymous, rate-limited.
The sequence
sequenceDiagram
autonumber
actor C as Client
participant AC as AuthController
participant M as CommandMediator
participant AH as AuditingCommandHandler
participant H as LoginCommandHandler
participant V as LoginCommandValidator
participant PR as Person repository
participant TS as PersonAuthTokenService
participant TI as JwtAuthTokenIssuer
participant DB as PostgreSQL
C->>AC: POST /api/auth/login {email, password, scopeId?}
AC->>M: ExecuteCommandAsync(LoginCommand)
M->>AH: HandleAsync
AH->>H: HandleAsync
H->>V: ValidateAsync
alt invalid shape (AF-11f)
V-->>H: errors
H-->>C: 400 validation errors
end
H->>PR: find by the lookup the role implies
PR->>DB: SELECT person + role + scope navigations
DB-->>PR: person or null
Note over H: AF-11a unknown · AF-11b wrong password<br/>AF-11c deleted person · AF-11d/e scope not eligible<br/>locked out (FR-AU-09)<br/>→ all answer "invalid credentials"
H->>H: Hash.TextMatches(password, hash, salt)
H->>TS: TryBuildSubject(person)
H->>DB: SELECT active TwoFactorAuth for person
alt 2FA active (AF-11g)
H->>H: retire live email codes, issue + mail a fresh one
H->>H: issue challenge token (MfaPending, ~5 min)
H-->>C: 200 {requiresTwoFactor, challengeToken, availableMethods}
else no 2FA
H->>TS: IssueAsync(subject)
TS->>TI: IssueAsync
TI->>DB: SELECT flagged scope permissions
TI-->>TS: signed JWT + expiry
H-->>AH: DataOutput{token, expiresAt, emailVerified}
AH->>DB: INSERT AuditLog(LoginCommand)
AH-->>C: 200 {token, expiresAt, emailVerified}
end
Which lookup, and why
Person has no scope column, so how the person is found depends on the role being sought:
Role
Found by
User
Email within the scope named by the request — the same address may exist as a User in several scopes.
ScopeAdmin / SystemAdmin
Email alone — those addresses are unique system-wide.
The lookup deliberately omits an !IsDeleted filter. AF-11c exists to reject a logically deleted
person, so the person has to be found before they can be rejected.
One answer for five causes
AF-11a (unknown email), AF-11b (wrong password), AF-11c (deleted person), AF-11d (a User whose
scope is logically deleted) and AF-11e (a ScopeAdmin with no live owned scope) all return the same
invalid credentials error.
The checks still run in the specification’s order, so the code reads against UC-11 line by line —
only the answer is uniform. An anonymous endpoint that distinguished them would be a directory of
which addresses are registered and which accounts still exist.
The answer has to be uniform in time as well as in text. AF-11a used to return without hashing
anything, since there was no stored hash to compare against — so “no such account” came back in
single-digit milliseconds while every other rejection paid a full Argon2id verification (600 MB / 16
threads) and took hundreds. That gap answered the question the shared message refuses, and varying
the scope id turned it into a per-scope directory. The handler now verifies the submitted password
against a decoy hash — of a random secret generated once per process, belonging to nobody — and
discards the result. A lockout takes the same path, so it is not detectable either.
The lockout counts attempts as they start, not as they fail. Each attempt reserves itself in
failed_login_attempts — one conditional UPDATE, refused once the account is locked or ten
attempts are already counted — before the password is derived; a wrong password leaves the
reservation standing, a right one clears the count. The attempt that reaches ten also sets
locked_out_until and resets the count in that same UPDATE, so a sign-in cut short at the limit
still locks the account for the usual fifteen minutes, and it unlocks by itself. A right password on
the tenth attempt still signs in and clears the lock. Counting after the derivation let ten
simultaneous wrong passwords all read a count of zero and all write one, so the account never locked
(FR-AU-09).
Scope eligibility
PersonAuthTokenService.TryBuildSubject is the single place the eligibility rules live, shared with
two-factor verification
so a gated login ends exactly like a direct one:
flowchart LR
P[Person] --> R{Role}
R -->|User| U{Their scope deleted?}
U -->|Yes| X[Not eligible — AF-11d]
U -->|No| T["Subject: personId, roleId,<br/>scopeId"]
R -->|ScopeAdmin| A{Any live owned scope?}
A -->|No| X2[Not eligible — AF-11e]
A -->|Yes| T2["Subject: personId, roleId,<br/>ownedScopeIds"]
R -->|SystemAdmin| T3["Subject: personId, roleId<br/>— no scope claims"]
What the token carries
Claims are written by IdentityUserMapper, the same class that reads them back on validation:
Claim
Present for
id
Everyone — the person’s PublicId
roleId
Everyone
scopeId
User only
ownedScopeIds
ScopeAdmin only, comma-separated
scopePermissions
Anyone acting within a scope that has flagged permissions (details
)
mfaPending
Challenge tokens only
subjectRightsOnly
Subject-rights-only tokens only — a restricted identity’s (NFR-24)
sst
Every person token — full, challenge and subject-rights-only — the person’s security stamp (FR-AU-11). Not on a Google User’s token
The claims are omitted rather than emitted empty, so a token never suggests a scope association the
person does not have. Every value is a PublicId — an internal id never reaches a token (NFR-15),
except sst, a random value that means nothing outside Heimdall.
Revocation: when a token stops working
A person token is refused, with 401 and “This token has been revoked. Sign in again.”, from the
first request after any of these, whoever made the change and whichever token made it:
Event
Use case
Password reset
UC-13
Email change, by the person or an administrator
UC-08
Two-factor authentication confirmed
UC-37
Two-factor authentication disabled
UC-39
Recovery codes regenerated
UC-40
Each replaces the person’s security stamp, and ActorLivenessFilter, which already reads the
person’s row on every request, refuses a token whose sst is missing or no longer matches. The
request that made the change still succeeds, but its token is revoked with the rest, so a client
signs in again after it. A token issued before this mechanism existed carries no sst and is refused
the same way. A 2FA challenge token outdated by such a change is refused at
POST /api/auth/2fa/verify as an invalid challenge.
Consumers validating person tokens offline do not see a revocation: they still accept the token
until it expires. Heimdall itself refuses it.
Audiences: what a consumer must pin
Heimdall issues three kinds of person token, signed with the same keys under the same issuer. Only
the full token is a sign-in, and the audience says which kind a token is:
Token
aud
Full authentication token
HEIMDALL_AUTH_TOKEN_AUDIENCE, exactly as configured
With HEIMDALL_AUTH_TOKEN_AUDIENCE=heimdall, those are heimdall, heimdall.2fa-challenge and
heimdall.subject-rights. The two derived audiences need no configuration of their own, and start-up
logs all three.
A consumer that accepts person tokens must validate the audience, pinned to
HEIMDALL_AUTH_TOKEN_AUDIENCE exactly. It then refuses challenge tokens, subject-rights-only
tokens and application tokens
with no knowledge of Heimdall’s
claims. A consumer that skips the audience check would take a challenge token, which a password
alone obtains, for a full sign-in. Set HEIMDALL_AUTH_TOKEN_AUDIENCE (it defaults to empty), so
there is an audience to pin.
Inside Heimdall every bearer token must name HEIMDALL_AUTH_TOKEN_ISSUER as its issuer and exactly
one of these three audiences (NFR-30); anything else is refused with 401 “Invalid token”, as a bad
signature is. IdentityUserMapper then holds each token to its own audience: it maps a token only
when its audience and its mfaPending and subjectRightsOnly claims agree, and refuses one that
carries either claim under the wrong audience or one of the derived audiences without its claim.
The 2FA branch
When the person has an active two-factor configuration, login does not return an authentication
token at all. It returns a short-lived challenge token carrying mfaPending under its own
audience
. Only POST /api/auth/2fa/verify and the email-code
resend accept it, as a body field; MfaPendingGuardFilter rejects it as a bearer token everywhere,
and a consumer pinning the person audience refuses it.
If the Email method is enabled, login retires every live email code for that configuration and mails
a fresh one first — so only the code mailed for this attempt can complete it.
UC-36 to UC-40 — setup, confirmation, the challenge-token round trip, and recovery codes.
Two-factor authentication is opt-in, per person, and available to User, ScopeAdmin, and
SystemAdmin alike. It is not available to Google Users, who are already subject to Google’s own
account security.
Every 2FA endpoint acts on the caller themselves — the person comes from the bearer token, never from
a path or body — so no caller can reach another person’s configuration.
sequenceDiagram
autonumber
actor P as Person (authenticated)
participant AC as AuthController
participant EH as EnableTwoFactorAuthCommandHandler
participant CH as ConfirmTwoFactorAuthCommandHandler
participant TP as ITotpSecretProtector
participant ES as ITwoFactorEmailSender
participant DB as PostgreSQL
P->>AC: POST /api/auth/2fa/enable {methods, currentPassword}
AC->>EH: HandleAsync
EH->>DB: SELECT TwoFactorAuth for caller
alt already active (AF-36a)
EH-->>P: rejected — configuration untouched
end
alt Email selected and the account-email budget would refuse (AF-36f, AF-36g)
EH-->>P: 429 — nothing changed or sent, no password check
end
alt currentPassword missing or wrong (AF-36e)
EH-->>P: 401 — nothing created, no account email charged
end
opt Email selected
EH->>DB: reserve one account email (FR-PE-16) — a burst gets one
end
EH->>DB: UPSERT pending row (IsActive = false)
Note over EH,DB: AF-36d — re-initiating overwrites<br/>the pending selection entirely
opt App selected
EH->>TP: Protect(fresh TOTP secret)
TP-->>EH: encrypted secret
EH->>DB: store TotpSecretEncrypted
EH-->>P: secret returned once, for enrolment
end
opt Email selected
EH->>DB: retire outstanding codes, INSERT fresh 6-digit code (hash + salt, 10 min)
EH->>ES: SendAsync(email, code)
end
P->>AC: POST /api/auth/2fa/confirm {appCode?, emailCode?}
AC->>CH: HandleAsync
CH->>DB: SELECT pending row
CH->>DB: charge the re-authentication budget (FR-AU-13)
CH->>CH: check appCode if AppEnabled (AF-37b)
CH->>CH: check emailCode if EmailEnabled (AF-37c)
CH->>DB: replace the person's security stamp
CH->>DB: INSERT 10 recovery codes (SHA-256 digests only)
CH->>DB: UPDATE IsActive = true
CH->>DB: mark the confirming email code Used
CH-->>P: 200 — ten recovery codes, shown exactly once
Recovery codes are 80 bits (FR-2F-20): ten random bytes written as sixteen characters of
Crockford’s base32 — the digits and the uppercase letters without I, L, O and U — and shown as
XXXX-XXXX-XXXX-XXXX. A code is accepted with or without its hyphens, with spaces, and in any case,
and an O, I or L typed in one reads as 0, 1 and 1. Codes of the shorter XXXX-XXXX shape issued
before keep working until they are used or regenerated; regenerating (UC-40) is how a person gets
the stronger ones.
Enabling needs the password (FR-2F-18): adding a second factor requires proof of identity,
as removing one does, and a bearer token alone is not that proof. Confirming
revokes every token the person holds, the confirming one included (FR-AU-11): the client keeps
the recovery codes, then signs in again, now through UC-38.
IsActive is what separates a confirmed configuration from one still pending. A row with
IsActive = false means UC-36 created it and UC-37 has not yet activated it — and only an active
configuration gates a login.
The order of the recovery-code and activation writes is deliberate. The repository layer exposes
no transaction, so ordering is what stands in for one, and the dangerous half-write is “two-factor
is active but the caller holds no recovery codes” — which locks someone out of their own account on
the strength of a request that reported failure. Writing the codes first (in one insert, not ten)
leaves only harmless failure modes: codes stored against a configuration that never activated, which
the next attempt replaces, and which authenticate nothing while IsActive is false. UC-40 orders
its writes the same way and for the same reason, creating the replacements before deleting the set
they replace, so a failure leaves the caller with the codes they already hold rather than none at
all.
Confirmation requires proof of every method selected at setup: an appCode if AppEnabled, an
emailCode if EmailEnabled, both if both. A missing or wrong code answers 401 “The password or
code is incorrect.”, whichever it was, and is charged to the re-authentication budget below.
Completing a gated login — UC-38
sequenceDiagram
autonumber
actor C as Client
participant AC as AuthController
participant VH as VerifyTwoFactorAuthCommandHandler
participant CT as JwtTwoFactorChallengeTokenIssuer
participant FV as ITwoFactorFactorVerifier
participant TS as PersonAuthTokenService
participant DB as PostgreSQL
Note over C: login answered requiresTwoFactor<br/>with a challenge token
C->>AC: POST /api/auth/2fa/verify {challengeToken, code | recoveryCode}
AC->>VH: HandleAsync
VH->>CT: validate signature, expiry, challenge audience + mfaPending claim
alt invalid (AF-38a)
CT-->>VH: rejected
VH-->>C: 401 challenge invalid
end
VH->>DB: SELECT person + active TwoFactorAuth
opt app code or recovery code (FR-2F-17)
VH->>DB: charge one guess to the challenge (UPDATE … WHERE attempts < 5)
alt no guesses left, or redeemed meanwhile
VH-->>C: 401 challenge invalid
end
end
VH->>DB: charge one failure to the account (FR-AU-12)
VH->>FV: TOTP? live email code? unused recovery code?
alt none matches, or the account's second factor is locked (AF-38b / AF-38c)
FV-->>VH: no
VH->>DB: at the fifth guess, clear the challenge
VH-->>C: 401 factor invalid
end
VH->>DB: clear the account's second-factor failures
VH->>TS: TryBuildSubject(person)
alt scope no longer eligible
VH-->>C: 401 scope no longer eligible
end
VH->>DB: redeem the challenge (UPDATE … WHERE challenge_id = token's)
VH->>DB: spend the recovery code (UPDATE … WHERE NOT used) + UsedAt
VH->>DB: spend the email code (UPDATE … WHERE NOT used)
VH->>TS: IssueAsync(subject)
TS-->>VH: full authentication token
VH-->>C: 200 {token, expiresAt, emailVerified}
Three details in that diagram carry real weight:
AF-38b and AF-38c answer identically. A wrong code and an already-used recovery code both return
factor invalid, 401 — the same reasoning that collapses UC-11’s five rejections into one.
Scope ineligibility gets its own message. If the challenge token and the second factor were both
valid but the person’s scope eligibility no longer holds, the answer is
scope no longer eligible, not challenge invalid. Collapsing that case would misdescribe what
actually happened — nothing the caller supplied was wrong.
Nothing can be replayed. The redeemed recovery code and the redeemed email code are both marked
used before the token is issued. An app code is single-use too, though it has no row to mark: the
time step it was accepted at is recorded on TWO_FACTOR_AUTH.last_totp_time_step_used, and a code
from that step or earlier is refused (RFC 6238 §5.2). Without it the ±1-step tolerance meant an
observed code stayed usable for up to ninety seconds, across verify, disable, and regenerate alike.
A wrong email code costs the code. Five wrong guesses retire it (failed_attempts), so the
million values a six-digit code can take are not a million attempts — guessing again costs a fresh
login, which is rate limited and mails the account holder a code they did not ask for.
A challenge allows five guesses with an app code or a recovery code (FR-2F-17). Neither has a
row of its own to count on, so they are counted on the challenge (challenge_attempts), and at five
the challenge is cleared: its token is refused as AF-38a from then on, and further guessing costs a
fresh password check. A login resets the count with the challenge.
The account allows ten failures, whatever the challenge (FR-AU-12). Every guess — app code,
email code or recovery code — is also counted on the person, and neither a new challenge nor a
correct password resets that count; only a second factor that checks out does. The tenth failure
locks second-factor verification for fifteen minutes, twice as long at each repeat, up to a day.
While it is locked every code is refused, the right one included, with the same factor invalid
answer, and the challenge’s own five still run out as usual — so the lock does not show.
All of it holds when requests arrive together. Redeeming the challenge, spending a code,
accepting a TOTP step and charging a guess are each one conditional UPDATE (IAtomicWrites), and
every guess is charged before it is compared. Of any number of simultaneous requests carrying the
same challenge or the same code, exactly one gets a token; a parallel burst of guesses gets exactly
the budget compared, no more.
The final token is issued by PersonAuthTokenService — the same service a direct login uses — so a
2FA-gated login ends with exactly the token a direct one would have produced.
Why the guard filter never fires here
MfaPendingGuardFilter runs on every controller action and rejects any identity carrying
MfaPending. It never trips on POST /api/auth/2fa/verify because that endpoint is
[AllowAnonymous] and reads the challenge token as a body field, not as an
Authorization: Bearer credential — so the pipeline never attaches an MFA-pending identity for that
call. The filter only ever fires when a challenge token is misused as a bearer token somewhere
else — exactly the case FR-2F-10 wants blocked.
Disable and regenerate — UC-39 and UC-40
Both require a valid second factor as well as authentication, and both reuse
ITwoFactorFactorVerifier, so “match against TOTP, or the current email code, or an unused recovery
code” is implemented in exactly one place.
Every check under a bearer token shares one budget and one answer (FR-AU-13). Enabling,
confirming and disabling two-factor authentication, regenerating recovery codes, an own email change
and a person’s erasure request are each charged to the account’s re-authentication budget before
anything is compared: five failures within fifteen minutes pause them for fifteen minutes. A wrong or
missing password, a wrong, missing or used code, and a pause all answer 401 “The password or code
is incorrect.” — so disabling never says which of its two credentials was wrong. The budget is kept
apart from the login lockout and the second-factor budget, so a stolen token cannot be used to lock
its owner out of signing in.
UC-39 disable
UC-40 regenerate
Requires
Bearer token + second factor
Bearer token + second factor
Effect
Removes the configuration
Replaces all ten recovery codes
Response
Confirmation
The ten new codes, shown once
Afterwards
Every token revoked, the caller’s included: sign in again
Every token revoked, the caller’s included: keep the codes, then sign in again
Storage of secrets
Stored as
Returned
TOTP secret
Encrypted at rest via ASP.NET Core Data Protection
Once, at provisioning
Recovery codes
SHA-256 only — 80 random bits need no per-code salt or slow hash (FR-2F-20)
UC-24 to UC-29 — enabling the feature per scope, sign-up and sign-in down one endpoint, and administering Google Users.
Google Sign-In is off by default and enabled per scope. A Google identity is always
User-equivalent — it can never create or authenticate a Scope Admin or System Admin, whatever the
Google account is (FR-GO-04).
Enabling it — two independent switches
flowchart LR
A["PUT /api/scopes/{id}/google-signin<br/>UC-24 — per scope"] --> C{Both true?}
B["HEIMDALL_GOOGLE_CLIENT_IDS<br/>— per deployment"] --> C
C -->|Yes| D[Google sign-in works]
C -->|Scope off| E["403 — sign-in unavailable"]
C -->|No client IDs| F["401 — every token refused"]
With no client IDs configured, the API registers a verifier that refuses every token and warns
once at start-up. This is not a convenience fallback: verification needs an audience to check against
(NFR-13), and a verifier with no configured client could only reject everything or trust
everything — so it rejects. Every other endpoint keeps working.
Sign-up and sign-in — UC-25
One endpoint, POST /api/auth/google, does both. The first call for a given Google account in a
given scope creates the Google User; every later one authenticates it.
sequenceDiagram
autonumber
actor C as Client
participant AC as AuthController
participant H as GoogleSignInCommandHandler
participant GV as IGoogleIdTokenVerifier
participant G as Google
participant TI as JwtAuthTokenIssuer
participant DB as PostgreSQL
C->>AC: POST /api/auth/google {idToken, scopeId, nonce?}
AC->>H: HandleAsync
H->>GV: VerifyAsync(idToken)
GV->>G: signature, issuer, audience, expiry (NFR-13)
alt unverifiable or absent (AF-25a)
GV-->>H: null
H-->>C: 401 google authentication failed
end
G-->>GV: payload {sub, email, email_verified, name, picture, nonce}
alt nonce sent and the token's differs or is absent (AF-25g)
H-->>C: 401 google authentication failed
end
H->>DB: SELECT scope WHERE PublicId = scopeId<br/>AND NOT IsDeleted AND GoogleSignInEnabled
alt missing, deleted, or disabled (AF-25b)
H-->>C: 403 google sign-in unavailable
end
H->>DB: SELECT GoogleUser WHERE ScopeId AND GoogleId = sub
alt not found — sign-up (FR-GO-09)
alt email_verified is not true (AF-25f)
H-->>C: 403 sign-up requires a verified email address
end
H->>DB: is the email free among the scope's Google Users AND Users?
alt taken (AF-25c)
H-->>C: 409 email already registered
end
H->>DB: INSERT GoogleUser from the verified claims
else found but deleted (AF-25d)
H-->>C: 401 google authentication failed
end
H->>TI: IssueAsync(googleUser.PublicId, User role, scope.PublicId)
TI->>DB: SELECT flagged scope permissions
TI-->>H: signed JWT + expiry
H-->>C: 200 {token, expiresAt, emailVerified}
Verification comes first, before the scope is even read. An unverified caller learns nothing
about which scopes exist. It is also what answers a request that omitted the token entirely — which
is why UC-25 needs no 400 flow at all.
AF-25b answers alike for three different situations — the scope is missing, logically deleted, or
has the feature switched off. The alternative flow names all three as one outcome, so one filter
answers for all three.
A nonce binds the token to the client’s own sign-in attempt (FR-GO-20). nonce is optional: a
client that sent one to Google when asking for the ID token sends the same value here, and the
token’s nonce claim must equal it exactly; a token with another nonce, or none, answers as an
unverifiable token does. The nonce is a client-side check that the token it presents was issued for
its own sign-in request; whether a token is accepted rests on its signature, issuer, audience and
expiry (NFR-13). A request without nonce compares nothing, as before.
Only an address Google vouches for can sign up (FR-GO-07, AF-25f). A first sign-in whose
token does not carry email_verified: true — false, or absent — creates nothing and answers 403
“Google sign-up requires a verified email address.”. This is checked before the address is compared
with the scope’s. A Google User that already exists signs in whatever the claim says; FR-GO-19 keeps
its stored flag current.
AF-25c is named separately, on purpose. Being told the address is already registered reveals
something — but the caller has proved to Google that the address is theirs, and Google vouches for
it, so it tells them only about themselves. Uniqueness is checked jointly against the scope’s Google
Users and its password Users.
Signing out — UC-26
POST /api/auth/google/sign-out ends the client’s session and nothing else. Heimdall keeps no
Google session, and its tokens are stateless signed JWTs with an expiry, so there is nothing to
revoke: a copy of the token kept elsewhere stays valid until it expires (FR-GO-18). What the
endpoint does is check — it confirms the token still names a live Google User and then answers the
success that tells the client to drop the token.
That check is the substance. Authentication reads no database per request, so a token outlives the
account it names; one naming a deleted or unknown Google User is refused here.
Administering Google Users — UC-27 to UC-29
All four endpoints are nested under the scope the Google User belongs to (FR-GO-06), and each
refuses a Google User reached through the wrong scope.
The by-id read is the one that admits a Google User, and it is why that endpoint carries no
[RoleRequirement]: a Google User’s token is User-role, so anything strong enough to keep other
Users out would lock out the actor UC-27 grants. The listing admits none of them.
The logical delete is idempotent — repeating it answers 200 with alreadyDeleted: true and
writes nothing — and it is honoured everywhere it matters: a deleted account cannot sign in, cannot
sign out, and is absent from reads unless includeDeleted=true. The hard delete has no idempotent
path; a second call is a 404. Neither cascades, because a Google User owns nothing.
Testing without Google
A third verifier exists for the functional suite, which cannot override a DI registration and must
still reach the flows behind verification. It is guarded twice — never in Production, and never
without an explicitly set signing secret — and is checked before the real verifier so a test
environment cannot accidentally run both.
7.4 - Person onboarding
UC-06, UC-14 and UC-15 — creating a person, the verification email, and replacing a lost link.
Creating a person is one use case (UC-06) with three paths, decided by which endpoint is called
rather than by a field in the body.
Endpoint
Path
Creates
POST /api/persons
b
A ScopeAdmin or SystemAdmin with no scope
POST /api/scopes/{scopeId}/persons
a
A User in that scope, with a SCOPE_USER row
POST /api/scopes/{scopeId}/owners
c
A brand-new ScopeAdmin directly as a co-owner, with a SCOPE_OWNER row
Creating a User — UC-06 path a
sequenceDiagram
autonumber
actor A as Actor (SystemAdmin or ScopeAdmin)
participant PC as PersonController
participant AH as AuditingCommandHandler
participant H as CreateUserCommandHandler
participant OC as IScopeOwnershipChecker
participant EV as IEmailVerificationService
participant ES as IEmailVerificationSender
participant DB as PostgreSQL
A->>PC: POST /api/scopes/{scopeId}/persons {name, email, password}
PC->>AH: ExecuteCommandAsync
AH->>H: HandleAsync
H->>H: validate input (AF-06d)
H->>DB: SELECT scope — must exist and not be deleted (AF-06b)
H->>OC: does the actor own this scope? (AF-06e)
Note over OC: a SystemAdmin bypasses this check
H->>DB: is the email free among the scope's Users? (AF-06a, case-insensitive)
H->>DB: INSERT person (Argon2id hash + salt) + SCOPE_USER row
H->>EV: IssueAndSendAsync(person)
EV->>DB: INSERT EmailVerificationToken (48 chars, time-limited)
EV->>ES: SendAsync(email, token)
Note over EV,ES: a send failure does not undo the person —<br/>the token is persisted and can be re-sent (UC-15)
H-->>AH: DataOutput{person}
AH->>DB: INSERT AuditLog(CreateUserCommand)
AH-->>A: 201 person created
Email uniqueness is scoped to the role: a User’s address must be unique within their scope
(compared case-insensitively, LOWER() in SQL), while a ScopeAdmin’s or SystemAdmin’s is unique
system-wide. The same address can therefore be a User in several different scopes — which is exactly
why login needs a scope identifier to find a User.
Verifying the address — UC-14
sequenceDiagram
autonumber
actor P as Person
participant FE as Front-end verification page
participant AC as AuthController
participant H as VerifyEmailCommandHandler
participant DB as PostgreSQL
Note over P: opens the emailed link<br/>?token=…
P->>FE: click link
FE->>AC: POST /api/auth/verify-email {token}
AC->>H: HandleAsync
H->>DB: SELECT token, its person and their scopes
alt unknown (AF-14c), or the person may no longer be acted on (AF-14d)
H-->>FE: 400 — invalid token
end
alt expired (AF-14a) / spent (AF-14b)
H-->>FE: 400 — each rejection named distinctly
end
H->>DB: UPDATE person.EmailVerified = true<br/>WHERE address unchanged AND not restricted / deleted / anonymised
H->>DB: mark every live token for this person Used
H-->>FE: 200
The endpoint is anonymous — someone arriving from a link in their mail client holds no token of
any other kind.
Spending a token retires every other live token the person holds of that kind, so a verification link
left in an inbox stops working once the address is verified. An address that was already verified
answers 200 rather than an error: UC-14 defines no alternative flow for it, and the caller’s intent
has been satisfied either way. A person restricted, deleted or anonymised since the link was mailed,
or who can no longer sign in through their scope, gets 400 “Invalid token.” and nothing is written
(FR-EV-08), as UC-13 answers them.
Replacing a lost link — UC-15
POST /api/auth/resend-verification is authenticated and takes no body. The person is read from
the bearer token, so a caller can only ever ask for their own link.
flowchart TD
A[POST /api/auth/resend-verification<br/>bearer token only] --> B{Already verified?}
B -->|Yes| C["400 — AF-15a<br/>a link mailed to it could do nothing"]
B -->|No| G{Account-email budget<br/>reserves a send?}
G -->|"No — cooldown or daily limit"| H["429 — AF-15b / AF-15c<br/>nothing sent; the last link still works"]
G -->|Yes| D[Retire outstanding tokens]
D --> E[Issue + mail a fresh one]
E --> F[200 — only the newest link works]
Note the asymmetry with UC-14: verifying an already-verified address answers 200, but resending
to one answers 400. Verification’s job is done either way; a resend would mail a link that could
do nothing when clicked.
A resend is charged to the person’s account-email budget (FR-PE-16), which it shares with a
two-factor setup code and the notice an own email change sends: one per
HEIMDALL_ADMIN_EMAIL_COOLDOWN_IN_SECONDS (five minutes by default) and five per day-long window from the first, reserved by one
write before anything is sent. Inside the cooldown the answer is 429 “An email of this kind was
sent to you recently. Try again in a few minutes.” (AF-15b); once the window’s five are spent, 429 “You have
reached the limit for account emails. Try again later.” (AF-15c). Both carry Retry-After, the
seconds until a send would be accepted. Nothing is sent or retired
either way. A link an administrator sent, at creation or through UC-51, is not charged, so the
person may still ask once straight after. The endpoint is under the credential endpoints’ rate limit
too, whose own 429 says “Too many requests.” and carries a Retry-After header.
An administrator resending it — UC-51
A System Admin, or the owner of a User’s scope, can ask for the link on the person’s behalf with
POST /api/persons/{id}/verification-email. It is UC-15’s send — live links retired, a new one mailed
to the person’s own address — refused for a deleted or restricted person (409) or a verified address
(400). At most one such email goes to a person per HEIMDALL_ADMIN_EMAIL_COOLDOWN_IN_SECONDS (five
minutes by default), counting any verification email sent to the person in the window, including
the one sent when the account was created; a request inside the window answers 429 and sends
nothing. The check is best-effort: simultaneous requests for the same person can both pass it.
Without mail credentials
With Mailgun unconfigured, the API registers logging senders instead: each token is written to the
log rather than emailed, with a single warning at start-up. That keeps local runs and the functional
suite working without credentials and off the network.
It is a supported mode, not a broken one — outside Production. An unconfigured Production
deployment refuses to start outright, because the fallback would print verification tokens, password
reset tokens, and 2FA codes in plaintext to the logs, which is an account-takeover primitive for
anyone who can read them.
Both emails carry a link built from HEIMDALL_EMAIL_VERIFICATION_URL /
HEIMDALL_PASSWORD_RESET_URL, with the token appended as a token query parameter. If no link is
configured the email carries the bare token instead, which still works — the link is only a
convenience wrapper around it.
A scope may override both links for its own Users (PUT /api/scopes/{id}/email-links), so a
client system’s users are sent to that system’s screens; administrators, and a scope with no link
set, use the global ones.
7.5 - Password recovery
UC-12 and UC-13 — an endpoint whose whole design is about what it must not reveal.
Two anonymous, rate-limited endpoints: one asks for a link, the other spends it.
Requesting a link — UC-12
sequenceDiagram
autonumber
actor C as Anonymous caller
participant AC as AuthController
participant H as PasswordRecoveryCommandHandler
participant Q as PasswordRecoveryQueue
participant W as PasswordRecoveryDispatcher
participant PR as PasswordRecoveryProcessor
participant PS as IPasswordResetService
participant S as IPasswordResetSender
participant DB as PostgreSQL
C->>AC: POST /api/auth/password-recovery {email, scopeId?}
AC->>H: HandleAsync
H->>H: validate input shape (NFR-10)
Note right of H: the only rejection this endpoint ever issues
H->>Q: TryEnqueue(email, scopeId)
H-->>C: 200 — identical response, in the same time, either way
Q-->>W: next request (background, own DI scope)
W->>PR: ProcessAsync
PR->>DB: find by the lookup the role implies
alt person found and eligible
PR->>PS: issue token
PS->>DB: INSERT PasswordResetToken (time-limited)
PS->>S: SendAsync(email, token)
else nobody, deleted or restricted person, or deleted scope (AF-12a)
Note over PR,DB: no row written, no email sent
end
Every path returns the same success output. AF-12a — the address belongs to nobody — is not an
error flow but the absence of one: the handler issues no token and answers exactly as it would
have. A logically deleted person, and a User whose scope is deleted, are treated the same way.
The only thing that distinguishes the two paths is a row that does not get written and an email that
consequently never arrives — neither of which is visible to the caller.
The same answer, in the same time. While the lookup, the token insert and the Mailgun call ran on
the request, a registered address answered a Mailgun round trip later than an unknown one — a timing
difference that answered exactly what the uniform response refuses. So the handler only validates and
queues; everything that depends on the address happens afterwards, in PasswordRecoveryProcessor,
run by a hosted service in a DI scope of its own (never the finished request’s). The queue is in
memory and bounded: a durable outbox would keep every address anybody typed, registered or not, and
a response-time floor would have to outlast Mailgun’s slowest send. The cost is that a restart drops
whatever is still queued, which a person who receives nothing simply asks for again.
An outage must not become an oracle
A Mailgun failure here is logged, never surfaced. If a delivery error turned into a 500, the
difference between “your guess was a real account” and “it wasn’t” would be an HTTP status code.
An administrator sending the link — UC-52
POST /api/persons/{id}/password-reset lets a System Admin, or the owner of a User’s scope, have the
link mailed to the person’s own address. Eligibility is UC-12’s, from the same code — but because the
caller is authenticated, a refusal is reported instead of hidden: 409 for a deleted or restricted
person, or one whose account could not sign in. The send is not queued, since there is no anonymous
caller to protect from a timing difference. The same per-person cooldown as UC-51 applies (429), and is best-effort: simultaneous requests for
the same person can both pass it.
Setting the new password — UC-13
sequenceDiagram
autonumber
actor P as Person
participant FE as Front-end reset page
participant AC as AuthController
participant H as ResetPasswordCommandHandler
participant DB as PostgreSQL
P->>FE: opens the emailed link ?token=…
FE->>AC: POST /api/auth/password-reset {token, newPassword}
AC->>H: HandleAsync
H->>H: validate the new password's shape
H->>DB: SELECT token, its person and their scopes
alt unknown, or the person may no longer be acted on (AF-13c / AF-13e)
H-->>FE: 400 — invalid token
end
alt expired / already spent
H-->>FE: 400 — each rejection named
end
H->>DB: spend the token (UPDATE … WHERE NOT used AND unexpired)
alt a parallel request spent it first
H-->>FE: 400 — already used (AF-13b)
end
H->>DB: UPDATE person SET hash, salt, security stamp<br/>WHERE address unchanged AND not restricted / deleted / anonymised
alt the person changed since it was read
H-->>FE: 400 — invalid token
end
H->>DB: mark every other live reset token for this person Used
H-->>FE: 200
Both endpoints are anonymous because someone arriving from a link in their mail client holds no
token of any other kind.
Spending a reset token retires every other live reset token the person holds. A second “forgot
password” click cannot be replayed after the first has already changed the password.
The account is checked again when the link is followed (FR-PR-08). A link lives for an hour,
and the person may be restricted, deleted or anonymised, or lose the scope they sign in through,
while it waits in a mailbox. UC-12’s rule is run again, and a person it no longer admits gets exactly
the unknown-token answer, 400 “Invalid token.”, with nothing written — the token stays as it was,
and a restricted record is preserved. The password itself is written by one conditional UPDATE,
together with a fresh security stamp that revokes every earlier session, which repeats those checks
and requires the address the person was read with. After the row is created nothing else writes the
password columns, so an edit saved from an older copy of the person cannot put the old password back.
An email change retires the links (FR-PE-17). Whoever changes a person’s address, every live
reset and verification link they hold is retired, before the new address is saved and again straight
after.
Following one afterwards answers 400 “Token already used.”.
Why login costs what it costs
Password verification is Argon2id — by this codebase’s hashing library defaults, 600 MB and 16
threads per verification. That is deliberate for a stored credential, and it is also precisely why
these endpoints are rate-limited: an unthrottled burst against /login is a memory and CPU
exhaustion vector before it is a brute-force one. See
Operations
.
7.6 - Scope permission claims
UC-31 to UC-35 and FR-AU-08 — how a permission defined in a scope ends up inside a caller JWT.
A scope permission is a named permission defined inside one scope. It has no owner of its own —
owning the scope is the whole of the authorization to manage it — and it is a leaf in the data model:
nothing carries a foreign key to it.
Its one moving part is the IncludeAsJwtClaim flag.
From definition to claim
sequenceDiagram
autonumber
actor SA as Scope Admin
participant SPC as ScopePermissionController
participant CH as CreateScopePermissionCommandHandler
participant DB as PostgreSQL
actor U as User in that scope
participant AC as AuthController
participant LH as LoginCommandHandler
participant TI as JwtAuthTokenIssuer
SA->>SPC: POST /api/scopes/{scopeId}/permissions<br/>{name, description, includeAsJwtClaim: true}
SPC->>CH: HandleAsync
CH->>DB: is the actor an owner of this scope?
CH->>DB: INSERT ScopePermission
CH-->>SA: 201
Note over U: later — an ordinary login
U->>AC: POST /api/auth/login
AC->>LH: HandleAsync
LH->>TI: IssueAsync(subject)
TI->>DB: SELECT DISTINCT name<br/>WHERE scope in subject's scopes<br/>AND NOT scope.IsDeleted<br/>AND NOT permission.IsDeleted<br/>AND IncludeAsJwtClaim
DB-->>TI: permission names
TI->>TI: fold into the scopePermissions claim
TI-->>U: JWT carrying the permission names
Which permissions a caller claims
flowchart TD
S[Subject being issued a token] --> R{Role}
R -->|User| A["The permissions of<br/>the one scope they belong to"]
R -->|ScopeAdmin| B["The union over<br/>every scope they own"]
R -->|SystemAdmin| C["None — they belong<br/>to no scope"]
A & B --> F["Filtered: scope not deleted,<br/>permission not deleted,<br/>IncludeAsJwtClaim set"]
F --> G["Distinct names →<br/>scopePermissions claim (JSON array)"]
The fold happens at issuance, reading the database once. A permission created, flagged, or
deleted after a token was issued does not change that token — the holder picks up the change on their
next login. This is the same trade the rest of the authentication design makes: no database read per
request, in exchange for claims that are a snapshot rather than a live view.
A System Admin carries no scopePermissions claim at all. They belong to no scope, so there is
nothing to fold — and nothing they need it for, since their authority is not scope-derived.
The hard delete is System-Admin-only for the same reason it is on applications and Google Users: a
Scope Admin may retire a permission in a scope they own, but never purge it.
Deletion behaviour
Both deletions are simple, because a permission is a leaf:
Logical delete (UC-34) flips only its own IsDeleted flag. Nothing cascades. It stops being
folded into new tokens immediately.
Hard delete (UC-35) removes the row. Nothing cascades.
The interesting case is the scope’s deletion:
Scope operation
Effect on its permissions
Logical delete (UC-04)
None. They keep whatever IsDeleted state they had.
Hard delete (UC-05)
Purged, via the scope_permission → scope foreign key’s ON DELETE CASCADE.
Not cascading the logical delete is deliberate. The permissions become unreachable anyway — the
listing endpoint gates on the scope’s IsDeleted, and the claim fold above excludes permissions of
deleted scopes — so nothing is exposed. Leaving them untouched means a restored scope recovers its
permission set unchanged, which cascading would have destroyed.
7.7 - Audit logging
NFR-09 — one decorator, every write, no handler changes.
NFR-09: all write operations shall produce audit log entries.
Rather than a line in every handler — forgettable, and duplicated forty times — this is a decorator
applied at registration.
How a handler gets audited
sequenceDiagram
autonumber
participant DI as DI container
participant M as CommandMediator
participant A as AuditingCommandHandler
participant H as Concrete handler
participant W as AuditLogWriter
participant AA as IActorAccessor
participant DB as PostgreSQL
M->>DI: resolve ICommandHandlerAsync<TCommand, TOutput>
DI-->>M: AuditingCommandHandler wrapping the concrete handler
M->>A: HandleAsync(command, cancellationToken)
A->>H: HandleAsync(command, cancellationToken)
H->>DB: the actual write
H-->>A: DataOutput{success, data}
alt success
A->>A: resolve target from output.Id / output.PublicId
A->>W: WriteAsync(commandTypeName, targetId)
W->>AA: ActorPersonId, ActorRole
AA-->>W: from the current request's IdentityUser
W->>DB: INSERT AuditLog
alt the insert fails
W--xA: exception
A->>A: log a warning — the request still succeeds
end
else failure
Note over A: nothing written — only successful writes are audited
end
A-->>M: the unchanged DataOutput
Every write handler in Startup.AddDependencies is registered with AddAuditedCommandHandler, so
adding a new command means adding one registration line and inheriting the audit trail.
Depend on the interface
The concrete THandler registration exists only so the decorator factory can resolve it. Resolving
THandler directly — in a handler, a service, or a test that then asserts on production wiring —
silently bypasses auditing. Always depend on ICommandHandlerAsync<TCommand, TOutput>.
What an entry records
Column
Value
PublicId
The entry’s own GUID
ActorPersonId
The acting person’s PublicId, or null for an anonymous write
ActorRole
The acting person’s role value, or null for an anonymous write
Action
The command’s CLR type name, e.g. "CreateApplicationCommand"
TargetId
Best-effort PublicId of the affected entity, or null if none could be resolved
CreatedAt
UTC timestamp
Three design choices are worth calling out.
ActorPersonId is not a foreign key. It is a bare PublicId, so an entry survives the hard
deletion of the person who made the write — which is exactly the case an audit trail exists for.
The table is append-only. Entries are never deleted. The only update a database trigger permits
is the retention pass clearing the actor attribution, and a target naming a person or Google User,
once due (NFR-21).
The target is resolved reflectively, from the output’s Id or PublicId property. Commands
whose output carries neither record a null target rather than failing.
Failures never fail the request
If writing the entry throws, the decorator logs a warning and returns the original result. The user’s
write already succeeded and committed; turning that into a 500 would misreport what happened and
invite a retry of an operation that already took effect.
The trade is explicit: audit-trail completeness is best-effort against a database failure, and the
warning in the log is the record that a gap exists.
Anonymous writes
IActorAccessor reads the current request’s IdentityUser, which is null on an anonymous request.
The anonymous write endpoints — login, password recovery, password reset, email verification, Google
sign-in, second-factor verification — therefore produce entries with a null actor. The Action
still names the command, so the entry records what happened even when it cannot record who.
Reading the trail — UC-48
GET /api/audit-log lists entries for a System Admin, newest first. Ties are broken by write order,
so the order is deterministic for a fixed set of rows. Every command writes to the trail, so entries
written between two page requests shift older ones and the next page can repeat some; to page a
stable snapshot, pin to to the time of your first request. It filters by from/to (UTC, inclusive; a
value without an offset is read as UTC), actorId, action, targetId and succeeded, combined with
AND. targetId is indexed (ix_audit_log_target_id), so “what happened to this entity?” does not scan
the table.
The listing reads AUDIT_LOG and nothing else. A cleared attribution stays cleared: actorId and
actorRole read null, and no join or neighbouring entry is used to put a name back. A cleared person
target reads targetId: null the same way, so filtering by targetId cannot find that entry. Listing is a read
and writes no entry of its own.
7.8 - Application authentication
UC-53 to UC-57 — credentials for a registered application, the token they buy, and the live re-check consumers make.
An application authenticates to Heimdall as itself: an administrator issues it a secret (UC-53), the
application exchanges the secret for a short-lived token (UC-56), and a consumer service checks that
token live (UC-57). This page is written for the people building that consumer. The authoritative
descriptions live in the
Use Case Specification Document
.
Who this is for
A service that must know which registered application is calling it, not which person. Cerberus
is the first. Such a service never sees a person’s token for this purpose and never holds a role:
an application token carries the application’s identity and nothing else.
Provisioning and rotation
A System Admin, or the Scope Admin who owns the application and still owns its scope, issues a
credential. The expiresAt in the body is optional; a value without an offset is read as UTC.
{"data":{"id":"6f1c0d52-8f0e-4a43-9c52-2d6f6a9b1d11","secret":"hds_q3y0lV8mJ1k0d9yq5Qh2a8Xv7nTz4RbC6uWe1sPoKfM","createdAt":"2026-10-14T12:00:00Z","expiresAt":"2030-01-01T00:00:00Z"},"messages":["Application credential issued. Store the secret now: it is never shown again."],"errors":[],"success":true}
The secret appears in this response and nowhere else. Heimdall keeps only its SHA-256 hash, so no
later call, backup or log can return it. Put it in the consumer’s secret store now. A lost secret is
replaced, not recovered: issue another and revoke the lost one.
An application holds at most two live credentials (not revoked, not expired). That limit is what
makes rotation free of downtime:
issue a second credential (POST …/credentials);
deploy its secret to the application;
revoke the first (DELETE …/credentials/{credentialId}).
With two already live, a third is refused with 409 Conflict; revoke one first. GET …/credentials
lists them newest first with id, createdAt, expiresAt, revokedAt, lastUsedAt and live, so
you can see the new secret in use (lastUsedAt) before you revoke the old one.
Obtaining a token
The application exchanges a secret for a token. The endpoint is anonymous: the secret authenticates.
curl -X POST http://localhost:5177/api/applications/token \
-H "Content-Type: application/json"\
-d "{\"applicationId\":\"$APPLICATION_ID\",\"scopeId\":\"$SCOPE_ID\",\"secret\":\"$APPLICATION_SECRET\"}"
Request a new token shortly before expiresAt and reuse it until then. Never request one per
call: the endpoint is rate-limited per client address like person sign-in, and each exchange is
audited.
Every refusal is the same 401 Unauthorized — “The application credentials were not accepted.” — so
the endpoint reveals nothing about which applications, scopes or credentials exist. When you see it,
check, in order:
the applicationId is the application the credential was issued for;
the scopeId is that application’s scope;
the credential is live: not revoked, not past its expiresAt, and the application and scope are
not deleted (GET …/credentials shows live);
the secret is exactly as issued. Surrounding whitespace is trimmed (a trailing newline from a
file, for one); a truncated or altered secret is not accepted.
A request that is missing a field, carries a secret over 100 characters, or is not readable JSON
(or carries an id that is not a GUID) is a 400 instead: the field’s message, or
“The request is malformed.” for a body that cannot be read.
The token
An application token is a JWT signed with HMAC-SHA256 under the same keys as person tokens, valid for
ten minutes by default (HEIMDALL_APPLICATION_TOKEN_EXPIRATION_IN_SECONDS).
Claim
Value
iss
HEIMDALL_AUTH_TOKEN_ISSUER, when one is configured
aud
HEIMDALL_APPLICATION_TOKEN_AUDIENCE — never the person audience
exp, iat, nbf
Expiry, issue time and not-before
tokenKind
application
id
The application’s identifier
scopeId
The identifier of the application’s scope
credentialId
The credential the token was issued from
name
The application’s name
There is no role claim. An application token never grants a role, and Heimdall’s own endpoints
refuse it: any token carrying tokenKind is refused where a person token is expected.
Validating offline
A consumer can validate the token without calling Heimdall. It must check all of these:
the algorithm is HS256, and nothing else is accepted;
the signature, against both signing keys while a rotation is in progress
(HEIMDALL_AUTH_TOKEN_SECRET and HEIMDALL_AUTH_TOKEN_SECRET_PREVIOUS);
the issuer, when Heimdall is configured with one (Heimdall itself requires its own issuer, or none
when none is configured, and the application audience as the token’s only audience — NFR-30);
the audience is the application audience, the value of HEIMDALL_APPLICATION_TOKEN_AUDIENCE;
the lifetime, with zero clock skew;
tokenKind is "application".
If the consumer also validates person tokens, it must pin the person audience. Both kinds are
signed with the same keys. A consumer that checks the signature but not the audience would accept
an application token as a person’s. Heimdall refuses to start when the two audiences are equal for
exactly this reason. Pinning it also refuses Heimdall’s 2FA challenge and subject-rights-only tokens,
which carry audiences of their own (Login: audiences
).
Heimdall itself validates with zero skew, so instances whose clocks differ can refuse a freshly issued
token for the length of the drift. Keep clocks synchronised (NTP).
Checking live
Offline validation says the token was issued and has not expired. It cannot say the credential is
still wanted. For that, present the token to Heimdall:
The answer is 200 OK only when the token validates (as above) and its credential is live and
its application and scope are live and match the token. Anything else is one 401 Unauthorized —
“The application token was not accepted.” — whatever failed.
Revocation policy
Revoking or expiring a credential, or deleting the application or its scope, takes effect at the
next live check for every token already issued. A token’s own exp is fixed when it is issued, so
it stays valid offline for up to its whole lifetime, ten minutes by default, after its credential
expires or is revoked. Only the live check reflects that at once. So a consumer re-checks before
every sensitive release, and treats the offline check as a fast path for everything else.
Status codes
Endpoint
Status
When
POST …/credentials
201
Credential issued; the secret is in the body
400
expiresAt is not in the future, is not a date-time, or the body is not JSON (AF-53c)
401 / 403
Anonymous; a User, or a Scope Admin who does not own the application or no longer owns its scope (AF-53b)
404
No live application with that id in the addressed, live scope (AF-53a)
409
Two live credentials already (AF-53d)
GET …/credentials
200
The credentials, newest first; an empty list when there are none (AF-54c)
401 / 403 / 404
As above (AF-54a, AF-54b)
DELETE …/credentials/{credentialId}
200
Credential revoked
401 / 403 / 404
As above, and 404 when the credential is not on this application (AF-55a to AF-55c)
409
Already revoked (AF-55d)
POST /api/applications/token
200
Token issued
400
A field missing, the secret over 100 characters, or a body that is not readable (AF-56a)
401
Any other reason, always the same message (AF-56b)
429
Rate limit; the Retry-After header gives the seconds to wait
GET /api/applications/self
200
Token valid and still live
401
Any reason, always the same message (AF-57a)
8 - Environments and deployment
The four environments — local on Docker Desktop, development, homologation and production on one VPS through yggdrasil — and, step by step, what the PostgreSQL behind each has to allow.
The API runs in four environments. All of them use the same image and the same
docker-compose.yml
;
what differs between them is the env file, who deploys it and, mostly, what the host’s PostgreSQL
has to be told to allow.
Environment
Where
Deployed by
ASPNETCORE_ENVIRONMENT
API at
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
Jenkins, on every push to develop
Development
https://heimdall-api-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
https://heimdall-api-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 — it then merges and tags
Production
https://heimdall-api.example.com
heimdall, PostgreSQL on the VPS
example.com stands for the real domain throughout this page. In every VPS environment the API is
also served under the web UI’s own host at /api/ (https://heimdall-dev.example.com/api/…,
-hml, and https://heimdall.example.com/api/…), which is how heimdall-ui’s browser build calls
it without CORS.
What the ASP.NET environment changes:
Development (local, development)
Staging (homologation)
Production
Swagger UI
Served
Not served
Not served
Developer exception page
On
Off
Off
EF Core parameter logging
On
On
Off
Verification and reset e-mails without Mailgun
Logged instead of sent
Logged instead of sent
Start-up fails
Local is deployed by hand on Docker Desktop. Development, homologation and production share
one Ubuntu VPS and are deployed by yggdrasil
, whose Jenkins
runs this repository’s
Jenkinsfile
. Development and
homologation are started only while somebody uses them.
What is being deployed
One service, api, built from the repository’s Dockerfile
:
A multi-stage build that publishes the Web API and an EF Core migrations bundle. The bundle is
a plain executable, so the runtime image never needs the SDK or dotnet-ef.
The entrypoint applies pending migrations and only then starts the API — which is what lets a
container be pointed at an empty database. Set HEIMDALL_RUN_MIGRATIONS=false to apply them out of
band instead; see Operations
. With
HEIMDALL_MIGRATIONS_CONNECTIONSTRING set, the bundle migrates as that connection’s role — the
schema’s owner — and the API runs as a role that owns nothing; see
Database roles
.
The container drops to a non-root user, writes logs to a named volume, and answers a health check
on /healthcheck — Compose reports the container healthy only once the API answers.
PostgreSQL is deliberately not a service in the Compose file. Every host already runs one
instance shared by several services and environments, each owning a database of its own. So the
first half of each walkthrough below is about that instance, not about Docker.
Three environments, one engine
The VPS runs development, homologation and production on one Docker engine and one PostgreSQL
instance. They are kept apart by name, not by machine: each environment’s Compose project is
heimdall-api-<environment>, its image tags start with <environment>-, its network alias is
heimdall-api.<environment>, and its database and login are its own. Nothing an environment is
given — secret, master user, database — is shared with another.
Windows — the local environment
Docker Desktop on the developer’s machine, against the PostgreSQL installed on Windows. Nothing
deploys here but the developer: no Jenkins, no Traefik, the port published on the host.
The context marked * should be desktop-linux. If the command fails, start Docker Desktop and wait
for the whale icon to stop animating.
2. Create the database and its login
Each service on the shared instance gets its own database and its own role. With psql on PATH
(C:\Program Files\PostgreSQL\18\bin), as the postgres superuser:
psql-Upostgres-c"CREATE ROLE heimdall_svc LOGIN PASSWORD '<pick-a-password>';"psql-Upostgres-c"CREATE DATABASE heimdall_local OWNER heimdall_svc;"
Do not name the login heimdall
The entities live in a schema called heimdall, and PostgreSQL’s default search path — "$user", public — makes the login’s name a schema lookup. A login named heimdall sends the second run
looking for its migration history in that schema, where the first run wrote none: it concludes
nothing was ever applied and dies on relation "role" already exists. Compose pins Search Path=public for exactly this reason, but the safest thing is still to call the login something else.
3. Confirm the container can reach it
Docker Desktop forwards a container’s connection to host.docker.internal through its own VM, and
the Windows PostgreSQL sees it arrive from 127.0.0.1 — so the stock pg_hba.conf, which allows
host all all 127.0.0.1/32, already covers it. Nothing to change here, and no Windows Firewall rule
to add either, because nothing crosses a real network interface.
Prove it before going further, rather than discovering it from a container that will not start:
A row of 1 means the container has everything it needs. no pg_hba.conf entry for host "<address>"
means the connection arrived from an address the rules do not cover — add host heimdall_local heimdall_svc <that-address>/32 scram-sha-256 to pg_hba.conf and reload the service.
-Confirm is not decoration: the template’s values are empty, so re-running this over a file already
filled in silently un-fills it, and the next up fails with required variable ... is missing a value — which reads like the file was never written rather than like it was overwritten.
docker/local.env is gitignored — it holds the database password, the token signing secret and the
master user’s credentials. Open it and fill in at least:
Variable
Value
DB_USER / DB_PASSWORD
The login from step 2
DB_NAME
heimdall_local
HEIMDALL_AUTH_TOKEN_SECRET
A long random value, unique to this environment: at least 32 bytes of ASCII, or the API will not start (openssl rand -base64 48)
HEIMDALL_APPLICATION_TOKEN_AUDIENCE
Required, and different from HEIMDALL_AUTH_TOKEN_AUDIENCE and the audiences derived from it. local.env.example presets heimdall-applications; keep it unless a consumer pins another
HEIMDALL_MASTER_USER_NAME / _EMAIL / _PASSWORD
The first system administrator, seeded on the first run against an empty database
DB_HOST=host.docker.internal is already correct for Docker Desktop. To generate the secret:
Every other variable has a default that suits a local deployment: Swagger and the developer exception
page are on, and verification and reset e-mails are written to the log instead of sent, because
ASPNETCORE_ENVIRONMENT=Development and the Mailgun variables are empty. The e-mails’ links point
at the local heimdall-ui, http://localhost:8081.
CORS lists the local heimdall-ui
HEIMDALL_CORS_ALLOWED_ORIGINS unset refuses every cross-origin request, which is the right default
for an API that hands out credentials. Locally heimdall-ui is served from http://localhost:8081 and
calls the API on http://localhost:8080 — another origin — so the template lists it. Any other
browser front end needs its origin added, scheme, host and port exactly as the browser sends them.
On the VPS no entry is needed: heimdall-ui calls the API under its own host.
The first build takes a few minutes — it restores, publishes, and builds the migrations bundle.
Later builds reuse the layer cache, and a source-only change re-runs neither the tool restore nor the
package restore.
6. Verify
dockercompose--env-filedocker/local.envps
Wait for STATUS to read Up (healthy) — the health check has a 30 s start period, so starting is
expected at first. Then:
curl.exehttp://localhost:8080/healthcheck
The start-up log should show the migrations applied and then the API listening:
dockercompose--env-filedocker/local.envlogs-fapi
Swagger UI is at http://localhost:8080/swagger
, and the master user from the env file can log in at
POST /api/auth/login. On an empty database the entrypoint’s migration step is where a bad
connection string surfaces — read the first twenty lines of the log before anything else.
7. Day-to-day
Task
Command
Follow the logs
docker compose --env-file docker/local.env logs -f api
Restart after an env change
docker compose --env-file docker/local.env up -d
Rebuild after a code change
docker compose --env-file docker/local.env up -d --build
Stop, keeping the log volume
docker compose --env-file docker/local.env down
Stop and discard the logs
docker compose --env-file docker/local.env down -v
A shell in the container
docker compose --env-file docker/local.env exec api sh
The env file has to be passed on every Compose command, not just up: it supplies
COMPOSE_PROJECT_NAME, so without it Compose looks for a differently-named project and reports
nothing running.
The VPS — development, homologation and production
One Ubuntu VPS runs all three, behind yggdrasil’s Traefik, against the PostgreSQL installed on the
VPS itself. The VPS’s own setup — Docker, the platform stack, Jenkins, the wildcard certificate and
the single *.example.com DNS record that covers heimdall-api-dev, heimdall-api-hml and
heimdall-api alike — is yggdrasil’s, and described in its documentation. What follows is what this
API needs on top of it.
How yggdrasil deploys it
Jenkins builds the image on the VPS and runs docker compose with this repository’s Compose file
plus yggdrasil’s stacks/heimdall-api.proxy.yml, which:
removes the host port — Traefik, on the shared edge network, is the only way in, so API_PORT
means nothing here;
names the project heimdall-api-<environment> (COMPOSE_PROJECT_NAME is ignored too) and gives
the container the alias heimdall-api.<environment> on the edge and telemetry networks —
Prometheus scrapes heimdall-api.<environment>:9464 there;
routes two hosts to it: PUBLIC_HOST (heimdall-api-dev.example.com) for the mobile and desktop
clients, and UI_HOST + /api/ (heimdall-dev.example.com/api/) for heimdall-ui’s browser build.
The env file is /etc/yggdrasil/<environment>/heimdall-api.env on the VPS, never in a repository.
Push
Deploys to
Afterwards
develop
development
Left stopped if it was stopped
release/x.y.z
homologation
Left stopped if it was stopped
Pull request release/x.y.z → main, every check green
production
Jenkins merges the pull request and tags vx.y.z
Development and homologation are on demand. A deploy still builds, starts and health-checks the
new version — a broken build is caught and rolled back either way — but an environment that was not
running before the deploy is stopped again afterwards. To use one, on the VPS:
scripts/ygg.sh env start development
and scripts/ygg.sh env stop development when done (homologation likewise). The yggdrasil console
shows each environment’s state, a stopped on-demand environment as Stopped rather than as a
problem.
1. Open PostgreSQL to the bridge network
This is the step that fails silently if skipped. The container connects to host.docker.internal,
which Compose maps to the Docker bridge gateway (172.17.0.1) — a real interface, not a
forwarded loopback. Ubuntu’s PostgreSQL ships listening on 127.0.0.1 only, so it is unreachable
from a container until told otherwise.
Check what it listens on:
ss -ltn | grep 5432
127.0.0.1:5432 alone means the edit below is needed. In
/etc/postgresql/<version>/main/postgresql.conf:
listen_addresses = 'localhost,172.17.0.1'
Naming the gateway rather than * keeps the instance off every other interface — including the
VPS’s public address. listen_addresses requires a restart:
sudo systemctl restart postgresql
2. Create each environment’s database and login
One PostgreSQL instance, one database and its own logins per environment, so that a development
or homologation container — or a leaked development password — cannot reach production’s data.
Production keeps the database it has always had, heimdall.
The supported setup runs the migrations as an owner role and the API as a role that owns nothing
(NFR-29), so each environment has two logins. For a new environment:
then apply the grants and the rest of the steps in
Operations, “Database roles”
(“A new deployment”).
Production’s pair is heimdall_owner and heimdall_svc. An environment whose database belongs to
the API’s own login is moved to this setup with the steps in the same section (“Switching an
existing deployment over”). Skip what an environment already has.
Then, in /etc/postgresql/<version>/main/pg_hba.conf, one rule
per login for the Docker range, below the loopback rules — each login reaches its own database and no
other:
The owner rules are needed only while the container migrates; an environment that migrates out of
band gives its owner a local rule instead.
172.16.0.0/12 rather than one network’s subnet: yggdrasil’s edge and telemetry networks, and
any network Docker creates later, are all allocated from it. pg_hba.conf needs only a reload:
sudo systemctl reload postgresql
Do not name a login heimdall
The same trap as on Windows: a login named heimdall turns the search path’s "$user" into the
entities’ schema. Compose pins Search Path=public, but the safest thing is still a login called
something else.
3. Confirm a container can reach each database
docker run --rm --add-host host.docker.internal:host-gateway alpine sh -c 'nc -z -w 3 host.docker.internal 5432 && echo reachable || echo unreachable'docker run --rm --add-host host.docker.internal:host-gateway postgres:18-alpine psql "postgresql://heimdall_development_svc:<password>@host.docker.internal:5432/heimdall_development" -c "select 1"
Repeat the second line for homologation and production. unreachable points back at
listen_addresses; no pg_hba.conf entry for host points at the pg_hba.conf rules. And the
separation should hold — the development login against production’s database must be refused:
Each environment has a template in the repository — docker/development.env.example,
docker/homologation.env.example, docker/production.env.example — already filled in with what
that environment needs, example.com standing for the domain. On the VPS, as the user that owns
yggdrasil’s secrets directory:
Replace example.com with the real domain, then fill in at least:
Variable
Value
DB_USER / DB_PASSWORD
That environment’s runtime login (…_svc) from step 2
HEIMDALL_MIGRATIONS_CONNECTIONSTRING
Optional: the owner role’s whole connection string, when the environment has one (Database roles
)
HEIMDALL_AUTH_TOKEN_SECRET
A long random value of its own (openssl rand -base64 48), at least 32 bytes of ASCII or the API will not start — every service that validates Heimdall’s tokens with this key is given the same value
HEIMDALL_APPLICATION_TOKEN_AUDIENCE
Required, and different from HEIMDALL_AUTH_TOKEN_AUDIENCE and the audiences derived from it: the API will not start otherwise. The templates preset heimdall-applications. HEIMDALL_APPLICATION_TOKEN_EXPIRATION_IN_SECONDS is optional and defaults to 600
HEIMDALL_MASTER_USER_NAME / _EMAIL / _PASSWORD
That environment’s first system administrator, seeded on the first run against its empty database
MAILGUN_API_KEY / MAILGUN_DOMAIN
Production only — it refuses to start without them
MAILGUN_FROM
The sender, e.g. Heimdall <no-reply@example.com> on the Mailgun domain — without it mail goes out from a bare postmaster@ address
What the templates already set, and how the three differ:
Variable
development
homologation
production
PUBLIC_HOST
heimdall-api-dev.example.com
heimdall-api-hml.example.com
heimdall-api.example.com
UI_HOST
heimdall-dev.example.com
heimdall-hml.example.com
heimdall.example.com
ASPNETCORE_ENVIRONMENT
Development
Staging
Production
HEIMDALL_ALLOW_SENSITIVE_DIAGNOSTICS
empty
empty
empty
DB_NAME
heimdall_development
heimdall_homologation
heimdall
HEIMDALL_EMAIL_VERIFICATION_URL
https://heimdall-dev.example.com/verify-email
https://heimdall-hml.example.com/verify-email
https://heimdall.example.com/verify-email
HEIMDALL_PASSWORD_RESET_URL
https://heimdall-dev.example.com/password-reset
https://heimdall-hml.example.com/password-reset
https://heimdall.example.com/password-reset
All three set DB_HOST=host.docker.internal and HEIMDALL_TRUSTED_PROXIES=172.16.0.0/12 — Traefik
reaches the container from yggdrasil’s edge network, so forwarded addresses are believed from the
Docker range and from nobody else — and leave HEIMDALL_CORS_ALLOWED_ORIGINS empty, since
heimdall-ui calls the API under its own origin.
scripts/ygg.sh config heimdall-api on the VPS opens the file of the environment it asks for and
redeploys with it.
Development and homologation log personal data
Neither sends e-mail without Mailgun: the verification and reset tokens — and 2FA codes — are written
to the log instead, and EF Core logs parameter values outside Production. That is what lets them
run without a mail account, and it is why they are for made-up people: never load production’s data
into them. Development also serves Swagger and the developer exception page to anyone who reaches
its host — one more reason to keep it stopped while nobody uses it.
5. Deploy and verify
Push — develop for development, a release/x.y.z branch for homologation — and follow the build in
Jenkins or in the yggdrasil console. The first deploy of an on-demand environment ends stopped; start
it, then:
Both answer when Traefik routes both hosts. The start-up log should show the migrations applied and
then the API listening:
docker compose -p heimdall-api-development logs -f api
6. Day-to-day
Task
Command, on the VPS
Turn development or homologation on / off
scripts/ygg.sh env start development / scripts/ygg.sh env stop development
What runs in each environment
scripts/ygg.sh env status
Follow the logs
docker compose -p heimdall-api-<environment> logs -f api
Change the env file and redeploy
scripts/ygg.sh config heimdall-api
A shell in the container
docker compose -p heimdall-api-<environment> exec api sh
Redeploys come from Jenkins, not from docker compose up by hand: a hand-run up would skip
yggdrasil’s overlay and publish the API’s port on the host.
Production
Production follows the steps above with docker/production.env.example, and differs in what matters:
ASPNETCORE_ENVIRONMENT=Production — no Swagger, no developer exception page, no EF parameter
logging, and the API refuses to start without MAILGUN_API_KEY and MAILGUN_DOMAIN rather
than silently swallowing every e-mail it owes a user. It never needs
HEIMDALL_ALLOW_SENSITIVE_DIAGNOSTICS.
It is always on: scripts/ygg.sh env stop production refuses unless forced.
It is deployed only from a release pull request whose checks have all passed, and a failed deploy
rolls back to the previous production image and leaves the pull request open.
HEIMDALL_RUN_MIGRATIONS=false if it ever runs more than one replica: two containers applying
migrations at start-up will race each other. Apply them as their own deploy step with
python scripts/migrations.py.
For a PostgreSQL on another machine, put its private address in DB_HOST and require TLS through
DB_CONNECTION_EXTRA=SSL Mode=Require;Trust Server Certificate=true — dropping
Trust Server Certificate once the server presents a certificate the container can verify.
Troubleshooting
Symptom
Cause
Fix
set DB_USER in the env file at up
Compose interpolation found the variable empty
Fill it in — the :? markers in the Compose file fail loudly rather than starting a container that cannot connect
Container restarts; log ends at entrypoint: applying EF Core migrations...
The bundle cannot reach PostgreSQL
Re-run the two probes above; the address in the error names which side is wrong
Container restarts; log ends with Environment variable 'HEIMDALL_AUTH_TOKEN_SECRET…' is … bytes or … holds characters outside ASCII
A signing secret, current or previous, shorter than 32 bytes or with a character outside ASCII (NFR-31)
Replace it with openssl rand -base64 48; the message names the variable, never the value. Leave the previous one empty when not rotating
Migrations apply, then the API logs permission denied for table …
The API’s login was switched to a runtime role, but the grants were not applied, or a migration ran as someone other than the owner
Migrations, start-up guards, health checks, metrics, logging, rate limiting, data retention, and the integrations.
Migrations
The schema is managed with EF Core migrations, applied explicitly:
python scripts/migrations.py
The script asks which environment file to load (for the connection string), then offers list,
create (generate), and apply. It needs dotnet tool restore to have been run once, for the
pinned EF Core CLI tool. Authoring a migration is covered in Contributing
.
The API never migrates on startup, and refuses to start when migrations are pending. The seeder
checks GetPendingMigrationsAsync first and throws with the missing migration names:
The database is missing 2 migration(s): 20260811_AddAuditLog, 20260812_AddTwoFactorEmailCode.
Apply them with scripts/migrations.py before starting the API.
Automatic migration on startup is convenient exactly until two instances start at once, or until a
deployment silently applies a migration nobody reviewed. An explicit step makes schema change a
decision rather than a side effect.
Shared public ids — before ProtectIdentityPublicIds (#199)
ProtectIdentityPublicIds and ProtectEntityPublicIds make public_id immutable on person,
google_user, scope, application, application_credential, scope_permission, role and
pending_registration, refuse a new row whose public_id another of those tables holds or a hard
delete tombstoned, and refuse a new person or Google User whose id an audit entry names. They guard
future writes only and do not look at what is already stored. The API only ever issues fresh random
ids, so a duplicate can exist only if somebody wrote one by hand in SQL. To check for a hand-made
duplicate, run both queries before migrating; each should return no rows.
-- 1. Any public_id that rows of two or more guarded tables hold.
WITHids(tbl,public_id)AS(SELECT'person',public_idFROMheimdall.personUNIONALLSELECT'google_user',public_idFROMheimdall.google_userUNIONALLSELECT'scope',public_idFROMheimdall.scopeUNIONALLSELECT'application',public_idFROMheimdall.applicationUNIONALLSELECT'application_credential',public_idFROMheimdall.application_credentialUNIONALLSELECT'scope_permission',public_idFROMheimdall.scope_permissionUNIONALLSELECT'role',public_idFROMheimdall.roleUNIONALLSELECT'pending_registration',public_idFROMheimdall.pending_registration)SELECTpublic_id,string_agg(tbl,', 'ORDERBYtbl)ASheld_byFROMidsGROUPBYpublic_idHAVINGcount(*)>1;-- 2. Any person or Google User whose id audit entries named before the identity existed, so those
-- entries were about something else. The minute absorbs clock skew between the API and the database.
SELECT'person'AStbl,p.public_id,count(*)ASearlier_entriesFROMheimdall.personpJOINheimdall.audit_logaONa.target_id=p.public_idWHEREa.created_at<p.created_at-interval'1 minute'GROUPBYp.public_idUNIONALLSELECT'google_user',g.public_id,count(*)FROMheimdall.google_usergJOINheimdall.audit_logaONa.target_id=g.public_idWHEREa.created_at<g.created_at-interval'1 minute'GROUPBYg.public_id;
A row from either query is worth investigating before anything else: it is either a hand-made fix-up
or somebody preparing to make audit entries clearable. No rows proves less than it seems. Query 1
sees only rows that are there now. Query 2 trusts created_at, which an insert can set, so a
backdated plant passes it. Neither sees a plant that has already run its course — target cleared,
identity tombstoned and deleted — or a scope or other entity sitting on an id that erased_subject
records. Once migrated, hand-written SQL that rewrites a public_id — a support fix-up, or a
restore that remaps ids — fails with restrict_violation.
Database roles — TH-18
The supported setup runs the migrations as an owner role and the API as a role that owns nothing
(NFR-29). The owner holds the schema and everything in it, including the audit triggers and their
functions; the API’s role gets the row privileges it needs and nothing more. Two roles per
environment, which the operator creates and applies:
Role
Example
Owns
Used by
Owner
heimdall_owner
The database, the heimdall schema, every table, sequence and trigger function, and the migrations history
Migrations only: the container entrypoint through HEIMDALL_MIGRATIONS_CONNECTIONSTRING, or scripts/migrations.py run out of band
Runtime
heimdall_svc
Nothing
The API, through HEIMDALL_DATA_CONNECTIONSTRING (DB_USER in the env file)
The runtime role may connect, read and write rows, and nothing else: SELECT, INSERT, UPDATE
and DELETE on the heimdall tables, except that audit_log gets no DELETE; SELECT on the
migrations history, which the start-up seeder reads; no TRUNCATE anywhere and no CREATE on the
database or a schema. audit_log keeps UPDATE because the retention pass writes the rows it
clears whole; the triggers accept an update only when it clears the attribution or a person target
once due (NFR-21). That covers everything the API does at runtime, attempt_notice_budget and the
budget columns on person included.
Use one pair per environment, as the deployment guide does for logins: heimdall_owner and
heimdall_svc for production, heimdall_development_owner and heimdall_development_svc, and so on.
The SQL below is for production; substitute the names for another environment.
The grants
Run as a superuser (sudo -u postgres psql -d heimdall). Part 1 can run at any time, before the
first migration too; part 2 needs the audit_log table, so it runs after the first migration.
-- Part 1. Access: connect, use the schema, read and write rows, create nothing.
GRANTCONNECTONDATABASEheimdallTOheimdall_svc;REVOKECREATEONSCHEMApublicFROMPUBLIC;CREATESCHEMAIFNOTEXISTSheimdallAUTHORIZATIONheimdall_owner;GRANTUSAGEONSCHEMAheimdallTOheimdall_svc;-- What exists already.
GRANTSELECT,INSERT,UPDATE,DELETEONALLTABLESINSCHEMAheimdallTOheimdall_svc;GRANTUSAGE,SELECTONALLSEQUENCESINSCHEMAheimdallTOheimdall_svc;GRANTSELECTONALLTABLESINSCHEMApublicTOheimdall_svc;-- What later migrations create, as the owner, gets the same.
ALTERDEFAULTPRIVILEGESFORROLEheimdall_ownerINSCHEMAheimdallGRANTSELECT,INSERT,UPDATE,DELETEONTABLESTOheimdall_svc;ALTERDEFAULTPRIVILEGESFORROLEheimdall_ownerINSCHEMAheimdallGRANTUSAGE,SELECTONSEQUENCESTOheimdall_svc;ALTERDEFAULTPRIVILEGESFORROLEheimdall_ownerINSCHEMApublicGRANTSELECTONTABLESTOheimdall_svc;-- Part 2. The audit trail is never deleted from. Its triggers decide which updates it accepts.
REVOKEDELETEONheimdall.audit_logFROMheimdall_svc;
public holds nothing but the migrations history ("__EFMigrationsHistory", kept there by the
pinned Search Path=public), so the runtime role reads that table and nothing else of it. On
PostgreSQL 15 and later nobody but the database owner may create in public anyway, and the
REVOKE CREATE only restates it; CONNECT is granted to the runtime role by name, and whatever
else PUBLIC may do on the database is left as it was. Default privileges are recorded against the
owner, so they apply only to objects the owner creates: never run a migration as a superuser or as
the runtime role, or its tables will not be granted. When a migration adds a table the API must not
write freely — another append-only table, say — its revoke is added to part 2 of the grants here,
and the operator runs it after that migration, as part 2 is run for audit_log.
Checking the setup
As a superuser, connected to the environment’s database. Each query must return no rows, and every
column of the last one must be false:
-- 1. Nothing in this database, and no database, still belongs to the runtime login.
SELECTclassid::regclassAScatalog,objidFROMpg_shdependWHEREdeptype='o'ANDrefobjid='heimdall_svc'::regroleANDdbidIN(0,(SELECToidFROMpg_databaseWHEREdatname='heimdall'));-- 2. The database, the schema, every table, sequence and index in heimdall and public, and every
-- function in heimdall belong to the owner.
SELECT'database'ASkind,datnameASname,pg_get_userbyid(datdba)ASownerFROMpg_databaseWHEREdatname='heimdall'ANDdatdba<>'heimdall_owner'::regroleUNIONALLSELECT'schema',nspname,pg_get_userbyid(nspowner)FROMpg_namespaceWHEREnspname='heimdall'ANDnspowner<>'heimdall_owner'::regroleUNIONALLSELECT'relation',n.nspname||'.'||c.relname,pg_get_userbyid(c.relowner)FROMpg_classcJOINpg_namespacenONn.oid=c.relnamespaceWHEREn.nspnameIN('heimdall','public')ANDc.relowner<>'heimdall_owner'::regroleUNIONALLSELECT'function',n.nspname||'.'||p.proname,pg_get_userbyid(p.proowner)FROMpg_procpJOINpg_namespacenONn.oid=p.pronamespaceWHEREn.nspname='heimdall'ANDp.proowner<>'heimdall_owner'::regrole;-- 3. The runtime login's privileges and flags.
SELECThas_table_privilege(r.oid,'heimdall.audit_log','DELETE')ASaudit_delete,has_table_privilege(r.oid,'heimdall.person','TRUNCATE')AStruncate,has_schema_privilege(r.oid,'heimdall','CREATE')AScreate_in_heimdall,has_schema_privilege(r.oid,'public','CREATE')AScreate_in_public,has_database_privilege(r.oid,'heimdall','CREATE')AScreate_schema,pg_has_role(r.oid,'heimdall_owner','MEMBER')ASmember_of_owner,r.rolsuper,r.rolcreaterole,r.rolcreatedb,r.rolbypassrlsFROMpg_rolesrWHEREr.rolname='heimdall_svc';
Then, connected as heimdall_svc, this must fail with “must be owner of table audit_log”. It is
wrapped in a transaction that is rolled back, so it changes nothing even if it is run by mistake as
the owner — in which case it answers ALTER TABLE, and the session is the wrong one:
and give both a pg_hba.conf rule like the one in the
deployment guide
(heimdall_owner only needs it while the
container migrates; a deployment that migrates out of band gives it a local rule instead).
Run part 1 of the grants.
In the env file, set DB_USER/DB_PASSWORD to the runtime login and
HEIMDALL_MIGRATIONS_CONNECTIONSTRING to the owner’s whole connection string, with the same
Search Path as the API’s:
Host=host.docker.internal;Port=5432;Database=heimdall;Username=heimdall_owner;Password=…;Search Path=public.
Deploy. The entrypoint migrates as the owner, and the API starts as the runtime role, which part
1’s default privileges have already granted the new tables.
Run part 2 of the grants.
Run the checks above.
Switching an existing deployment over
A deployment created from the earlier guide has one login, heimdall_svc, owning the database and
everything in it. The switch moves the ownership to a new owner and grants the login back what the
API needs, in one transaction.
First deploy a release whose entrypoint reads HEIMDALL_MIGRATIONS_CONNECTIONSTRING — this
one or later. On an older image the variable is ignored.
Create the owner login: sudo -u postgres createuser --pwprompt heimdall_owner, and its
pg_hba.conf rule as above.
Make sure no deploy runs between steps 4 and 5: once the ownership has moved, a container that
migrates as heimdall_svc fails. Hold the environment’s deploys for those minutes, or set
HEIMDALL_RUN_MIGRATIONS=false in its env file until step 5.
As a superuser, connected to the environment’s database (sudo -u postgres psql -d heimdall):
BEGIN;SETLOCALlock_timeout='5s';ALTERDATABASEheimdallOWNERTOheimdall_owner;REASSIGNOWNEDBYheimdall_svcTOheimdall_owner;-- Part 1 and part 2 of the grants, as above.
COMMIT;
REASSIGN OWNED moves everything the login owns in this database — the schema, the tables, their
sequences, the trigger functions and the migrations history — and any database the login owns.
It takes a lock on every table it changes and holds them until COMMIT, so requests wait for
that moment; with lock_timeout the transaction gives up after five seconds rather than queue
behind a long one, and rolls back whole. Run it again at a quieter moment if it does. Because it
is one transaction, the API sees either the old ownership or the new one with its grants.
Add HEIMDALL_MIGRATIONS_CONNECTIONSTRING (the owner’s) to the env file, put
HEIMDALL_RUN_MIGRATIONS back if step 3 changed it, and redeploy. Leave DB_USER as it is.
Run the checks above.
To undo it, reverse the ownership in one transaction, as a superuser connected to the environment’s
database, with the same lock_timeout and the same retry as step 4:
Then remove HEIMDALL_MIGRATIONS_CONNECTIONSTRING from the env file. The grants left behind then name
the runtime login on objects it owns again, which changes nothing. On PostgreSQL 14 or earlier, where
PUBLIC could create in public by default, GRANT CREATE ON SCHEMA public TO PUBLIC restores
that too.
Where the owner’s password lives. With HEIMDALL_MIGRATIONS_CONNECTIONSTRING set, the owner’s
connection string is in the env file and the container’s configuration, beside the other secrets
there. The entrypoint hands it to the migration bundle through the bundle’s own environment, never
its command line, and removes it before the API starts, so the API process never holds it. A
deployment that wants it off the container altogether sets HEIMDALL_RUN_MIGRATIONS=false and
migrates out of band with scripts/migrations.py, from an environment file that names the owner.
Start-up sequence
flowchart TD
A[Configure Serilog] --> B[Load environment configuration]
B --> C[Configure Web API + custom model-state response]
C --> D0{Diagnostics on and<br/>a public host?}
D0 -->|"Yes, and HEIMDALL_ALLOW_<br/>SENSITIVE_DIAGNOSTICS ≠ true"| D0X["Turn them off —<br/>one warning"]
D0 -->|No, or allowed| D[Register dependencies]
D0X --> D
D --> D1{Production and<br/>Mailgun unconfigured?}
D1 -->|Yes| DX["Refuse to start —<br/>tokens would be logged in plaintext"]
D1 -->|No| E[Configure security + JWT + rate limiting]
E --> E1{Token secret set?}
E1 -->|No| EX["Refuse to start —<br/>every request would fail with IDX10703"]
E1 -->|Yes| F[Build app + middlewares + Swagger]
F --> G[Start services → DatabaseSeeder]
G --> G1{Migrations pending?}
G1 -->|Yes| GX[Refuse to start — names them]
G1 -->|No| H[Ensure the three Role rows]
H --> I{Any system admin?}
I -->|No| J["Create one from<br/>HEIMDALL_MASTER_USER_*<br/>— refuses to start if unset"]
I -->|Yes| K[Ready]
J --> K
The seeder is idempotent, so it runs on every start-up: it ensures every Roles member exists as a
row and that at least one system administrator exists to sign in as. It never applies migrations.
Each of those refusals replaces a failure that would otherwise appear far from its cause — an opaque
IDX10703 on every authenticated request, a plaintext token in a production log, or a runtime error
against a column that does not exist yet. Each is an unhandled exception at start-up: its message is
the last thing the container logs, and the process exits non-zero, so the container restarts and the
health check never turns green.
Diagnostics on a public host — NFR-28
Every environment but Production asks for EF Core’s sensitive data logging and detailed errors,
which put query parameter values in the log, and Development and Local also ask for the developer
exception page, which answers a failed request with its details. That suits a developer’s machine.
On an instance that serves a public host they are turned off, all three together, and the instance
starts as usual with one warning:
This instance serves a public host (PUBLIC_HOST=heimdall-api-dev.example.com, UI_HOST=heimdall-dev.example.com),
so EF Core sensitive data logging, detailed errors and the developer exception page are off in the
"Development" environment. Set HEIMDALL_ALLOW_SENSITIVE_DIAGNOSTICS=true to keep them on where the
instance holds made-up people only
One decision, taken once at start-up, sets both EF Core’s options and the exception page, so neither
can stay on while the other is off. It covers the exception page the host adds by itself in
Development too: where the decision turns the page off, a failed request gets the API’s usual 500
body, “Internal server error, please try again later”.
A host is public when any of these names anything but localhost, a name under .localhost, or a
loopback address: PUBLIC_HOST or UI_HOST (the names Traefik routes to the instance, which
docker-compose.yml passes into the container), the host of HEIMDALL_EMAIL_VERIFICATION_URL or
HEIMDALL_PASSWORD_RESET_URL, or any origin in HEIMDALL_CORS_ALLOWED_ORIGINS. A URL or an origin
counts by its host, and one written without a scheme is read as a host name; a template placeholder
([[...]]) does not count. With none of them public, nothing changes: a developer machine and the
functional suite keep their diagnostics.
HEIMDALL_ALLOW_SENSITIVE_DIAGNOSTICS=true keeps them on with a public host, and logs a warning at
every start. Set it only where the environment holds no real person’s data. The templates leave it
empty.
Health checks — UC-30
Endpoint
Who
What it does
GET /api/healthcheck
Anonymous
Liveness. Confirms the process is up and responding. Reads no database.
GET /api/healthcheck/detailed
System Admin
Reports the status of each verified dependency.
Both also answer at the root, /healthcheck and /healthcheck/detailed, which is what the container
health check and yggdrasil’s status page call. Use the /api/ addresses through Traefik: on the web
UI’s host, a path outside /api/ is served by heimdall-ui, not the API.
The aggregate status is Healthy only when every entry is healthy (FR-HC-05).
The liveness endpoint is anonymous because a load balancer cannot authenticate; the detailed one is
System-Admin-only because “which of my dependencies is down” is operational intelligence.
Each dependency is one IServiceHealthCheck registration. DatabaseHealthCheck issues a trivial
read through the repository abstraction and catches everything — an unreachable database throws on
execution, and the check reports unhealthy rather than propagating, so the aggregate can still be
reported (AF-30c). Adding a verification is one more registration; the detailed handler resolves them
all as IEnumerable<IServiceHealthCheck>.
Metrics — Prometheus
The API publishes its metrics in Prometheus text format at GET /metrics on port 9464 — and on
that port only. They are collected with OpenTelemetry: request rate, duration and status per route
(http.server.*), Kestrel connections, outbound HttpClient calls (Mailgun), the .NET runtime (GC,
heap, thread pool, exceptions), EF Core, and the Npgsql connection pool. Every series carries
service_name="heimdall-api".
Variable
Default
Effect
HEIMDALL_METRICS_PORT
9464
The port /metrics answers on. Blank means the default; 0 switches metrics off entirely — no exporter is registered and nothing is collected.
The endpoint is recognised by the port the connection arrived on, never by the Host header.
On the VPS (development, homologation, production) the API runs behind Traefik, which forwards the
client’s own Host header — a check on it would be a check on a value any caller chooses. The local
port of the socket is the one thing a caller cannot pick: Traefik reaches the container on 8080,
Prometheus reaches it directly on 9464 over a private Docker network, and a request for /metrics
on 8080 falls through to the API, which has no such route (401 without a token, 404 with one).
That guarantee holds only while 9464 stays private, so:
Never publish 9464.docker-compose.yml maps only the API’s port; the image listens on both
(ASPNETCORE_HTTP_PORTS=8080;9464). Put Prometheus on a network it shares with the api service
and scrape it there — under yggdrasil, heimdall-api.<environment>:9464 on the telemetry
network.
Never route Traefik to 9464. With two ports exposed, give Traefik the service port explicitly
(traefik.http.services.<name>.loadbalancer.server.port=8080) rather than letting it pick.
Moving the port means moving both. A HEIMDALL_METRICS_PORT other than 9464 must also be
added to ASPNETCORE_HTTP_PORTS, or Kestrel never listens where the endpoint waits.
The endpoint is mounted first in the pipeline, as a terminal branch: a scrape never meets CORS,
rate limiting, authentication or authorization, and — not being a controller action — it is not in
the OpenAPI document. The rest of the API is still served on 9464 too; only /metrics is special
there, which is harmless for a port nothing outside the private network can reach.
Running from source (dotnet run), the API listens on 5177 only, so /metrics is not served; add
the port to the URLs it listens on (--urls "http://localhost:5177;http://localhost:9464") to try
it.
Logging
Serilog, configured before anything else so that even configuration failures are recorded:
Console — JSON formatted.
Rolling files — <HEIMDALL_LOG_DIRECTORY>/<yyyy/MM>/log-<date>.json, a new file per day inside
a folder per month. HEIMDALL_LOG_DIRECTORY defaults to logs.
EF Core diagnostics are enabled outside Production only.SensitiveDataLogging and
DetailedErrors print parameter and column values — password hashes, salts, email addresses — so
they stay off where those logs are retained.
Retention and redaction — NFR-22
Log files are written flat under the log directory, one per day, and removed once older than
HEIMDALL_RETENTION_LOG_DAYS (365 by default). They used to be nested in year/month directories via
Serilog’s Map sink, which created a new sink per month — a retention limit bounds files within a
sink, so it bounded each month’s directory and never removed a month. The flat sink is the shape
where the limit applies.
No log statement writes an email address. Where a line needs to refer to one so an operator can
correlate, it writes a stable non-reversible reference ({EmailRef}) instead. LogRedactionTests
scans the source and fails if an {Email} placeholder reappears anywhere.
The reference is pseudonymisation, not anonymisation: somebody who already suspects an address can
hash it and look for it. What it defeats is reading addresses out of the logs in bulk.
Rate limiting and lockout
Brute force is bounded in two independent places, because each covers what the other misses.
Per caller — a fixed-window limiter of 10 requests per minute, partitioned by client IP
(an IPv6 caller by its /56, since a subscriber is routinely handed a whole /56 and counting per
/64 would give them 256 partitions), applied to
AuthController’s anonymous, credential-checking endpoints: login, password recovery, password
reset, email verification, Google sign-in, and second-factor verification and resend. Rejections
answer 429 with a Retry-After header (whole seconds) and the usual error envelope. None of these
requires a bearer token, so nothing else bounds how fast one caller can hit them, and each login attempt
costs a full Argon2id verification (600 MB / 16 threads by the hashing library’s defaults) — so memory
exhaustion is realistic without it. The authenticated
endpoints that check a password or a second factor — 2FA enable, confirm, disable and recovery-code
regeneration, the erasure request, and the person update (PUT /api/persons/{id}, which checks the
password when a person changes their own email) — share the same limit: a bearer token is not the
password, and without it whoever held one could test passwords or six-digit codes against them
unthrottled. The person update is limited whether or not it checks a password, administrators'
updates included. The verification-link resend (POST /api/auth/resend-verification) shares it too,
because every call mails the account’s address.
Per administrator — account creation. The three endpoints that create a person with a password —
POST /api/persons, POST /api/scopes/{id}/persons and POST /api/scopes/{id}/owners — each derive
an Argon2id hash, and need nothing but an administrator’s token. They are limited to 30 requests a
minute per acting person and 30 a minute per address, both at once; a rejection is the same 429
with Retry-After. Only administrators’ access tokens count against the address, so other requests
— without a token, or with a User’s, a two-factor challenge or a subject-rights token — cannot use
up an address administrators share. Thirty a minute is more than anyone creates by
hand, and a bulk import should pace itself to it. For traffic from many addresses at once, see
Bounding a distributed flood at the edge
.
Per account — a failure budget, which is what an attacker spread across many source addresses
defeats the limiter with:
Target
Budget
On exhaustion
Password (UC-11)
10 consecutive failures
The account is locked for 15 minutes. PERSON.failed_login_attempts counts, PERSON.locked_out_until holds the window; a successful login clears both.
2FA email code (UC-37, UC-38)
5 wrong guesses per issued code
The code is retired. TWO_FACTOR_EMAIL_CODE.failed_attempts counts; guessing again costs a fresh login, which is itself limited and mails the account holder a code they did not ask for.
2FA app code
Single use
TWO_FACTOR_AUTH.last_totp_time_step_used records the accepted time step, so an observed code cannot be replayed within the ±1-step verification window (RFC 6238 §5.2).
2FA challenge (UC-38)
Single redemption
TWO_FACTOR_AUTH.challenge_id holds the one outstanding challenge, which the challenge token names. Redeeming it clears it and a newer login replaces it, so a challenge token is refused once spent (FR-2F-10).
2FA challenge guesses (UC-38)
5 guesses with an app code or a recovery code per challenge
The challenge is cleared, so its token is refused and guessing again costs a fresh login (FR-2F-17). TWO_FACTOR_AUTH.challenge_attempts counts; a new login resets it. An email-only configuration is bounded by its code’s budget instead.
Second factor at sign-in (UC-38)
10 failures per account, across every challenge
Second-factor verification is locked for 15 minutes, twice as long at each repeat, up to 24 hours (FR-AU-12). PERSON.failed_second_factor_attempts counts, PERSON.second_factor_locked_out_until holds the lock and PERSON.second_factor_lockouts the repeats. A correct password resets none of them; a successful second factor resets all three. While locked, every code is refused with the ordinary “factor invalid” answer.
Re-authentication under a token (UC-08, UC-36, UC-37, UC-39, UC-40, UC-42)
5 failures within 15 minutes per account
These checks are paused for 15 minutes (FR-AU-13). PERSON.failed_reauthentication_attempts counts within the window that PERSON.reauthentication_window_started_at opened, and PERSON.reauthentication_paused_until holds the pause; a success clears them. Wrong password, wrong code and pause all answer 401 “The password or code is incorrect.”. Never charged to the login lockout or the second-factor budget, so a stolen token cannot lock the owner out of signing in.
Every budget, and every single-use item — recovery codes, reset and verification tokens, the
challenge, the TOTP step — is charged or spent by one conditional UPDATE whose WHERE clause
is the rule itself (“unused”, “under the cap”, “still outstanding”), and before the comparison it
guards rather than after it. A burst of parallel requests therefore cannot outrun a budget or spend
one code twice: the database lets exactly as many through as the rule allows. A login attempt is
counted in failed_login_attempts while it is in flight and cleared if the password turns out to
be right.
A lockout is a window rather than a latch an administrator clears: reaching the threshold needs
nothing but wrong guesses, so a permanent lock would hand any anonymous caller a denial of service
against any address they know. It answers with UC-11’s ordinary InvalidCredentials, and spends the
same Argon2id work a real check would, so it is not observable — by message or by timing — to a
caller who does not already know the password.
Releasing a second-factor lock or a re-authentication pause
Both run out on their own: a second-factor lock after at most 24 hours, a re-authentication pause
after 15 minutes. Heimdall has no endpoint to release either early, on purpose. For a support case
that cannot wait, an operator can clear them in the database.
Verify the person first
A second-factor lock means someone holding the account’s password has been guessing the second
factor. Before releasing it, confirm through a channel you already trust that you are dealing with the
account holder, and have them change their password first (UC-12/UC-13). If the password is not
changed, releasing the lock only gives the guesser a fresh budget. A re-authentication pause likewise
means someone holding a token was guessing, so make sure the person’s sessions are their own. The
write bypasses the API, so the audit trail does not record it: log the release in your support
ticket.
Replace the id with the person’s public_id (the id the API returns for them):
-- Second-factor lock at sign-in (FR-AU-12): the count, the lock, and the repeat count that sets
-- how long the next lock lasts.
UPDATEheimdall.personSETfailed_second_factor_attempts=0,second_factor_locked_out_until=NULL,second_factor_lockouts=0WHEREpublic_id='00000000-0000-0000-0000-000000000000';-- Re-authentication pause (FR-AU-13): the count, the window and the pause.
UPDATEheimdall.personSETfailed_reauthentication_attempts=0,reauthentication_window_started_at=NULL,reauthentication_paused_until=NULLWHEREpublic_id='00000000-0000-0000-0000-000000000000';
Each statement should report UPDATE 1. Run only the one you need. The person table’s triggers
guard public_id alone, so these columns are writable by the API’s own role.
Not a substitute for a gateway
The limiter’s partition key is the connection’s remote IP — the real caller’s only when
HEIMDALL_TRUSTED_PROXIES names the proxy in front of the API
(Client addresses behind a proxy
). Without it, every caller
shares the proxy’s partition. This is a per-instance, defence-in-depth
throttle — not a replacement for a WAF or an API gateway’s own rate limiting in front of a real
deployment. The per-account budgets above are in the database, so they hold across instances.
Bounding a distributed flood at the edge
Every password check passes through PasswordHashGate: four Argon2id derivations at a time per
instance, about seven hundred a minute, the rest refused with 503 after a ten-second wait. The
per-caller limit admits ten a minute, so about seventy unrelated networks sending login attempts with
made-up addresses keep the gate full — and while they do, real sign-ins wait and fail. The API
cannot stop this itself (Threat Model Document, TH-03): those callers look like real ones, and a
global budget in the API would only let the attacker spend it and deny everybody at a lower cost.
The bound belongs at the edge, where the request costs nothing to refuse.
On the VPS that edge is Traefik (v3.6). Add these labels to the overlay that declares Heimdall’s
routers, yggdrasil’s stacks/heimdall-api.proxy.yml, beside its heimdall-api-${YGG_ENVIRONMENT} and
heimdall-api-${YGG_ENVIRONMENT}-same-origin routers. Every name carries ${YGG_ENVIRONMENT}, because
the environments share one Traefik.
labels:# The credential routes, on both hosts: the API's own and the web UI's same-origin /api/.# (?i): Traefik matches paths case-sensitively and ASP.NET does not, so /api/AUTH/login must not# slip past on the main router.- traefik.http.routers.heimdall-api-${YGG_ENVIRONMENT}-auth.rule=(Host(`${PUBLIC_HOST}`) || Host(`${UI_HOST}`)) && PathRegexp(`(?i)^/api/auth/`)- traefik.http.routers.heimdall-api-${YGG_ENVIRONMENT}-auth.entrypoints=websecure# The chain is replaced, not extended, so keep the headers every other router has.- traefik.http.routers.heimdall-api-${YGG_ENVIRONMENT}-auth.middlewares=secure-headers@file,heimdall-api-${YGG_ENVIRONMENT}-auth-limit- traefik.http.routers.heimdall-api-${YGG_ENVIRONMENT}-auth.service=heimdall-api-${YGG_ENVIRONMENT}# Per source: 10 a minute with a burst of 10, an IPv6 caller counted by its /56, as the API does.- traefik.http.middlewares.heimdall-api-${YGG_ENVIRONMENT}-auth-limit.ratelimit.average=10- traefik.http.middlewares.heimdall-api-${YGG_ENVIRONMENT}-auth-limit.ratelimit.period=1m- traefik.http.middlewares.heimdall-api-${YGG_ENVIRONMENT}-auth-limit.ratelimit.burst=10- traefik.http.middlewares.heimdall-api-${YGG_ENVIRONMENT}-auth-limit.ratelimit.sourcecriterion.ipstrategy.ipv6subnet=56# A client system that signs its users in through Heimdall (Fortuna) reaches it from one source,# so under the limit above all of its users would share one bucket of 10 a minute. It gets a# router and a bucket of its own, matched on that source. Inside the API its users are counted# apart only when it names each one's address with HEIMDALL_TRUSTED_CALLER_KEYS; until it does,# they share one bucket there too.- traefik.http.routers.heimdall-api-${YGG_ENVIRONMENT}-auth-fortuna.rule=Host(`${PUBLIC_HOST}`) && PathRegexp(`(?i)^/api/auth/`) && ClientIP(`<fortuna-source>`)- traefik.http.routers.heimdall-api-${YGG_ENVIRONMENT}-auth-fortuna.priority=1000- traefik.http.routers.heimdall-api-${YGG_ENVIRONMENT}-auth-fortuna.entrypoints=websecure- traefik.http.routers.heimdall-api-${YGG_ENVIRONMENT}-auth-fortuna.middlewares=secure-headers@file,heimdall-api-${YGG_ENVIRONMENT}-auth-fortuna-limit- traefik.http.routers.heimdall-api-${YGG_ENVIRONMENT}-auth-fortuna.service=heimdall-api-${YGG_ENVIRONMENT}- traefik.http.middlewares.heimdall-api-${YGG_ENVIRONMENT}-auth-fortuna-limit.ratelimit.average=600- traefik.http.middlewares.heimdall-api-${YGG_ENVIRONMENT}-auth-fortuna-limit.ratelimit.period=1m- traefik.http.middlewares.heimdall-api-${YGG_ENVIRONMENT}-auth-fortuna-limit.ratelimit.burst=100
TLS is set on the websecure entrypoint, so the routers need nothing more. No priority is needed on
the first router: Traefik ranks the longer rule first, and it is longer than the main router’s.
The relay router takes an explicit priority so it always wins over the first.
Give each consumer its own bucket. One router and one middleware per client system that relays
its users, each matched on that system’s source and sized for its users; never one bucket shared
by every consumer, and never the public bucket for a relaying system. Replace <fortuna-source>
with Fortuna’s address as Traefik sees it: the ClientHost of its requests in Traefik’s access
log. Size average for the sign-ins Fortuna’s users make in a minute at their busiest.
Only a source that is Fortuna’s alone. If Fortuna’s ClientHost is a Docker bridge gateway or
a NAT router’s address — which it can be when it reaches Traefik through the public host, and which
other traffic then shows too (hairpinned clients, or IPv6 clients that Docker’s port proxy
forwards) — do not use ClientIP for the exemption: everything behind that address would land in
the relay’s large bucket. Route Fortuna by a source only it has, such as a private network to
Traefik or a host name of its own.
Check the addresses first. Before relying on any of this, confirm in Traefik’s access log that
ClientHost shows real client addresses, IPv6 ones included, rather than a proxy’s or Docker’s. A
limiter keyed on the wrong address puts everybody in one bucket.
This is the same per-source limit the API applies, moved in front of it: a refused request then
never reaches an instance, and one Traefik counts across every replica behind it, where each
instance’s own limiter counts alone.
It does not, by itself, stop a flood from enough unrelated networks: that is the definition of one.
What does is visibility and a decision: the hash gate’s 503s and the
CREDENTIAL_VERIFICATION_SHEDDING security signal show it starting; the source networks are in
Traefik’s access log; and a flood that persists is for the host firewall or an upstream filtering
service, which can drop whole networks before they reach Traefik. Raising the replica count raises
the gate’s total in proportion while it lasts.
Do not add a global limit (one with sourcecriterion.requesthost=true) to the credential
routes. It turns the flood into a denial of every caller at a lower cost, which is exactly what the
API declines to do.
Bounds on the mail one address receives
Heimdall sends mail from the operator’s Mailgun domain, so mail an attacker can trigger costs the
operator quota and sender reputation as well as annoying its recipient. A per-caller limit does not
bound what one recipient receives from many callers. The sends a caller can repeat are bounded as
follows; mail an administrator causes — the links of UC-51 and UC-52, a new account’s verification
link, the notice when they change someone’s address — is trusted to administrators, as their other
powers over the accounts they manage are:
Mail
Bound
A person’s own: verification link asked for again (UC-15), two-factor setup code (UC-36, email method), notice to the previous address after an own email change (UC-08)
One budget per person across all three (FR-PE-16), so an own email change counts toward it and the new address’s verification link, asked for straight after, waits out the cooldown: one per HEIMDALL_ADMIN_EMAIL_COOLDOWN_IN_SECONDS (five minutes) and five in each day-long window, which starts at the first send, reserved by one conditional write before anything is sent. 429 “An email of this kind was sent to you recently. Try again in a few minutes.” inside the cooldown, 429 “You have reached the limit for account emails. Try again later.” once the window’s five are spent; both carry Retry-After, the seconds until a send would be accepted (for the limit, the later of the window’s end and the cooldown’s)
Verification link or password reset an administrator asks for (UC-51, UC-52)
One of each kind per person per cooldown, counting every link of that kind issued for them; not charged to the person’s own budget, so an administrator can still help someone who has spent theirs
Password recovery requested anonymously (UC-12)
One reset link per person per cooldown — never longer than the link’s lifetime — whoever asks; further requests send nothing and are answered as before
Two-factor sign-in code (UC-38, UC-46)
One per sign-in, plus at most three reissues per challenge (FR-2F-16), sent to the registered address; a person’s own email change is refused while the email method is on (FR-PE-18)
Self-registration confirmation (UC-47)
Three live pending registrations per address in a scope; further requests store and mail nothing
“Someone tried to register your address” (UC-47)
Three a day per address, across every scope; further notices are not sent. Counted under a SHA-256 digest of the address in attempt_notice_budget, purged with the single-use tokens
The self-registration and recovery bounds are silent by design: the caller gets the same answer
whatever happened, so the bound cannot be used to learn whether an address is registered.
A client system calling for its users — HEIMDALL_TRUSTED_CALLER_KEYS
A client system such as Fortuna’s API signs its users in on their behalf, so Heimdall sees one
address for all of them — and the 10-a-minute limit on the anonymous endpoints is then shared by
every user it serves. It can instead tell Heimdall whose request this is. Heimdall counts the request
under that user’s address when it carries a key from HEIMDALL_TRUSTED_CALLER_KEYS:
Header
Value
X-Heimdall-Caller-Key
One of the configured keys
X-Heimdall-Client-Address
The end user’s own IP address, IPv4 or IPv6 (an IPv6 user is counted by its /56, as everywhere else)
HEIMDALL_TRUSTED_CALLER_KEYS is a comma-separated list. Entries are trimmed and empty ones
ignored. Each key must be at least 32 characters — they are secrets, so a shorter one stops
start-up with an error naming its position, not its value. Generate them randomly, for example
openssl rand -base64 48. Unset, no caller is trusted and the headers are ignored.
The key is compared in constant time against every configured key. A wrong or missing key, or an
address that does not parse, is not an error: the request is counted under the connection
address as before. The headers can only choose the bucket; they authenticate nobody and open no
endpoint.
Rotating a key needs no downtime: list the new key beside the old
(HEIMDALL_TRUSTED_CALLER_KEYS=<old>,<new>), restart Heimdall, move the client system to the new
key, then remove the old one and restart again.
Send the headers only from the client system’s server, over TLS, never from a browser or a mobile
app, and keep the key in that system’s secret store.
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.
A leaked key lets its holder pick their own bucket, which only spreads credential guesses over
invented addresses; the per-account budgets still count them. That includes the six authenticated
endpoints the same limiter guards — 2fa/enable, 2fa/confirm, 2fa/disable,
2fa/recovery-codes/regenerate, erasure-request and PUT /api/persons/{id} — which share the
re-authentication budget above, whatever addresses the guesses come from (Threat Model Document,
TH-32 and TH-36). The seventh, resend-verification, is bounded per person by the account-email
budget below, whatever bucket it is counted in.
Start-up logs how many keys are configured, never the keys, and neither header’s value is logged.
Database connection encryption — NFR-25
The API warns at start-up when HEIMDALL_DATA_CONNECTIONSTRING does not require TLS. Npgsql’s
default SSL Mode is Prefer, which silently falls back to an unencrypted connection when the
server offers none — so a deployment can be sending every credential and address in clear text while
looking correctly configured. Set SSL Mode=Require, VerifyCA or VerifyFull.
Variable
Default
Meaning
HEIMDALL_DATA_REQUIRE_TLS
false
true refuses start-up instead of warning
Turn it on once TLS is confirmed. It is opt-in rather than the default so that enabling it is a
decision somebody makes, not one that happens to them during an upgrade — but a deployment that has
verified TLS and left this off is one warning away from not noticing when the setting is lost.
Data retention — NFR-19
Personal data is kept only as long as its purpose requires (GDPR Art. 5(1)(e), LGPD Art. 15). The
schedule — every table holding personal data, its period, and the reason for it — is the
Data Retention Schedule Document
, and it is the
source of truth these settings are configured against.
The schedule marks which periods are enforced and which are not yet, so it never describes a
guarantee the code does not make. Read it there rather than inferring enforcement from the presence
of a setting here.
Single-use tokens are purged on a schedule. A hosted service dispatches a purge hourly by
default, removing password reset tokens, email verification tokens, and two-factor email codes that
are past their expiry by the grace period — and, with them, unconfirmed pending registrations, spent
application credentials, and self-registration attempt-notice budgets whose day has ended. Each run removes at most a bounded batch per table,
oldest first, so a backlog drains over several runs rather than in one long transaction — a purge is
delete pressure on the same tables the login and recovery paths write to, and the write path
degrades with table size (SRD §6.3.2).
The grace period is not zero, deliberately. UC-13 answers a presented token with TokenInvalid,
TokenExpired or TokenAlreadyUsed, and purging the moment a token expires collapses the last two
into the first — telling someone following a stale link that their token never existed. A live token
is never at risk: the cutoff is strictly in the past.
Every instance runs its own purge and none coordinates with the others (NFR-06). The delete re-reads
its batch, so an instance whose rows another already removed simply deletes fewer than it selected.
Each run writes one audit entry (NFR-09) as an anonymous write — the evidence that the schedule was
enforced, and when. A run that removes nothing is a success; most runs find nothing to do.
Logically deleted identities are anonymised on a schedule. A soft delete is a restriction of
processing, not an erasure — the name, the address and the credential material all stay in the row.
A second pass gives it a terminal state: once the retention window has elapsed, the record is
anonymised in place, and the person’s single-use tokens and two-factor configuration go with it.
The window depends on why the record was deleted. Where the data subject asked (UC-42), it is 30
days — GDPR Art. 12(3)’s outer limit rather than a chosen period, and refused if configured higher.
Where an administrator deleted it, it is 90 days, a reversal window. Records deleted before this
existed carry no reason and take the longer one.
A subject’s request stores its own deadline when it is made, and that stored value wins over the
configured window: a later change to the setting must not move an obligation already owed. Where
NFR-12 blocked a request — the subject was a scope’s last owner — the pass retries it once ownership
has been transferred, dating the suspension from the request rather than from the moment the block
cleared, so a long-blocked request is due immediately rather than handed a fresh deadline.
GET /api/auth/erasure-requests is the queue that surfaces those before they are late.
Anonymisation overwrites rather than removes, because NFR-07 requires every foreign key to keep
resolving and the audit trail, the scope join rows and an owner’s applications all point at the
record. Hard deletion (UC-10) remains available for callers that want the row gone.
The audit trail stops naming people on its own schedule. A third pass clears the actor
attribution from entries past their attribution period, and immediately from entries naming an
identity that has been anonymised. On the same two grounds it clears an entry’s target when that
target is a person or Google User — a self-service command’s own account, or the person an
administrator acted on. Targets naming anything else are kept. The entry itself is never removed and
what it says happened is never altered — what happened, when and how often all survive.
The rule is enforced by a database trigger rather than by the pass, because this is the one table
whose immutability is a security control. It permits the attribution, and a target naming a person,
to be set to null and nothing else: no deletion, no truncation, no change to any other column, and no
reassignment to a different value. A thirty-day floor stops somebody clearing their own attribution,
or the person they acted on, for something they did this week; it is an anti-tamper backstop, not
the retention period, which is eighteen months. A deployment upgrading to AllowClearingAuditTarget
has its older person targets cleared over the next runs, one batch at a time.
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
A deployment that sets none of these still gets the published periods — an unset variable must not
mean “keep forever”. 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: a typo in a retention setting should not take down
the identity provider every client system authenticates against. Falling back silently would be
worse than either, since an operator would believe a setting was applied.
Each bound is enforced for a reason. A grace period beyond ten years is a stray unit rather than a
policy. An interval below a minute turns a 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 a hosted service stops the host by default.
Security signal detection — NFR-26
The API records strong signals and, until now, nothing read them. SecurityMonitoringService reads
them on a schedule and writes what exceeds its thresholds to the log at warning level.
Every line carries the marker SECURITY_SIGNAL and a stable Kind. Match on those, not on the
prose — the wording is written for whoever reads the line afterwards and will change.
Kind
Raised when
REPEATED_REFUSALS
One identity exceeds the refusal threshold within the window
LOCKOUT_SPIKE
More accounts are locked out at once than the threshold
CREDENTIAL_VERIFICATION_SHEDDING
The hash gate refuses more often than the threshold (TH-03)
Variable
Default
Accepted range
Meaning
HEIMDALL_MONITORING_ENABLED
true
true / false
Set false to stop detecting
HEIMDALL_MONITORING_WINDOW_MINUTES
15
1 to 1440
How far back each signal looks, and how often it runs
HEIMDALL_MONITORING_REFUSAL_THRESHOLD
20
any positive integer
Refusals by one identity before alerting
HEIMDALL_MONITORING_LOCKOUT_THRESHOLD
10
any positive integer
Accounts locked out at once before alerting
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.
Signals repeat on every run while the condition persists. That is deliberate — 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. Deduplicate in the alerting rule if you
want quiet, not here.
A signal is not a breach. It is a reason to look. What follows one is the
Incident Response Document
, which carries the
notification deadlines and the register.
What a signal actually looks like
A warning-level log line, written to stdout and to the files in the log directory:
SECURITY_SIGNAL REPEATED_REFUSALS: One identity had 23 writes refused in the last
00:15:00. Sustained refusals are what somebody probing for what they may do looks
like. (count 23, actor 3f2a91c4-7d55-4e1a-9b2f-08c7d1e6a934)
Detection that tells nobody is not detection: both breach notification clocks run from awareness,
so a signal sitting in a log file has not been detected. This job is what closes that. 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.
python3 scripts/security_signals.py --stdout # see what it would send, change nothingpython3 scripts/security_signals.py # report new signals by e-mailpython3 scripts/security_signals.py --stdout --all # ignore the watermark, print everything
Variable
Required
Meaning
HEIMDALL_LOG_DIRECTORY
If not ./logs
Where the API writes log-*.json — the same variable the API reads
HEIMDALL_SIGNAL_ALERT_TO
Yes, unless --stdout
Where alerts go
HEIMDALL_SIGNAL_ALERT_FROM
No
Sender, default heimdall-signals@$MAILGUN_DOMAIN
HEIMDALL_SIGNAL_STATE_FILE
No
The watermark, default <log directory>/.security-signals-state
MAILGUN_API_KEY, MAILGUN_DOMAIN
Yes, unless --stdout
The API’s own credentials, unchanged
MAILGUN_API_BASE_URL
No
Default https://api.mailgun.net. Set the EU endpoint here if the account is in the EU region
Scheduling it. Every fifteen minutes matches the monitoring window’s default, so nothing waits
longer to be reported than it waited to be detected:
The MAILTO is not decoration. The job exits non-zero when it cannot deliver, and prints the
signals it was holding to stderr — so a broken collector announces itself instead of looking like a
quiet week. Without a MAILTO, the failure of the thing that catches failures is itself uncaught.
--quiet keeps the ordinary no-signal run silent, so the only mail cron sends is a real problem.
It needs to read the log files.docker-compose.yml keeps them in a named volume
(logs:/app/logs), which the host cannot read directly — so run the job against that volume rather
than against a host path:
The alternative is to change the volume to a bind mount and run the job on the host, which is
simpler to schedule and puts the logs somewhere a backup can reach them. Either works; what does not
work is scheduling it on the host and pointing it at a path the container owns.
The state file is written next to the logs by default, so it survives in the volume. Point
HEIMDALL_SIGNAL_STATE_FILE somewhere writable if the log directory is read-only — and somewhere
persistent, because losing it means the next run re-reports the history.
Exit codes:0 ran and delivered whatever it found · 1 signals were found and could not be
delivered · 2 the collection itself could not run.
Why it keeps a watermark
Signals repeat on every monitoring tick while a condition persists — that is deliberate in the
service, and it would be intolerable in an inbox. The job records how far it has read, so each
signal is reported once. An alert that resends its entire history is one that gets filtered into a
folder within a week, and a filtered alert is the same non-detection the job exists to close.
Two details follow from that, and both are the conservative direction:
The watermark advances only after delivery succeeds. A Mailgun outage delays signals rather
than consuming them.
An unreadable state file is treated as a first run, so the history is re-reported. Noisy, and
the alternative — a truncated file silently meaning “everything is already handled” — is detection
switching itself off without saying so.
Signals raised in the same monitoring tick share a timestamp exactly, so the watermark carries the
digests of everything at its own instant. Without that, one of a pair raised together would be lost.
What is in the alert
The marker, the kind, the count, the timestamp, and the actor’s PublicId. That is a pseudonym — it
identifies an account to somebody holding the database and to nobody else — so the alert can be read
on a phone without carrying anybody’s e-mail address into a mailbox. NFR-22 keeps addresses out of
the logs to begin with; the alert inherits that rather than re-deciding it. Do not extend the job
to include addresses.
The alert also says, in its own body, that the moment you read it is when awareness happened — the
timestamp that goes in the Art. 33(5) register, not the time the signal was raised.
Cross-origin requests
HEIMDALL_CORS_ALLOWED_ORIGINS lists the browser front ends allowed to call the API, comma
separated, as scheme and host (https://app.example.com). With the variable unset, no
cross-origin request is allowed.
Refusing by default is deliberate. The same-origin policy is what stops a page on an unrelated
origin from reading an authenticated response, and an identity API is the last place to switch it
off: any site the caller visited could otherwise read /api/persons/{id} with a token it scraped,
and drive the anonymous endpoints from every visitor’s browser at once. A missing entry costs a
front end its access until an operator adds one — visible, and quickly fixed. Defaulting to “any
origin” would instead leave a deployment open with nothing to indicate it.
Server-to-server callers are unaffected: CORS is a browser rule, and non-browser clients send no
Origin header.
Client addresses behind a proxy
On the VPS every connection reaches the API from Traefik, so the connection’s address is
Traefik’s. The caller’s own address travels in X-Forwarded-For, and the scheme they used in
X-Forwarded-Proto. HEIMDALL_TRUSTED_PROXIES lists the proxies those headers are believed from —
addresses and CIDR networks, comma separated:
Traefik reaches the API
Value
From a container on a Docker network shared with api
That network’s subnet, e.g. 172.18.0.0/16 — Traefik’s own address changes when its container is recreated. The VPS templates use 172.16.0.0/12, the range Docker allocates every network from, which covers yggdrasil’s edge network whatever subnet it was given
From the host, through the published API_PORT
The Compose network’s gateway, e.g. 172.19.0.1 — what published-port traffic arrives from
Two things read the result: the rate limiter, which partitions on it, and the request log, which
records it (Started request with TraceId … from <address> on every request). Unset, nothing is
trusted and both see Traefik — one rate-limit bucket for every caller, and a log that names the
proxy. Start-up warns when that is the case.
Only the headers’ last hop is read: Traefik appends the address it saw to whatever the caller sent,
so the rightmost entry is the only one it vouches for, and an earlier entry a caller forged is
ignored. Never list a range a caller can originate from — anything trusted here can claim any
address it likes. X-Forwarded-Host is not read; nothing in the API decides on the host name.
Integrations
Email delivery (Mailgun)
Region: United States. This is a deliberate choice, not an inherited default — Mailgun also
offers an EU region. Data is hosted in Brazil, so every send is an international transfer under
LGPD Art. 33 whichever region is picked; the choice is about which second jurisdiction is
involved. The mechanism relied on is recorded in
§7 of the Data Protection Document
. Changing the
region means updating that section and the sub-processor list in the
Data Processing Agreement
.
Verification (UC-06) and password reset (UC-12) emails, and 2FA email codes, go out through Mailgun
via ArturRios.Messaging
. Delivery is enabled only
when both credentials are present:
Variable
Value
MAILGUN_API_KEY
Your Mailgun private API key
MAILGUN_DOMAIN
The Mailgun sending domain
MAILGUN_API_VERSION
Optional; defaults to v3
MAILGUN_FROM
Optional; the sender, as Name <address> or a bare address, e.g. Heimdall <no-reply@example.com>. Unset, mail goes out from a bare postmaster@<MAILGUN_DOMAIN>
Variable
Value
HEIMDALL_EMAIL_VERIFICATION_URL
Front-end page that verifies an email address
HEIMDALL_PASSWORD_RESET_URL
Front-end page that sets a new password
Each page finishes the job by posting its token back — the verification page to
POST /api/auth/verify-email, the reset page to POST /api/auth/password-reset with the new
password. If no link is configured the email carries the bare token instead, which still works.
Pointing a scope at a client application’s screens
The two variables above are one link for the whole deployment. A client system such as Fortuna has
its own /verify-email and /password-reset screens, and its users’ emails must lead there. A scope
can carry its own link bases, which replace the global ones for that scope’s Users only:
PUT /api/scopes/{id}/email-links
Authorization: Bearer <System Admin or owning Scope Admin token>
{
"emailVerificationUrl": "https://app.example.com/verify-email",
"passwordResetUrl": "https://app.example.com/password-reset"
}
Each must be an absolute http or https URL of at most 2048 characters; anything else is a 400.
The token is appended as a token query parameter, as for the global links.
The body is the whole state of the two links: one left out, null or empty is cleared, and the
global link applies to that email again. They have their own endpoint, not fields of
PUT /api/scopes/{id}, because that update replaces every field and would erase them whenever a
client that does not know them sent it.
The verification link is used for the sign-up confirmation (UC-47) and for address verification
(UC-06, UC-15); the password-reset link for password recovery (UC-12). A Google User receives
neither.
Administrators have no scope, so their emails always use the global links.
The client’s screens finish the job as the global ones do: the verification screen posts the token
to POST /api/auth/verify-email (with the chosen password for a sign-up confirmation), the reset
screen to POST /api/auth/password-reset.
Both are returned on every scope response (GET /api/scopes, GET /api/scopes/{id} and the write
endpoints).
Password recovery is answered before it is worked.POST /api/auth/password-recovery only
validates the request and puts it on an in-process queue; a background worker then looks the address
up, issues the token and sends the email. So the response takes the same time whether or not the
address is registered — otherwise the Mailgun round trip a registered address waited for would give
away what the uniform answer hides (AF-12a). Two consequences for operators:
A restart drops whatever is still queued — at most a few seconds’ worth. Nobody is told; the
person asks again, as they would for an email that never arrived.
The queue holds 1,024 requests. Beyond that a request is dropped and the log says
The password recovery queue is full — a sign that Mailgun is down or very slow, not that the
endpoint is under attack (the rate limiter caps that first).
State
Behaviour
Configured
Emails are sent.
Unconfigured, not Production
Each token is logged instead of emailed, with one warning at start-up. This keeps local runs and the functional suite working without credentials and off the network.
Unconfigured, Production
Start-up fails. The fallback would print verification tokens, reset tokens and 2FA codes in plaintext — an account-takeover primitive for anyone who can read the logs.
Google Sign-In
Variable
Value
HEIMDALL_GOOGLE_CLIENT_IDS
Comma-separated Google OAuth client IDs accepted as an ID token’s audience
Unset, the API registers a verifier that refuses every token with a 401 and warns once at
start-up. Unlike the email fallback this is not a convenience mode: verification needs an audience to
check against (NFR-13), so a verifier with no configured client could only reject everything or
trust everything. Every other endpoint is unaffected, as is any scope that never enabled the feature.
Google sign-in also requires the scope itself to have it switched on, through
PUT /api/scopes/{id}/google-signin — it is off by default. See
Google Sign-In
.
Rotating the signing secret
HEIMDALL_AUTH_TOKEN_SECRET signs every token the API issues, so replacing it used to invalidate
every token in flight the moment the new value took effect. HEIMDALL_AUTH_TOKEN_SECRET_PREVIOUS
makes that a rotation instead: both secrets are accepted while only the current one signs.
Set HEIMDALL_AUTH_TOKEN_SECRET_PREVIOUS to the secret currently in use, set
HEIMDALL_AUTH_TOKEN_SECRET to a new one, and restart. Tokens issued before the restart keep
working; new ones are signed with the new secret.
Wait one HEIMDALL_AUTH_TOKEN_EXPIRATION_IN_SECONDS — an hour by default — so that no token
signed with the old secret can still be alive.
Clear HEIMDALL_AUTH_TOKEN_SECRET_PREVIOUS and restart again.
Do step 3 immediately when the reason for rotating is that the old secret leaked. Withdrawing a key
invalidates its tokens at once, which is the point: signing everybody out is the correct response to
a compromised key and the wrong response to a routine replacement.
Every instance must carry the same pair, so roll the change out to all of them before step 3 — an
instance that has already dropped the old secret will refuse tokens its neighbours still accept.
Both secrets must be at least 32 bytes of ASCII, or the API refuses to start, naming the variable and
never the value (NFR-31); openssl rand -base64 48 gives 64. An empty previous variable, including
one holding just "", means no previous key, so clearing it in step 3 is safe whatever the env file
quoting.
Application tokens (UC-56) are signed with the same keys, so the same rotation covers them. Step 2
waits out the longer of the two lifetimes, HEIMDALL_AUTH_TOKEN_EXPIRATION_IN_SECONDS and
HEIMDALL_APPLICATION_TOKEN_EXPIRATION_IN_SECONDS (ten minutes by default), and a consumer that
validates them offline must be given both keys for the same period.
Environment variables at a glance
Variable
Required
Default
HEIMDALL_DATA_CONNECTIONSTRING
✅
—
HEIMDALL_DATA_DATABASETYPE
✅
— (PostgreSql)
HEIMDALL_AUTH_TOKEN_SECRET
✅
— (at least 32 bytes of ASCII)
HEIMDALL_APPLICATION_TOKEN_AUDIENCE
✅
— (must differ from HEIMDALL_AUTH_TOKEN_AUDIENCE and the audiences derived from it)
A present but unusable value of HEIMDALL_AUTH_TOKEN_EXPIRATION_IN_SECONDS,
HEIMDALL_APPLICATION_TOKEN_EXPIRATION_IN_SECONDS or HEIMDALL_ADMIN_EMAIL_COOLDOWN_IN_SECONDS is
logged as a warning at start-up, naming the variable, the rejected value and the default applied; an
absent one is just the default and logs nothing.
Application tokens (HEIMDALL_APPLICATION_TOKEN_AUDIENCE, UC-56) are validated with zero clock skew,
so instances whose clocks differ can refuse a freshly issued token for the length of the drift: keep
instance clocks synchronised (NTP).
The account-email cooldown (HEIMDALL_ADMIN_EMAIL_COOLDOWN_IN_SECONDS) began as the administrators'
(UC-51/UC-52) and keeps that name, but it now also sets the cooldown of a person’s own account-email
budget (FR-PE-16) — resend, email setup code, email-change notice — and of anonymous password recovery
(UC-12). A shorter value lets people retry sooner. 0 removes the cooldown: a person’s own sends are
then bounded only by the budget’s five per window and the per-IP limit, and recovery only by the
per-IP limit, so keep it on wherever the API faces the internet. For recovery the cooldown is held to
the reset link’s lifetime (HEIMDALL_PASSWORD_RESET_TOKEN_EXPIRATION_IN_SECONDS), so a long cooldown
can never leave the owner without a working link.
For the administrators’ sends and for recovery, the cooldown is derived from the issued token rows
(ExpiresAt minus the token lifetime), not from a separate record. Two consequences:
if HEIMDALL_RETENTION_TOKEN_GRACE_DAYS is 0 and a token lifetime is shorter than the cooldown, an expired
token from inside the window may already have been purged, and the cooldown can undercount. And it is
best-effort: simultaneous administrator requests for the same person can both pass it. Recovery requests
are worked one at a time per instance, so only a second instance could overlap one. A person’s own
budget is a conditional write on their row and has neither weakness.
Environments/.env in the Web API project is a tracked template listing every variable; real
values live in per-environment files that are gitignored.
Scaling
The API is designed for horizontal scaling (NFR-06), and the authentication design is what makes
that cheap: tokens are stateless and validated from their claims, so no session store or sticky
routing is needed. The rate limiter’s window is per instance as a result, which is why the
per-account failure budgets above live in the database instead.
A token still carries no revocation list, but it no longer outlives the identity it names:
ActorLivenessFilter resolves the caller on every authenticated request and refuses a token whose
person or Google User has been deleted (FR-AU-05, FR-GO-12) or is under a restriction of
processing (NFR-24). That costs one indexed read per request — the price of making logical
deletion and restriction take effect immediately rather than whenever the token happens to expire.
The four endpoints through which a subject exercises their own rights — data export, erasure request,
restriction and lifting their own restriction — still admit a restricted or suspended (not yet
anonymised) subject, and leave the answer to the use case.
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.
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.
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.
10.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.
10.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 / 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 --> J
8.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
10.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
UC-23: Promote User to Scope Owner
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 User403, 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 User403, 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 User403, 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 User403, 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 User403, 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).
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 Delete
4.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 Delete
Note: 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 Delete
4.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 Delete
Note: 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 Delete
Note: 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.
Note: 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.
10.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:
Technology versions and roles are maintained in exactly one place.
Rule: when a technology choice changes, it changes here first. Other documents link to this one rather than duplicating the detail.
2. Platform & Language
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
Nullableenabled, ImplicitUsingsenabled
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.
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.
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
10.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 · ResidualLow
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) · ResidualLow
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 · ResidualLow
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.
10.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
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
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.
10.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.
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
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)
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)
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)
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)
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
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
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 ProfilePictureUrlcome 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.
10.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.
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.
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
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
10.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.
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.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.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.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.
10.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.
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.
5. Tooling & packages
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):
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:
usingArturRios.Util.Test.Attributes;usingArturRios.Util.Test.Mock;usingBogus;usingFluentValidation;usingFluentValidation.Results;usingMoq;namespaceArturRios.Heimdall.Command.Tests;publicclassCreateScopeCommandHandlerTests{ [UnitFact]publicasyncTaskGivenUniqueNameAndValidOwners_WhenHandlingCreateScope_ThenScopeIsCreated(){// GivenvarscopeReader=newFakeRepository<Scope,long>();varscopeWriter=scopeReader;// same in-memory storevarroleReader=newFakeRepository<Role,long>();varscopeAdminRole=newRole{Name=nameof(Roles.ScopeAdmin)};roleReader.Create(scopeAdminRole);// Bogus builds the collaborator data; set only the fields the behavior depends on.varowner=newFaker<Person>().RuleFor(p=>p.PublicId,_=>Guid.NewGuid()).RuleFor(p=>p.RoleId,_=>scopeAdminRole.Id).RuleFor(p=>p.IsDeleted,_=>false).Generate();varpersonReader=newFakeRepository<Person,long>();personReader.Create(owner);// Moq stubs the non-repository collaborator (the validator).varvalidator=newMock<IValidator<CreateScopeCommand>>();validator.Setup(v=>v.ValidateAsync(It.IsAny<CreateScopeCommand>(),It.IsAny<CancellationToken>())).ReturnsAsync(newValidationResult());// no failures = validvarhandler=newCreateScopeCommandHandler(validator.Object,scopeReader,personReader,roleReader,scopeWriter);varcommand=newCreateScopeCommand{Name="Acme",OwnerIds=[owner.PublicId]};// Whenvaroutput=awaithandler.HandleAsync(command);// ThenAssert.True(output.Success);Assert.NotNull(output.Data);Assert.Equal("Acme",output.Data!.Name);Assert.Contains(ScopeMessages.ScopeCreatedSuccessfully,output.Messages);} [UnitFact]publicasyncTaskGivenScopeNameAlreadyExists_WhenHandlingCreateScope_ThenReturnsNameAlreadyExistsError(){// Given a store already containing a scope named "Acme" …// When handling a CreateScopeCommand for "Acme" …// ThenAssert.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:
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 PostgreSqlContaineronce 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.publicsealedclassPostgresFixture:IAsyncLifetime{privatereadonlyPostgreSqlContainer_container=newPostgreSqlBuilder().WithImage("postgres:18-alpine").Build();publicstringConnectionString=>_container.GetConnectionString();publicasyncValueTaskInitializeAsync(){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.awaitusingvarcontext=newAppDbContext(/* options bound to ConnectionString */);awaitcontext.Database.MigrateAsync();}publicValueTaskDisposeAsync()=>_container.DisposeAsync();}[CollectionDefinition(nameof(FunctionalCollection))]publicsealedclassFunctionalCollection: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))]publicclassScopeControllerTests:WebApiTest<Program>{publicScopeControllerTests(PostgresFixturedb):base(EnvironmentType.Local){/* seed as needed */} [FunctionalFact]publicasyncTaskGivenSystemAdminAndValidPayload_WhenPostScopes_ThenScopeIsCreatedAndReturned(){// Given an authenticated System Admin and an existing ScopeAdmin owner …// Whenvarresponse=awaitGateway.PostAsync<DataOutput<CreateScopeCommandOutput>>("/api/scopes",newCreateScopeCommand{Name="Acme",OwnerIds=[ownerPublicId]});// Then — responseAssert.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.
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:
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
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.
10.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.
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.JwtIsTokenValidAsync), 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).
10.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
):
## — 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.
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:
Implemented on a feature/uc-##-use-case-name branch created from develop.
Main flow and every alternative flow from the use case specification are implemented.
Unit tests cover each handler and new Domain behavior (main + applicable AF-xx).
Functional tests cover each endpoint (main + every AF-xx, including authorization).
The full test suite passes (Category=Unit and Category=Functional).
A pull request was merged to develop — reviewed by a human, or merged by an agent under an
authorized batch run (Step 7.1).
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
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
.
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.
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 --> DB
2.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.
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.
Second-factor failures at sign-in are now counted per account, across every challenge (FR-AU-12). App codes, email
codes and recovery codes all count, a correct password no longer resets the count, and only a successful second
factor does. The tenth failure locks second-factor verification for 15 minutes, twice as long at each repeat, up to
24 hours; the repeat count resets after a successful second factor. While locked, POST /api/auth/2fa/verify
refuses every code, the right one included, with its usual 401 and “The code or recovery code is missing,
incorrect, or already used.”, so a client sees nothing new.
Re-authentication under a bearer token has a per-account budget of its own (FR-AU-13): POST /api/auth/2fa/enable,
2fa/confirm, 2fa/disable, 2fa/recovery-codes/regenerate, erasure-request (a person’s password) and an own
email change through PUT /api/persons/{id}. Five failures within 15 minutes pause these checks for the account
for 15 minutes. The budget never touches the login lockout or the second-factor budget, so the owner can still
sign in. A wrong or missing password, a wrong, missing or used code, and a paused budget all answer 401 with one
message, “The password or code is incorrect.”. Clients: this replaces “The current password is incorrect.” and
“The code or recovery code is missing, incorrect, or already used.” on those endpoints, and on 2fa/confirm it
replaces the 400 answers “The authenticator app code is missing or incorrect.” and “The email code is missing,
incorrect, expired, or already used.” with the same 401. A client that treats a 401 as the end of its session
must recognise the new message as a refused credential instead. A Google User’s erasure request still answers
“The credential presented was not accepted.”, but a person who sends no credential at all now gets the new message
and is charged like a wrong password. A new migration, AddFactorBudgets, adds six columns to person, all
starting empty; only targeted writes change them, and anonymisation clears them. The data export
(POST /api/auth/data-export) now includes all six in security, beside failedLoginAttempts and
lockedOutUntil. Deploy order: deploy the heimdall-ui release that recognises “The password or code is
incorrect.” and the 401 from 2fa/confirmbefore this API, or together with it. Against an older UI, a
mistyped password or code on any of these endpoints signs the user out. That UI release only adds the message, so
it is safe to deploy first. Third-party clients need the same change before upgrading. Operators: no setting
changes; the operations guide has SQL to release a second-factor lock or a re-authentication pause for a verified
person.
A person’s own requests now share one account-email budget (FR-PE-16): asking for the verification link again (POST /api/auth/resend-verification), starting two-factor setup with the Email method (POST /api/auth/2fa/enable) and
an own email change (PUT /api/persons/{id}, whose notice goes to the previous address). At most one such email per
HEIMDALL_ADMIN_EMAIL_COOLDOWN_IN_SECONDS (five minutes by default) and five in each day-long window starting at
the first send, reserved by one conditional write before anything is sent, so simultaneous requests cannot exceed
it. Both refusals carry Retry-After, the seconds until a send would be accepted. A refusal is 429 and changes
and sends nothing: “An email of this kind was sent to you recently. Try again in a few minutes.” inside the
cooldown, “You have reached the limit for account emails. Try again later.” once the window’s five are spent.
Two-factor setup checks it before the password and charges it only after, and App alone is never charged; an email
change charges it after the password and second factor, and a refusal spends no recovery or email code (an accepted
authenticator code’s time step is recorded, as for any accepted code). Administrator-initiated mail (UC-51, UC-52)
is not charged, so a person whose account was just created by an administrator, or whose budget is spent, can still
be helped. resend-verification is now also under the credential endpoints’ rate limit, whose 429 is “Too many
requests.” with Retry-After. A new migration, AddAccountEmailBudget, adds three columns to person, starting
empty; they are in the data export and cleared by anonymisation. Clients: treat both new 429s as “wait and try
again”, not as a session end or a refused credential; the wait can be hours, and Retry-After gives it. An own
email change counts toward the budget too, so asking for the new address’s verification link straight after the
change waits out the cooldown.
Anonymous password recovery (POST /api/auth/password-recovery) sends at most one reset link per person per
HEIMDALL_ADMIN_EMAIL_COOLDOWN_IN_SECONDS, held to the reset link’s lifetime, counting links sent by an
administrator too (FR-PR-07). The answer is unchanged.
A person can no longer change their own email while their email sign-in method is enabled, active or pending
(FR-PE-18): PUT /api/persons/{id} answers 409 “Turn off the email sign-in method before changing your email
address.” once the password and second factor are accepted. The re-authentication budget is cleared, as after any
accepted check, and an accepted authenticator code’s time step is recorded; no account-email budget is charged, no
recovery or email code is spent, and the email does not change. Clients: offer to turn the method off first;
it can be set up again at the new address. An administrator’s change is unaffected.
Creating a person with a password (POST /api/persons, POST /api/scopes/{id}/persons,
POST /api/scopes/{id}/owners) is limited to 30 requests a minute per acting administrator and 30 a minute per
address; a rejection is 429 “Too many requests.” with Retry-After. Only administrators’ access tokens count
against the address, so requests without a token, or with a User’s, a two-factor challenge or a subject-rights
token, from an address administrators share cannot use it up.
Self-registration bounds the mail one address can be sent (FR-SR-13, FR-SR-14): at most three live pending
registrations per address in a scope, and at most three “someone tried to register your address” notices per
address a day, across every scope. A request beyond either stores and sends nothing, and is answered 202 exactly
as before. The notice count is kept under a SHA-256 digest of the address in a new table, attempt_notice_budget
(migration AddAttemptNoticeBudget), and purged with the single-use tokens once its day has ended.
The credential endpoints’ rate limiter counts an IPv6 caller by its /56 rather than its /64, so a subscriber
handed a /56 is one caller instead of 256. Trusted callers’ named IPv6 addresses are counted the same way.
Operators: the operations guide now shows how to add a rate limit for the credential routes at Traefik,
alongside the API’s own limits.
Two-factor recovery codes now carry 80 bits (FR-2F-20): ten random bytes written as sixteen characters of
Crockford’s base32 (the digits and the uppercase letters without I, L, O and U) and returned by
POST /api/auth/2fa/confirm and POST /api/auth/2fa/recovery-codes/regenerate as XXXX-XXXX-XXXX-XXXX. Each is
stored as the SHA-256 of its sixteen characters. Wherever a recovery code is accepted (2fa/verify, 2fa/disable,
2fa/recovery-codes/regenerate, the twoFactorCode of an own email change) it is matched ignoring hyphens,
whitespace and case, and an O, I or L in a sixteen-character code reads as 0, 1 and 1. Codes of the earlier
XXXX-XXXX shape keep working, in any case and with or without the hyphen, until used or regenerated.
Users: everyone who enabled two-factor authentication before this release should regenerate their recovery
codes to get the stronger ones; the old set stops working once you do.
Clients: accept and show codes of 19 characters as well as 9, and do not restrict their alphabet beyond letters,
digits, hyphens and spaces. No migration: the column already holds a digest.
A first Google sign-in (POST /api/auth/google) now requires the ID token’s email_verified to be true
(FR-GO-07). A token where it is false or absent creates no Google User and is answered 403 with the new
message “Google sign-up requires a verified email address.”, before the address is compared with the scope’s.
A Google User that already exists signs in as before, whatever the claim says.
POST /api/auth/google accepts an optional nonce (FR-GO-20): the value the client sent Google when it asked for
the ID token. When it is present and not empty, the token’s nonce claim must equal it exactly, or the request is
answered 401 “Google authentication failed.”, as for any token that fails verification. Without it nothing is
compared. The nonce is a client-side check that the token it presents was issued for its own sign-in request;
whether a token is accepted rests on its signature, issuer, audience and expiry. Clients: generate a fresh
random nonce for each Google sign-in, pass it to Google, and send the same value here.
A password-reset or verification link is checked against its account again when it is followed (FR-PR-08,
FR-EV-08): if the person has since been restricted, deleted or anonymised, or can no longer sign in through their
scope, POST /api/auth/password-reset and POST /api/auth/verify-email answer 400 “Invalid token.”, exactly
as for an unknown token, and nothing is written. The new password and the verified flag are written only while
the person still holds the address they were read with; the new password and a fresh security stamp are written by
one statement, and once a person row exists its password hash and salt are written only by such targeted writes, so
no whole-row save of an older copy can put an old password back. An email change, by the person or an
administrator, now retires every live reset link as well as every verification link the person holds, before the
new address is saved and again straight after (FR-PE-17); following one afterwards answers 400 “Token already
used.”. Clients: no new messages; “Invalid token.” and “Token already
used.” keep their meaning of “this link no longer works, ask for a new one”.
A Scope Admin reading another person now sees only the scope ownerships the two share (FR-PE-19). In
GET /api/persons/{id}, GET /api/scopes/{id}/owners, GET /api/scopes/{id}/persons and the response of
PUT /api/persons/{id}, ownedScopeIds lists, for a Scope Admin reading somebody else, only the scopes the
Scope Admin owns too; a scope’s owners are each listed with that scope among them. A System Admin, and a person
reading themselves, still see every scope. GET /api/persons/scope-admins is unchanged: identifier, name and
email. Clients: no status or message changes; do not read another person’s ownedScopeIds as the full list
of what they own.
On an instance that serves a public host, EF Core’s sensitive data logging and detailed errors and the developer
exception page are off, whatever the environment, unless HEIMDALL_ALLOW_SENSITIVE_DIAGNOSTICS is true
(NFR-28). One decision at start-up sets all three, the exception page the host adds by itself in Development
included, and the instance starts as usual with one warning naming the hosts. A host is public when PUBLIC_HOST
or UI_HOST, the host of HEIMDALL_EMAIL_VERIFICATION_URL or HEIMDALL_PASSWORD_RESET_URL, or a
HEIMDALL_CORS_ALLOWED_ORIGINS entry, with or without a scheme, names anything but localhost or a loopback
address; docker-compose.yml now passes PUBLIC_HOST, UI_HOST and the new variable into the container.
Operators: nothing to change; set HEIMDALL_ALLOW_SENSITIVE_DIAGNOSTICS=true in an environment’s env file
only where it holds made-up people and its diagnostics are wanted. Production and a local run are unaffected.
Clients: a public-host instance does not answer with the developer exception page unless the override is
true; a failed request gets the usual 500 “Internal server error, please try again later”.
The supported database setup runs the migrations as an owner role and the API as a role that owns nothing
(NFR-29). The container entrypoint migrates with HEIMDALL_MIGRATIONS_CONNECTIONSTRING when it is set, and with
HEIMDALL_DATA_CONNECTIONSTRING as before when it is not. It hands the connection string to the migration
bundle through the bundle’s environment rather than its command line, and removes the migrations string before
the API starts, which never reads it. Operators: optional, and applied by you: the operations guide’s
“Database roles” has the grants, the default privileges for tables later migrations add, the steps for a new
database and for switching an existing one over, and the checks. Deploy this release before switching. The env
templates list the new variable; docker-compose.yml passes it through.
Every token the API reads is now checked for its issuer and audience as well as its signature and lifetime
(NFR-30). A bearer token must name HEIMDALL_AUTH_TOKEN_ISSUER as its issuer and exactly one of the person,
.2fa-challenge and .subject-rights audiences; the challenge token at POST /api/auth/2fa/verify and
2fa/challenge/resend, the challenge audience; an application token at GET /api/applications/self, the
application audience. A token Heimdall did not issue for that reader is refused however it is signed, with the
answer the endpoint already gives a token it cannot use: as a bearer token, 401 “Invalid token”, as for a bad
signature; at 2fa/verify, 401 “The two-factor challenge is invalid or has expired. Log in again.”; at
2fa/challenge/resend, the usual 200 “If the challenge is still valid, a new code has been sent.”, with nothing
sent; at GET /api/applications/self, 401 “The application token was not accepted.”. Clients: tokens
Heimdall issues are unaffected, and no new message is introduced.
Tokens are accepted under HS256 only, the algorithm they are signed with, now that the API is on
ArturRios.Jwt 1.3.0, declared directly in the Web API project. A token under HS384 or HS512 is refused as a
bearer token with 401 “Invalid token”, and at the two challenge endpoints and GET /api/applications/self as
above.
A browser front end on an origin in HEIMDALL_CORS_ALLOWED_ORIGINS can now read Retry-After: the API lists it in
Access-Control-Expose-Headers, so the wait a 429 gives reaches a cross-origin client too.
Start-up checks the signing secrets (NFR-31): HEIMDALL_AUTH_TOKEN_SECRET, and HEIMDALL_AUTH_TOKEN_SECRET_PREVIOUS
when it is set, must each be at least 32 bytes and ASCII only, or the API refuses to start with a message that
names the variable and never the value. A previous secret that is unset, blank or "" means no previous key.
Operators: before deploying, check that every environment’s secrets meet the rule; openssl rand -base64 48
gives 64 bytes.
UC-53 to UC-57 (#178): a registered application can authenticate to Heimdall as itself. A System Admin, or the
Scope Admin who owns the application and its scope, issues it up to two live credentials
(POST /api/scopes/{scopeId}/applications/{id}/credentials), lists them (GET the same path) and revokes one
(DELETE …/credentials/{credentialId}). The secret (hds_…) is returned once and only its SHA-256 is stored;
a third live credential is refused with 409. The application exchanges a secret for a ten-minute token
(POST /api/applications/token, anonymous and rate-limited, every refusal the same 401), and a consumer
re-checks that token live with GET /api/applications/self, which refuses it as soon as its credential is
revoked or expired or its application or scope is deleted. Application tokens carry their own audience and no
role, and Heimdall’s own endpoints refuse them. A new migration, AddApplicationCredential, adds the
application_credential table; the retention purge removes spent credentials. Operators: new variables
HEIMDALL_APPLICATION_TOKEN_AUDIENCE (required) and HEIMDALL_APPLICATION_TOKEN_EXPIRATION_IN_SECONDS
(default 600); see the consumer guide, “Application authentication”.
UC-51 and UC-52: an administrator can have a verification link (POST /api/persons/{id}/verification-email) or
a password-reset link (POST /api/persons/{id}/password-reset) mailed to a person’s own address — a System
Admin for anyone, a Scope Admin for Users of scopes they own. No token is ever returned. Deleted or restricted
persons are refused (409), as are a verified address (400) and, for a reset, an account that could not sign
in (409, UC-12’s rule, now shared). At most one email of each kind per person per cooldown: a repeat answers
429. The cooldown is best-effort. Operators: the new optional HEIMDALL_ADMIN_EMAIL_COOLDOWN_IN_SECONDS
sets it (default 300, 0 turns it off; unusable values fall back to 300); docker-compose.yml passes it
through.
UC-49 and UC-50: a scope’s owners and System Admins can list the self-registrations awaiting confirmation
(GET /api/scopes/{id}/pending-registrations — name, address, expiry, guesses left, never the password hash
or token) and discard one (DELETE /api/scopes/{id}/pending-registrations/{registrationId}), after which its
link answers “Invalid token”. Discards are audited. A new migration, AddPendingRegistrationPublicId, gives
each pending registration a random public id, backfilling existing rows.
GET /api/persons/system-admins lists the deployment’s System Admins (UC-07 read e, FR-PE-13), so one can
see who else holds full access. System Admin only; a Scope Admin or User is answered 403. Paginated, with
optional case-insensitive name and email filters, ordered by name, and projected to id, name and
email, as GET /api/persons/scope-admins is. Logically deleted and restricted (NFR-24) administrators are
not listed.
UC-48: GET /api/audit-log lists the audit trail for System Admins, newest first, filterable by when an entry
was recorded (from/to, UTC, inclusive), actorId, action (the command’s type name, matched exactly),
targetId and succeeded. An actor the retention pass has cleared reads as null and is never reconstructed.
A new migration, AddAuditLogTargetIndex, indexes audit_log.target_id so filtering by target does not scan
the table.
Changed
2FA challenge tokens and subject-rights-only tokens now carry their own audience (HEIMDALL_AUTH_TOKEN_AUDIENCE
plus .2fa-challenge and .subject-rights, respectively); consumers that pin the person audience reject them.
Full authentication tokens keep HEIMDALL_AUTH_TOKEN_AUDIENCE, and Heimdall accepts a challenge or
subject-rights-only token only under its own audience. Clients that pass the challenge token back to
POST /api/auth/2fa/verify unread need no change. Operators: no new variable; start-up logs the three
audiences, and now refuses a HEIMDALL_APPLICATION_TOKEN_AUDIENCE equal to either derived audience. Challenge
and subject-rights-only tokens issued before the upgrade are refused once, so those users sign in again; full
tokens are unaffected. During a rolling deploy, instances not yet upgraded still issue the old tokens, which
upgraded instances refuse, and the separation holds only once every instance is upgraded. Consumers that don’t
validate the audience should start doing so, pinned to HEIMDALL_AUTH_TOKEN_AUDIENCE exactly; see the consumer
guide, “Login: audiences”.
A present but unusable HEIMDALL_AUTH_TOKEN_EXPIRATION_IN_SECONDS,
HEIMDALL_APPLICATION_TOKEN_EXPIRATION_IN_SECONDS or HEIMDALL_ADMIN_EMAIL_COOLDOWN_IN_SECONDS now logs a warning
at start-up naming the variable, the rejected value and the default applied. The auth token lifetime also now
falls back to its default (3600) when zero, negative or not finite, where it was used as written, and the
lifetime settings now parse with the invariant culture rather than the host’s.
A request turned away by the AuthAnonymous rate limit (429) now carries a Retry-After header, in whole
seconds, and the usual error envelope with the message “Too many requests.”, instead of an empty body.
Contributors: the dotnet-ef tool in .config/dotnet-tools.json is now pinned to the version of
Microsoft.EntityFrameworkCore, and scripts/vulnerabilities.py fails when the two drift apart.
Upgrading:HEIMDALL_APPLICATION_TOKEN_AUDIENCE is now required and must differ from
HEIMDALL_AUTH_TOKEN_AUDIENCE; the API refuses to start otherwise. docker-compose.yml defaults it to
heimdall-applications and the docker/*.env.example templates preset that value, so a Compose deployment keeps
starting. The Web API’s Environments/.env holds only a placeholder; any other deployment must set it.
Application tokens are validated with zero clock skew, so keep instance clocks synchronised (NTP).
The audit retention pass (NFR-21) now also clears an entry’s TargetId when it names a person or Google User,
on the same two grounds as the actor: the entry is past the attribution period, or that identity has been
anonymised (#184). A self-service entry, or an administrator’s action on a person, therefore stops naming that
person once its attribution is cleared. Targets naming a scope, an application, a credential or a permission are
kept. A new migration, AllowClearingAuditTarget, replaces the append-only UPDATE trigger with
audit_log_attribution_clear_only(), which permits exactly this one more change — a person target set to
NULL once due, never to another value — and also makes the row id immutable. GET /api/audit-log reads a
cleared target as targetId: null, and filtering by targetId no longer finds it. Operators: no setting
changes. Existing entries already past the period are cleaned over the next scheduled runs, a batch
(HEIMDALL_RETENTION_PURGE_BATCH_SIZE) at a time, and the run’s log line now reports both counts.
The audit retention pass (NFR-21) now also clears the target of a person or Google User who was hard-deleted
(UC-10, UC-29, and the members UC-05 removes), including the hard delete’s own entry, on its next run and at any
age (#195). Before, a removed person’s id stayed in the trail for good, because with the row gone the trigger could
not tell it had named a person. Each hard delete now first writes a tombstone — the id, whether it was a person or
a Google User, and when — to a new erased_subject table (migration AddErasedSubject); a retried delete writes
it again without error. A trigger on that table accepts a row only for a person or Google User that still exists,
so a tombstone cannot be planted to make any other target clearable. A target whose id is tombstoned and no longer
resolves to a row is due, and a new migration, AllowClearingErasedSubjectTarget, lets the
audit_log_attribution_clear_only() trigger accept exactly that clearing; nothing else it permits changes. The
pass removes a tombstone once it is a day old and no entry names it, and its output and log line now report how
many it removed. Operators: no setting changes. Targets orphaned before this change are not recoverable:
the ids of persons and Google Users hard-deleted earlier were never tombstoned, so nothing records that they named
a person, and they stay in the trail as recorded. Clearing them would need a list of those ids from outside the
database; this release does not attempt it.
PUT /api/persons/{id} and POST /api/auth/2fa/enable are now under the AuthAnonymous rate limit (10
requests a minute per client address, 429 with Retry-After beyond it), like the other endpoints that check a
password. The person update is limited whether or not it checks one, administrators’ updates included.
Operators: no setting changes; a deployment behind a proxy needs HEIMDALL_TRUSTED_PROXIES set, as for
login, or every caller shares one budget.
Fixed
A sign-in attempt cut short at the lockout threshold now locks the account for the usual period instead of
leaving it unable to sign in. An account already left at the limit without a lock is handled normally again by
its next sign-in attempt, which locks it for the usual period, or clears it if the password is right. A new data
migration, ReleaseStuckLoginLockouts, also resets such accounts.
The Command tests no longer fail intermittently on password-hash gate timeouts under load (#196): the test
assembly installs a gate with the production permit count and a two-minute wait, and the saturation
exception’s message now names the gate and how long it waited. Production behaviour and responses are
unchanged.
A request that cannot be bound now answers 400 with “The request is malformed.” on every endpoint (#186),
instead of running the action with a missing or default argument: a body that is not JSON was a 500, and a
query filter of the wrong type (?succeeded=maybe, ?actorId=not-a-guid) was dropped and answered 200
unfiltered. A global MalformedRequestFilter replaces the two per-action guards added in #178; it runs after
authentication and role checks, so an unauthenticated caller still receives 401. A non-nullable query
parameter sent empty or unparseable (?pageNumber=, ?pageNumber=abc, ?includeDeleted=1) is now a 400
rather than its default; a missing required field keeps its validator message.
Security
A public_id can no longer be rewritten, or borrowed from another entity (#199). The audit trail’s retention rules
(#184, #195) read a person’s or Google User’s id as proof that an audit target names a person, so a row given a
scope’s, an application’s or a credential’s id, then anonymised or tombstoned, made targets that never named a
person clearable. Two new migrations add triggers to person, google_user, scope, application,
application_credential, scope_permission, role and pending_registration. ProtectIdentityPublicIds creates
public_id_is_immutable(), which refuses any UPDATE that changes public_id, and public_id_is_unused(), which
refuses an INSERT whose public_id a row of any other of those tables holds, soft-deleted or not, or a hard
delete tombstoned, and, for a person or Google User, one that an audit entry names, so a hard-deleted entity’s id
cannot be taken over while the trail still names it; it binds both to the identity tables. ProtectEntityPublicIds
binds both to the other six, so public_id is immutable on every table the check trusts, which closes the
rename-then-reuse path, and the check runs whichever table is written second. Every other column stays writable,
and the API only ever creates fresh ids and never changes one, so no request behaves differently. Operators: no
setting changes. The triggers guard future writes only; the migrations do not look for a duplicate already stored,
which could exist only if somebody wrote one by hand in SQL. Operations, “Shared public ids”, gives two queries to
run before migrating; each should return no rows. Once migrated, hand-written SQL that rewrites a public_id, such
as a support fix-up or a restore that remaps ids, fails with restrict_violation.
Person tokens are now revoked when a credential changes. Every person token (full, 2FA challenge and
subject-rights-only) carries the person’s security stamp in a new sst claim, and a token whose stamp is missing
or out of date is refused on every endpoint with 401 and “This token has been revoked. Sign in again.” The stamp
is replaced by a password reset, an email change (by the person or by an administrator), confirming or disabling
two-factor authentication, and regenerating recovery codes; the token that made the change is revoked with the
rest. POST /api/auth/2fa/verify refuses a challenge issued before such a change as an invalid challenge. A new
migration, AddPersonSecurityStamp, adds person.security_stamp, filled with a fresh value per row; only a
targeted write replaces it, so a concurrent whole-row save cannot restore an old one. Google User tokens are
unchanged. Operators: tokens issued before the upgrade carry no stamp and are refused, so everyone signs in
once more after deploying; during a rolling deploy, tokens issued by instances not yet upgraded are refused by
upgraded ones until every instance is upgraded. The migration rewrites the person table (its default is
evaluated per row) under an exclusive lock, which blocks sign-ins and every other read or write of the table
until it finishes; on a large table, plan for that lock time. Consumers that validate person tokens offline
do not see the stamp, so a revoked token stays valid at them until it expires. Revocation and the
re-authentication below hold only once every instance runs this version: until then, an instance not yet
upgraded still accepts revoked tokens and own email changes without a password.
Changing your own email through PUT /api/persons/{id} now requires currentPassword, and twoFactorCode (an
authenticator-app or email code, or a recovery code, which is spent) when your two-factor authentication is
active. No endpoint sends an email code to a signed-in caller, so a person whose only method is Email uses a
recovery code. A missing or wrong password answers 401 with “The current password is incorrect.”, and a missing or
wrong second factor 401 with “The code or recovery code is missing, incorrect, or already used.”, before the
new address is checked, and nothing changes. An administrator changing someone else’s email, and anyone changing
only their name, send neither. The email counts as changed unless it matches exactly after trimming, so a change
of case alone also asks for the password, revokes tokens, sends the notice and resets emailVerified. Starting
two-factor setup (POST /api/auth/2fa/enable) now requires currentPassword too, refused the same way. Any
email change also sends a notice to the previous address, which names neither the new address nor a link; a
failed delivery is logged and does not fail the update. Addresses are now stored trimmed, on creation and on
update; an update that differs from the stored address only by surrounding whitespace is no change.
Clients: send currentPassword (and twoFactorCode when 2FA is active) for an own email change, and
currentPassword to enable 2FA; after an own email change, a password reset, confirming or disabling 2FA, or
regenerating recovery codes, sign in again. Operators: release the heimdall-ui version that sends
currentPassword together with this API, or before it; an older UI gets 401 on 2FA enable and on an own email
change.
UC-47: anonymous self-registration (POST /api/auth/register) into scopes that open it with PUT /api/scopes/{id}/self-registration. Answers new and taken addresses identically; a taken address’s holder is emailed about the attempt. The account is created when the emailed link is followed (POST /api/auth/verify-email) with the password chosen at sign-up, with its address verified; until then nothing can sign in. Confirmation needs that password: without it the answer is 422, with a wrong one 401, neither consumes the registration, and the 10th wrong password discards it. A link followed again once the account exists answers “Invalid token”.
Per-scope email links (FR-SR-10). PUT /api/scopes/{id}/email-links sets the base links a scope’s
verification (and sign-up confirmation) and password-reset emails point at, so a client system such as
Fortuna can send its users to its own /verify-email and /password-reset screens. They replace
HEIMDALL_EMAIL_VERIFICATION_URL / HEIMDALL_PASSWORD_RESET_URL for that scope’s Users only;
administrators and a scope with none set keep the global links. Each must be an absolute http or
https URL of at most 2048 characters. emailVerificationUrl and passwordResetUrl are returned on every
scope response. A new migration, AddScopeEmailLinks, adds the two nullable columns.
Trusted caller keys for the anonymous-auth rate limit. A client system such as Fortuna’s API reaches Heimdall
from one address for every end user, so they shared one bucket of 10 requests a minute. A request carrying a
key from the new HEIMDALL_TRUSTED_CALLER_KEYS in X-Heimdall-Caller-Key is now counted under the address in
X-Heimdall-Client-Address (an IPv6 address by its /64), in a bucket of its own that a direct connection from that address does not share. A wrong or missing key, or an unusable address, falls
back to the connection address, as before. Operators: the variable is optional; each key must be at least 32
characters or start-up is refused, and two may be listed to rotate. docker-compose.yml passes it through.
A subject whose processing is restricted (NFR-24) can sign in again, for their own rights only (#145). Until now
POST /api/auth/login, POST /api/auth/2fa/verify and POST /api/auth/google refused a restricted identity
with the uniform 401, so once the token held at the time of the restriction expired the subject could no longer
lift it (UC-45), export (UC-41) or request erasure (UC-42) without a System Admin. They now answer 200 with a
subject-rights-only token and a new processingRestricted: true field (UC-11 AF-11i, UC-25 AF-25e). That
token claims no scope, and every endpoint other than data-export, erasure-request, processing-restriction
and processing-restriction/lift answers it 403 — still so after the restriction is lifted, when the subject
signs in again for a full token. It never lifts anybody else’s restriction, whatever role it claims. The check
comes after the password, lockout and second-factor checks, so every refusal still answers alike, and a
suspended (not yet anonymised) restricted subject is admitted too. A restricted person with email two-factor
authentication is now sent the code they sign in with, and 2fa/resend sends it again on request.
ArturRios.Messaging 1.3.0 → 1.4.0 (#147). Email no longer goes out as “Mailgun Sandbox”: the new MAILGUN_FROM
variable sets the sender (Heimdall <no-reply@example.com>, or a bare address), and without it mail is sent from
a bare postmaster@<MAILGUN_DOMAIN>. Operators: set MAILGUN_FROM in each environment’s env file — the
templates in docker/ and docker-compose.yml now carry it. A value that is not exactly one address makes every
send fail, logged with the variable’s name. The library also stops writing recipient addresses to the log; its
lines carry a pseudonymous RecipientRef instead.
The tests run on xUnit v3: xunit 2.9.3 → xunit.v3.mtp-off 4.0.2 and ArturRios.Util.Test 4.0.0 → 5.0.0,
the release built on xUnit v3. The mtp-off flavour runs through VSTest, so dotnet test, its filters and the
.trx logger work as before. Test-only; the HTTP API and the OpenAPI document are unchanged.
Fixed
An erasure requested while the subject’s processing was restricted could miss its legal deadline: NFR-24 kept the
restricted record out of the anonymisation pass, so the request waited, unanonymised, until somebody lifted the
restriction. POST /api/auth/erasure-request (UC-42) now lifts the subject’s own restriction in the same write —
only the subject can impose one (UC-44), and the request already demands their credential — whether the request
suspends the identity or is recorded but blocked (AF-42d), for persons and Google Users alike. It is lifted as a
subject lifts their own (UC-45), so no Art. 18(3) notification is sent. The response carries a new boolean,
restrictionLifted (additive), and the audit trail records a LiftProcessingRestrictionCommand entry on the
subject beside the request’s own. A restriction imposed after the request still holds the erasure until it is
lifted. UC-42, UC-44, NFR-24 and the API reference say so; the OpenAPI document is regenerated (#146).
A System Admin may own a scope (FR-SC-08). POST /api/scopes and POST /api/scopes/{scopeId}/owners/{personId}
accept a live SystemAdmin as an owner as well as a ScopeAdmin, so a System Admin can create a scope with
themselves as its owner when no Scope Admin exists yet — before, the request was refused with “One or more owners
do not reference an existing, non-deleted ScopeAdmin.” A System Admin who owns a scope may also own its
applications (FR-AP-03). GET /api/persons/scope-admins lists System Admins too when a System Admin calls it;
a Scope Admin still sees Scope Admins only. The owner-validation messages name both roles. NFR-12 counts a
System Admin owner like any other: deleting, hard-deleting, or erasing the last owner of a scope is refused
whatever their role.
Every dependency is on its latest stable release again: Testcontainers.PostgreSql 4.15.0 → 4.16.0 and
xunit.runner.visualstudio 4.0.0 → 4.0.1 (both test-only), and the documentation site’s Docsy theme 0.16.0 →
0.18.0, which now needs Dart Sass (1.105.1 in the docs workflow) to build its stylesheets and takes the Mermaid
settings in docs/hugo.toml under params.docsy.plugins.mermaid. The theme submodule now points at Docsy’s new
home, github.com/docsy/docsy. The HTTP API and the OpenAPI document are unchanged.
The functional tests run against PostgreSQL 18 (postgres:18-alpine, was postgres:16-alpine), the major
version the deployed environments run.
The health check moved to GET /api/healthcheck and GET /api/healthcheck/detailed. At the root it was
unreachable from the web UI’s host, which routes only /api/ to the API, so heimdall-ui’s health screen showed
errors in every deployed environment. The root /healthcheck addresses still answer, for the container health
check and yggdrasil’s status page, but are no longer in the OpenAPI document.
HEIMDALL_TRUSTED_PROXIES lists the reverse proxies (addresses and CIDR networks) whose X-Forwarded-For and
X-Forwarded-Proto headers are believed, one hop deep, so the rate limiter and the request log see the real
caller. Unset, nothing is trusted and start-up warns.
Every request’s client IP address is logged, and kept with the application logs for 12 months. The Privacy
Notice (now 1.2), the Data Retention Schedule and the Data Protection Document declare it.
A Scope Admin can list scopes: GET /api/scopes returns the scopes they own, its total counting only those,
with ownership read from the database. A System Admin still lists every scope; a User is still refused (403).
Changed
The credential-checking authenticated endpoints — POST /api/auth/2fa/confirm, 2fa/disable,
2fa/recovery-codes/regenerate and erasure-request — share the 10-a-minute rate limit the anonymous ones have,
and an IPv6 caller is now counted by its /64 rather than per address.
A migration adds two_factor_auth.challenge_id. A 2FA challenge issued before the upgrade is refused after it, so
a login in flight at deploy time has to sign in again.
A migration adds two_factor_auth.challenge_attempts (integer, default 0; additive, no table rewrite).
POST /api/auth/password-recovery answers before it looks the address up: the lookup, the token and the email
are done by a background worker, so the reset email arrives just after the response rather than before it. A
restart drops recovery requests still queued (the person asks again), and a full queue (1,024) drops and logs.
A login attempt is counted in failed_login_attempts while it is in flight, and cleared if the password was
right, so every login now writes that counter.
Every dependency is on its latest stable release, including ArturRios.Util.WebApi 5.1.0,
ArturRios.Data.Relational.Core 5.0.0, Mediator 2.0.0, Swashbuckle.AspNetCore 10.3.0 and OpenTelemetry
1.19. The HTTP API and the OpenAPI document are unchanged.
Four deployment environments instead of three: local (Docker Desktop on Windows, by hand), and development,
homologation and production, which share one VPS and are deployed by yggdrasil — develop to development,
release/x.y.z to homologation, the release pull request to production. Development and homologation run on
demand. The Development-in-WSL setup is gone. A new docker/homologation.env.example runs homologation as
Staging; the development and production templates now describe the VPS (PUBLIC_HOST, UI_HOST, Traefik’s
Docker range in HEIMDALL_TRUSTED_PROXIES, one database and login per environment) and the local one points
CORS and the e-mail links at the local heimdall-ui on http://localhost:8081. The e-mail templates’ reset link
is /password-reset, the route heimdall-ui serves. The docs page “Deploying with Docker” is now
“Environments and deployment”; the old address redirects to it.
Removed
scripts/deploy_wsl.py, which deployed into a WSL distro for the retired Development-in-WSL environment.
Fixed
Login and password recovery resolve the live person when a logically deleted one still holds the same address,
instead of refusing the live one until the deleted one is anonymised.
A record under a restriction of processing is no longer anonymised, nor moved toward erasure when a blocked
request clears (NFR-24).
Re-applying erasures after a restore also removes the person’s two-factor configuration, recovery and email codes,
and reset and verification tokens, as the scheduled anonymisation does.
An email code that authorizes a recovery-code regeneration is spent, as one that completes a login is.
Restricted Scope Admins are withheld from the scope-owner and scope-admin listings, as restricted Users already
were.
A page number large enough to overflow the page offset answers 400 instead of 500; the message now states
the accepted range.
The retention settings accept the lower bound of their documented ranges (1, 30 and 1 days, 1 minute).
A restricted subject can reach the endpoints through which they exercise their own rights — data export, erasure
request, restriction (answered 409, already restricted) and lifting their own restriction — instead of 401
everywhere (NFR-24, UC-41, UC-44, UC-45). A suspended, not yet anonymised subject can export and restrict, as
UC-41 and UC-44 say, and a repeated erasure request answers 409 rather than 401. Every other endpoint still
refuses them, and a restricted System Admin cannot lift anybody else’s restriction.
Validating a 2FA challenge honours the previous signing keys, as bearer tokens do, so a key rotation no longer
voids challenges in flight.
The OpenAPI document describes GET /api/auth/2fa and POST /api/auth/data-export with their own summaries; the
two-factor status summary had been attached to the export.
The OpenAPI document’s info.license names the project’s actual licence, “ArturRios.Heimdall — Proprietary
License”, instead of MIT, which it never was. Its description no longer says a 2FA challenge token works only at
POST /api/auth/2fa/verify: it is never a bearer credential, and POST /api/auth/2fa/challenge/resend reads it
from the request body too.
Security
A 2FA challenge token can be redeemed once (FR-2F-10). It stayed valid for its whole ten minutes, so whoever held
a spent one could trade any further factor for another full token without the password. A newer login also
replaces the previous challenge.
A restricted identity can no longer authenticate through Google Sign-In or by completing a 2FA challenge issued
before the restriction (NFR-24); both used to issue a full token.
Changing a person’s email retires their outstanding verification tokens, which could otherwise mark the new,
unproven address verified.
A Scope Admin removed as an owner of a scope can no longer read, update or delete the applications they still own
in it.
Two-factor email codes are hashed and checked under the process-wide Argon2id concurrency bound
(PasswordHashGate), which they bypassed.
The Mailgun library’s own log lines, which wrote the recipient’s address in the clear, are suppressed (NFR-22);
Heimdall already logs every delivery outcome under a non-reversible reference.
Password recovery takes the same time whether or not the address is registered (AF-12a). A registered address
used to wait for the token insert and the Mailgun round trip before answering, which told an anonymous caller
the address existed.
Single-use items are spent once and budgets hold under concurrent requests (NFR-27): recovery codes, email codes,
password-reset and verification tokens, the 2FA challenge and the TOTP time step are each spent by one
conditional UPDATE, and the login lockout, the email-code guess limit and the code-reissue limit are charged
before the comparison. Parallel requests could redeem one recovery code or token several times, trade one
challenge for several tokens, and lose lockout increments so ten simultaneous wrong passwords never locked the
account.
A 2FA challenge allows five guesses with an authenticator-app code or a recovery code, then is retired, so further
guessing needs the password again (FR-2F-17). Only the per-IP rate limit bounded them before.
Scope-based multi-tenancy: scopes, their owners, and the System Admin, Scope Admin and User roles.
Person and application (non-human identity) management, each with logical and hard deletion and defined cascade
rules.
Scope-specific permissions, optionally folded into the JWT issued within the scope.
Password login (JWT), password recovery, email verification, and email delivery through Mailgun, with a logging
fallback when no credentials are configured.
Two-factor authentication by authenticator app and/or email, with single-use recovery codes.
Google Sign-In per scope, and administration of the scope’s Google Users.
LGPD and GDPR data subject rights: self-service export, erasure and restriction of processing, with the
administrative counterparts.
Data protection: a retention schedule, anonymisation of deleted identities, purging of expired single-use tokens,
audit attributions cleared once due, bounded log retention with address redaction, the lawful basis and notice
version recorded per identity, restore reconciliation against the erasure ledger, and security signal detection.
Audit logging of write operations, rate limiting, liveness and detailed health checks, and Prometheus metrics on a
private port.
Deployment with Docker Compose against a host PostgreSQL, applying pending EF Core migrations at container
start-up.
12 - Contributing
Building from source, the test suites and coverage, migrations, the OpenAPI document, branching and releasing.
Prerequisites
.NET 10 SDK
PostgreSQL (the API’s relational database; functional tests spin up their own via Testcontainers)
Docker (functional tests start their PostgreSQL container through it)
Python 3 (the migration menu, the OpenAPI generator, the flake hunter and the other helpers under scripts/)
The pinned EF Core CLI tool — restore it once after cloning:
dotnet tool restore
To run the API locally, configure it first as described under
Configure
in the README.
Project structure
api-client/ Ready-to-send requests for every endpoint
http/ JetBrains HTTP Client (.http) files
bruno/ Bruno collection
docs/ Hugo (Docsy) documentation site — see Documentation site
requirements/ Vision, requirements, use cases, tech stack, data protection, testing
content/en/ The site's own pages (overview, architecture, flows…)
openapi/ Generated OpenAPI document, published by the site
themes/docsy/ The site theme, as a git submodule
scripts/ Tooling (EF Core migrations, coverage, OpenAPI)
tools/ArturRios.Heimdall.OpenApiGen/ Writes docs/openapi/heimdall.json (not in the solution)
src/
Domain/ArturRios.Heimdall.Domain/ Domain entities & enums
Application/
ArturRios.Heimdall.Command/ Command (write) handlers — CQRS
ArturRios.Heimdall.Query/ Query (read) handlers — CQRS
ArturRios.Heimdall.Shared/ Shared messages/contracts
Infrastructure/ArturRios.Heimdall.Data/ EF Core DbContext, entity maps, migrations, seeding
Presentation/ArturRios.Heimdall.WebApi/ ASP.NET Core Web API (entry point)
ArturRios.Heimdall.sln
tests/ Test projects mirroring src/ (unit + functional)
README.md
LICENSE
Build
dotnet build src/ArturRios.Heimdall.sln
Test
Run the whole suite:
dotnet test src/ArturRios.Heimdall.sln
Run one kind at a time — unit tests are isolated; functional tests run end-to-end against a real
PostgreSQL database provisioned by Testcontainers:
dotnet test src/ArturRios.Heimdall.sln --filter "Category=Unit"
dotnet test src/ArturRios.Heimdall.sln --filter "Category=Functional"
CI runs the two filters as separate steps and fails first if any test carries neither category, since neither
step would run it: use the [UnitFact] / [FunctionalFact] family of attributes on every test.
Every run writes a .trx under each project’s TestResults/ (git-ignored), so a failure is always
recorded by name rather than only as a console count. To chase a test that fails only occasionally,
repeat the run and let the harness collect the names:
Run the tests, collect coverage and build the HTML report in one step:
python scripts/coverage.py
The report is written to docs/coverage-report (git-ignored), and the run fails when merged line coverage
drops below the floor the
Testing Specification Document
sets; the report is written either way, since it is how you find what lost coverage. --report-only builds the
HTML from coverage files already on disk without running any tests, which is what CI does after its own two test
steps. --no-threshold generates without enforcing the floor.
Add --clean to delete every bin/ and obj/ first. dotnet build only overwrites the files it produces, so
assemblies from a previous project name survive in the output directories, and coverlet instruments whatever it
finds there — which is how the report once ended up a third full of ArturRios.IdentityManager.* classes that no
longer existed. dotnet clean does not remove them; --clean does. The script also empties
docs/coverage-report before writing, since ReportGenerator does not remove pages whose class has disappeared.
python scripts/coverage.py --clean
If REPORTGENERATOR_LICENSE is set, the script passes it to ReportGenerator and the PRO version generates the
report; the key is never echoed. CI reads it from the repository secret of the same name. Unset — as on a pull
request from a fork, where GitHub withholds secrets — generation still succeeds with the free version.
The published report is not committed. The Tests workflow uploads it as an artifact and, once its suites pass on
main, calls the Build Docs workflow, which unpacks that run’s report into docs/coverage-report before Hugo
builds, so a change under src/ moves the published numbers once it reaches main. A docs-only push takes the
report from the latest successful Tests run on main. The report itself is the only statement of
the current numbers; figures quoted in prose go stale.
Migrations
The schema is managed with EF Core migrations, applied explicitly — the API never migrates on
startup, and refuses to start when migrations are pending. Use the interactive migration menu to
list, create (generate), or apply migrations:
python scripts/migrations.py
It asks which environment file to load (for the connection string), then offers the migration
actions. Creating a migration prompts for its name and adds it to
src/Infrastructure/ArturRios.Heimdall.Data/Migrations. Requires dotnet tool restore (above)
to have been run once.
OpenAPI document
The API explorer renders docs/openapi/heimdall.json, which is generated and committed. Regenerate
it after changing anything it describes:
python scripts/openapi.py
check-openapi.yml
fails the build if it is out of date —
otherwise a controller change would keep being published with the old document, since the docs site
publishes the committed file and nothing else regenerates it.
Documentation site
The site is built with Hugo
and the Docsy
theme from
docs/. To preview it locally — Hugo Extended 0.160.1+, Node.js 24+, and
Dart Sass
1.95.0+ (the sass CLI on PATH, which Docsy 0.17+ compiles its
stylesheets with) required:
git submodule update --init --recursive
npm run install:theme-deps --prefix docs/themes/docsy
hugo -s docs server
The site’s Changelog and Contributing pages render this file and CHANGELOG.md
from the repository
root (mounted in docs/hugo.toml), so they are edited here, never copied into docs/content/. Build, test and
release material belongs in this file; the site keeps the usage, reference and architecture pages.
Pull request, squash or merge. The branch is deleted on merge.
release/x.y.z
develop
main
Pull request. Never merged by hand — see below.
develop, main
—
—
Protected: no direct pushes, no force pushes, no deletion.
Names are lowercase: letters, digits, ., _ and -. A release/ branch is a snapshot of
develop and carries no commits of its own: a fix for a release lands on develop through a
fix/ branch and a new release branch is cut.
The Branch Policy workflow checks all of this on every pull request and is a required check
on develop and main.
Each use case ships on its own feature/ branch, issue and pull request into develop — see the
Development Workflow Document
for the
issue status and testing gate it follows.
Commits and the changelog
Commit messages follow Conventional Commits
with a lowercase subject, e.g.
feat: let a data subject export their own data or fix: give a login attempt one deadline instead of two.
Record every change an API client or operator would notice under ## [Unreleased] in
CHANGELOG.md
, in the same pull request that makes it.
Versioning
The API follows Semantic Versioning
. Its public surface is everything an
API client or an operator depends on: the HTTP contract (routes, request and response bodies, status codes and
messages, the claims in the tokens it issues, the roles that may call each endpoint), the configuration a
deployment supplies, and the database the deployment keeps between releases.
Major — a release an existing client or deployment cannot take without changing something. An endpoint,
field or claim is removed or renamed, a response changes shape or status, a request that used to be accepted is
refused; an environment variable is renamed, removed or becomes required, or a default changes in a way a
running deployment would notice; a migration needs manual work before or after it is applied, or discards data.
Its entry in CHANGELOG.md
carries an ### Upgrading from <X>.x to <Y>.0 section saying what to
change.
Minor — backwards-compatible additions: a new endpoint, an optional request field, a new response field
(clients must ignore fields they do not know), a new optional setting whose default keeps the previous
behaviour, a migration that only adds to the schema.
Patch — fixes that change no contract: a defect, a performance or security fix, a dependency update,
documentation.
The version is not stored in the source. It is the name of the release branch: release/1.4.0 is deployed as the
image tag 1.4.0-<short commit>, which is what the yggdrasil console shows, and when it reaches production Jenkins
tags the merge v1.4.0 and creates the GitHub release of that name. The Branch Policy workflow refuses a release
branch whose version already has a tag. The v1 in the OpenAPI document and its URL (/swagger/v1/swagger.json)
is the document’s name, not the release version, and does not change with it.
Releasing
Four environments, described in
Environments and deployment
:
local is the developer’s own Docker Desktop, deployed by hand; development, homologation and
production share one VPS and are deployed by Jenkins.
Event
Deploys to
Push to develop (every merged pull request)
development — left stopped if it was stopped
Push of a release/x.y.z branch
homologation — likewise
Pull request release/x.y.z → main with every check green
production, then merge and tag
Development and homologation are on demand: they run only while somebody uses them, and a deploy
does not start a stopped one. To try a merge or a release candidate, turn the environment on, on
the VPS — scripts/ygg.sh env start development (or homologation) — and off again with
scripts/ygg.sh env stop <environment> when done. It is then at https://heimdall-api-dev.example.com
(-hml for homologation), example.com standing for the real domain.
Before cutting the release, finalize CHANGELOG.md
on develop through a normal feature/ or
fix/ pull request, since the release branch can carry no commits of its own: rename ## [Unreleased] to
## [1.4.0] - <yyyy-mm-dd> above a fresh, empty ## [Unreleased], and update the compare links at the bottom.
git switch develop && git pull && git switch -c release/1.4.0 && git push -u origin release/1.4.0
— Jenkins deploys the branch to homologation. Start homologation to check the release there
before step 2.
Open a pull request release/1.4.0 → main.
When every GitHub check on the pull request passes, Jenkins deploys to production. On
success it sets the deploy/production status, merges the pull request with a merge commit,
creates the tag and GitHub release v1.4.0, and deletes the release branch.
If the production deploy fails, Jenkins rolls back to the previous image and the pull request
stays open. Fix on develop, then cut a new release.
The GitHub release’s notes are generated from the pull requests merged since the previous tag;
CHANGELOG.md is the curated record.
Follow a release in the yggdrasil console (https://yggdrasil.<domain>, or the Android app).
The system card shows each environment — development, homologation, production — with this
application’s version, commit, deploy time and health in it; a stopped on-demand environment shows
as Stopped, not as a problem.
The repository owner can bypass these rules. That is for emergencies, not for routine work.
Where this is deployed from
Deployment is managed by yggdrasil
. This repository is
the application heimdall-api in its catalog.yaml, which is what gives it:
its Jenkins deploy job, and its place in each of the catalog’s environments (local,
development, homologation, production)
its GitHub rulesets and required checks (the catalog’s checks)
its Prometheus scraping
its place in the console
If a required check is renamed or added here, update the catalog entry, then run
python github/rulesets.py heimdall-api in yggdrasil.
The VPS environments’ env files live on the VPS at /etc/yggdrasil/<environment>/heimdall-api.env,
filled in from docker/<environment>.env.example. A variable added to the API needs adding to the
templates here and, where its value is not the default, to those files.