This is the multi-page printable view of this section. Click here to print.

Return to the regular view of this page.

Documentation

Everything needed to understand, run, test, and extend the Heimdall API.

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.

The documentation

SectionWhat it covers
OverviewThe domain vocabulary — scopes, persons, roles, applications, Google Users, permissions — and the rules that bind them.
Getting startedPrerequisites, environment configuration, migrations, and running the API locally.
ArchitectureThe four layers, the CQRS mediator, the auditing decorator, and the HTTP request pipeline — with class diagrams.
Domain modelThe entities, their relationships, and the deletion cascade rules — with a class diagram.
API referenceEvery endpoint, the role that may call it, and the use case it implements.
FlowsSequence diagrams for login, two-factor authentication, Google Sign-In, person onboarding, and audit logging.
OperationsMigrations, health checks, logging, rate limiting, and the environment variables the API reads.
Environments and deploymentThe four environments — local on Docker Desktop, development, homologation and production on the VPS — their env files and their PostgreSQL databases.
RequirementsThe source specifications: vision, system requirements, use cases, technology stack, testing, workflow, and operations.
ChangelogNotable changes in each release — the repository’s CHANGELOG.md, rendered.
ContributingBuilding 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.

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

TermWhat it is
ScopeA logical tenant boundary that groups the owners, users, applications, Google Users, and permissions belonging to one client system.
PersonA 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.
RoleOne of SystemAdmin, ScopeAdmin, User. A closed set of three rows.
Scope OwnerA 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 UserA person with the User role who belongs to exactly one scope.
ApplicationA registered non-human identity (another system), in exactly one scope, owned by exactly one Scope Admin who owns that scope.
Google UserAn 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 PermissionA permission defined inside a scope. When IncludeAsJwtClaim is set, its name is added as a claim on JWTs issued for that scope.
Two-Factor AuthAn 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 deletionSetting IsDeleted = true; the row stays.
Hard deletionRemoving 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 identitiesGoogle identities
EntityPersonGoogleUser
CredentialsPassword hash + per-person salt (Argon2id)Delegated to Google; no hash stored
Sign-inPOST /api/auth/loginPOST /api/auth/google
Roles reachableUser, ScopeAdmin, SystemAdminUser only, always
Second factorOptional 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):

git clone --recurse-submodules https://github.com/artur-rios/heimdall-api.git

Configure

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:

cp src/Presentation/ArturRios.Heimdall.WebApi/Environments/.env src/Presentation/ArturRios.Heimdall.WebApi/Environments/.env.local

How the environment picks its files

ASPNETCORE_ENVIRONMENT selects two files, both resolved next to the built assembly:

EnvironmentVariablesApp settings
Local (what the launch profiles set)Environments/.env.localSettings/appsettings.Local.json
DevelopmentEnvironments/.env.developmentSettings/appsettings.Development.json
StagingEnvironments/.env.stagingSettings/appsettings.Staging.json
ProductionEnvironments/.env.productionSettings/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.

Required — the API will not start without these

VariableValue
HEIMDALL_DATA_CONNECTIONSTRINGA PostgreSQL connection string
HEIMDALL_DATA_DATABASETYPEPostgreSql
HEIMDALL_AUTH_TOKEN_SECRETThe 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_AUDIENCEThe 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 / _PASSWORDCredentials 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

Optional — each falls back to a default

VariableDefault
HEIMDALL_AUTH_TOKEN_ISSUER / _AUDIENCEEmpty
HEIMDALL_AUTH_TOKEN_EXPIRATION_IN_SECONDS3600 (1 hour)
HEIMDALL_AUTH_TOKEN_SECRET_PREVIOUSEmpty (set only while rotating the signing secret , to a value held to the same rules as the secret)
HEIMDALL_APPLICATION_TOKEN_EXPIRATION_IN_SECONDS600 (10 minutes)
HEIMDALL_AUTH_MAX_CONCURRENT_PASSWORD_HASHES4 (~2.4 GB of Argon2id working set)
HEIMDALL_EMAIL_VERIFICATION_TOKEN_EXPIRATION_IN_SECONDS86400 (24 hours)
HEIMDALL_PASSWORD_RESET_TOKEN_EXPIRATION_IN_SECONDS3600 (1 hour)
HEIMDALL_ADMIN_EMAIL_COOLDOWN_IN_SECONDS300 (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_DIRECTORYlogs
HEIMDALL_METRICS_PORT9464 — the Prometheus scrape port; 0 switches metrics off (see Metrics )
HEIMDALL_CORS_ALLOWED_ORIGINSEmpty → 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_PROXIESEmpty → 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/.

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.

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:

DirectoryFormatOpens in
api-client/http/JetBrains HTTP Client (.http)Rider, IntelliJ IDEA, VS Code with the REST Client extension
api-client/bruno/Bruno collectionThe Bruno app, or bru run on the command line

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

3 - Architecture

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.

ProjectResponsibility
ArturRios.Heimdall.DomainEntities and the Roles enum. No behaviour beyond data shape.
ArturRios.Heimdall.CommandWrite side: commands, validators, handlers, application services, auditing.
ArturRios.Heimdall.QueryRead side: queries, validators, handlers, health checks, output DTOs.
ArturRios.Heimdall.SharedCross-cutting contracts and the message/status-code maps both sides use.
ArturRios.Heimdall.DataAppDbContext, entity maps, migrations, seeding.
ArturRios.Heimdall.WebApiControllers, JWT issuing and validation, filters, email and Google adapters, start-up.

CQRS: two mediators, two shapes

classDiagram
    class Controller {
        -CommandMediator commandMediator
        -QueryMediator queryMediator
    }
    class CommandMediator {
        +ExecuteCommandAsync~TCommand, TOutput~(command) DataOutput~TOutput~
    }
    class QueryMediator {
        +ExecuteQueryAsync~TQuery, TOutput~(query) DataOutput~TOutput~
        +ExecutePaginatedQueryAsync~TQuery, TOutput~(query) PaginatedOutput~TOutput~
    }
    class ICommandHandlerAsync~TCommand, TOutput~ {
        <<interface>>
        +HandleAsync(command, cancellationToken) DataOutput~TOutput~
    }
    class IQueryHandlerAsync~TQuery, TOutput~ {
        <<interface>>
        +HandleAsync(query, cancellationToken) DataOutput~TOutput~
    }
    class IPaginatedQueryHandlerAsync~TQuery, TOutput~ {
        <<interface>>
        +HandleAsync(query, cancellationToken) PaginatedOutput~TOutput~
    }
    class AuditingCommandHandler~TCommand, TOutput~ {
        -ICommandHandlerAsync inner
        -IAuditLogWriter auditLogWriter
        +HandleAsync(command, cancellationToken) DataOutput~TOutput~
    }
    class ConcreteCommandHandler {
        -IValidator~TCommand~ validator
        -IAsyncRepository~T~ repository
        +HandleAsync(command, cancellationToken) DataOutput~TOutput~
    }

    Controller --> CommandMediator
    Controller --> QueryMediator
    CommandMediator --> ICommandHandlerAsync
    QueryMediator --> IQueryHandlerAsync
    QueryMediator --> IPaginatedQueryHandlerAsync
    ICommandHandlerAsync <|.. AuditingCommandHandler
    ICommandHandlerAsync <|.. ConcreteCommandHandler
    AuditingCommandHandler o-- ConcreteCommandHandler : decorates

A controller action is deliberately thin — bind, dispatch, resolve:

[HttpPost]
[RoleRequirement((int)Roles.SystemAdmin)]
public async Task<ActionResult<DataOutput<CreateScopeCommandOutput?>>> Create(
    [FromBody] CreateScopeCommand command)
{
    var result = await commandMediator
        .ExecuteCommandAsync<CreateScopeCommand, CreateScopeCommandOutput>(command);

    return result.ToActionResult(statusMap: ScopeMessageMap.StatusCodes);
}

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):

services.AddScoped<THandler>();
services.AddScoped<ICommandHandlerAsync<TCommand, TOutput>>(provider =>
    new AuditingCommandHandler<TCommand, TOutput>(
        provider.GetRequiredService<THandler>(),
        provider.GetRequiredService<IAuditLogWriter>(),
        provider.GetRequiredService<ILogger<AuditingCommandHandler<TCommand, TOutput>>>()));

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.

See Audit logging for the sequence.

How a failure becomes a status

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 failureStatus
Unique violation409 Conflict
Other integrity rule (FK, NOT NULL, CHECK)409 Conflict
Concurrency conflict409 Conflict
Transient — the store is unavailable503 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:

[HttpDelete("{id:guid}/hard")]
[RoleRequirement((int)Roles.SystemAdmin)]

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.

The complete matrix — every action against every role — is §7 of the System Requirements Document , summarised per endpoint in the API reference .

Validation

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.

Which entities carry a PublicId

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

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

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

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

Secrets at rest

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

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

Deletion cascades

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

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

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

The rules that most often catch people out:

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

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

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

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

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

Persistence mapping

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

Email uniqueness

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

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

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

The scope a User belongs to, twice

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

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

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

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

NotationMeaning
Anonymous[AllowAnonymous] — no bearer token required.
System Admin[RoleRequirement(SystemAdmin)].
+ Scope AdminThe attribute admits Scope Admins too; the handler then confirms they own the addressed scope.
Any authenticatedNo 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 & pathWhoUse case
POST /loginAnonymousUC-11 — password login; answers a token, or a challenge token if 2FA is active
POST /password-recoveryAnonymousUC-12 — request a reset link
POST /password-resetAnonymousUC-13 — set a new password from the token
POST /verify-emailAnonymousUC-14 — confirm an address from the mailed token
POST /resend-verificationAuthenticated (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 /googleAnonymousUC-25 — Google sign-up/sign-in against a scope
POST /google/sign-outAuthenticated (Google User)UC-26 — end a Google session
POST /2fa/enableAuthenticated (self)UC-36 — begin 2FA setup; needs currentPassword (401 otherwise); with Email, 429 when the account-email budget refuses it
POST /2fa/confirmAuthenticated (self)UC-37 — confirm setup, receive recovery codes
POST /2fa/verifyAnonymousUC-38 — redeem the challenge token with a second factor
POST /2fa/disableAuthenticated (self)UC-39 — turn 2FA off
POST /2fa/recovery-codes/regenerateAuthenticated (self)UC-40 — issue a fresh set of ten
GET /2faAny authenticatedFR-2F-15 — the caller’s own two-factor status; 403 for a Google User
POST /data-exportAny authenticated (self)UC-41 — a copy of everything held about you, with recipients and retention
POST /erasure-requestAny 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-restrictionAny authenticated (self)UC-44 — suspend processing without deleting, on an Art. 18(1) ground
POST /processing-restriction/liftSelf, or System Admin for anotherUC-45 — lift it; an admin must inform the subject first
GET /erasure-requestsSystem AdminUC-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 & pathWhoUse case
POST /System AdminUC-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 authenticatedUC-02 — read one scope
PUT /{id}System AdminUC-03 — update name and description
DELETE /{id}System AdminUC-04 — logical delete (cascades to Users, Google Users, applications)
DELETE /{id}/hardSystem AdminUC-05 — hard delete (also purges permissions and join rows)
PUT /{id}/google-signinSystem Admin + Scope AdminUC-24 — turn Google Sign-In on or off
GET /{id}/pending-registrationsSystem Admin + Scope AdminUC-49 — the self-registrations awaiting confirmation, newest first: name, address, expiry, guesses left; never the hash, salt or token
DELETE /{id}/pending-registrations/{registrationId}System Admin + Scope AdminUC-50 — discard one; its link then answers “Invalid token” and the registrant may sign up again

Persons — /api

Method & pathWhoUse case
POST /personsSystem AdminUC-06 path b — create a ScopeAdmin or SystemAdmin with no scope
POST /scopes/{scopeId}/personsSystem Admin + Scope AdminUC-06 path a — create a User in a scope
POST /scopes/{scopeId}/ownersSystem Admin + Scope AdminUC-06 path c — create a new ScopeAdmin directly as a co-owner
POST /scopes/{scopeId}/owners/{personId}System Admin + Scope AdminUC-21 — add an existing ScopeAdmin or SystemAdmin as an owner
POST /scopes/{scopeId}/users/{personId}/promoteSystem Admin + Scope AdminUC-23 — promote a scope’s User to owner
GET /persons/{id}Any authenticatedUC-07 — read one person (a person may read themselves)
GET /scopes/{scopeId}/personsSystem Admin + Scope AdminUC-07 — list a scope’s Users
GET /scopes/{scopeId}/ownersSystem Admin + Scope AdminUC-07 — list a scope’s owners
GET /persons/scope-adminsSystem Admin + Scope AdminUC-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-adminsSystem AdminUC-07 read e — list the System Admins: who else holds full access
PUT /persons/{id}Any authenticatedUC-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 AdminUC-09 — logical delete
DELETE /persons/{id}/hardSystem AdminUC-10 — hard delete, cascading to owned applications, tokens, 2FA and join rows
POST /persons/{id}/verification-emailSystem Admin + Scope AdminUC-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-resetSystem Admin + Scope AdminUC-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 AdminUC-22 — remove an owner (rejected if it would be the last one)

Applications — /api/scopes/{scopeId}/applications

Method & pathWhoUse case
POST /System Admin + Scope AdminUC-16 — register an application in the scope
GET /{id}System Admin + Scope AdminUC-17 — read one
GET /System Admin + Scope AdminUC-17 — list the scope’s applications
PUT /{id}System Admin + Scope AdminUC-18 — update name and owner
DELETE /{id}System Admin + Scope AdminUC-19 — logical delete
DELETE /{id}/hardSystem AdminUC-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).

Application credentials — /api/scopes/{scopeId}/applications/{id}/credentials

Method & pathWhoUse case
POST /System Admin + Scope AdminUC-53 — issue a credential; the secret is in this response only; 409 with two live credentials already
GET /System Admin + Scope AdminUC-54 — list the application’s credentials, newest first; never a secret or hash
DELETE /{credentialId}System Admin + Scope AdminUC-55 — revoke a credential; tokens issued from it fail the next live check

A Scope Admin may manage the credentials only of an application they own, in a scope they still own, the rule GET/PUT /{id} apply (FR-AP-12).

Application tokens — /api/applications

Method & pathWhoUse case
POST /tokenAnonymous (the secret authenticates)UC-56 — exchange a credential’s secret for an application token; every refusal is one 401
GET /selfAnonymous (validates its own bearer token)UC-57 — check an application token live; one 401 for every refusal

The Application authentication guide is the page for the service consuming these tokens.

Scope permissions — /api/scopes/{scopeId}/permissions

Method & pathWhoUse case
POST /System Admin + Scope AdminUC-31 — create a permission
GET /{id}System Admin + Scope AdminUC-32 — read one
GET /System Admin + Scope AdminUC-32 — list the scope’s permissions
PUT /{id}System Admin + Scope AdminUC-33 — update name, description, and the JWT-claim flag
DELETE /{id}System Admin + Scope AdminUC-34 — logical delete
DELETE /{id}/hardSystem AdminUC-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 & pathWhoUse case
GET /System Admin + Scope AdminUC-27 — list the scope’s Google Users
GET /{id}Any authenticatedUC-27 — read one; admits the Google User themselves
DELETE /{id}System Admin + Scope AdminUC-28 — logical delete (idempotent: repeating answers 200 with alreadyDeleted: true)
DELETE /{id}/hardSystem AdminUC-29 — hard delete (a second call is a 404)

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 & pathWhoUse case
GET /api/audit-logSystem AdminUC-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 & pathWhoUse case
GET /api/healthcheckAnonymousUC-30 — liveness; the process is up (no database read)
GET /api/healthcheck/detailedSystem AdminUC-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": { /* the resource, or null */ },
  "messages": ["Scope created successfully"],
  "errors": [],
  "success": true
}
// PaginatedOutput<T> — every list endpoint
{
  "data": [ /* the page */ ],
  "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.

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.

FlowWhy it is worth tracing
LoginFive distinct failure causes, one answer — and a sixth outcome that is not a token at all.
Two-factor authenticationSetup, confirmation, and the challenge-token round trip that completes a gated login.
Google Sign-InVerification before anything else, then sign-up or sign-in down the same endpoint.
Person onboardingCreation, the verification email, and the resend path — including what happens with no mail credentials.
Password recoveryAn endpoint whose whole design is about what it must not reveal.
Scope permission claimsHow a permission defined in a scope ends up inside a caller’s JWT.
Audit loggingOne decorator, every write, no handler changes.
Application authenticationIssue 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:

RoleFound by
UserEmail within the scope named by the request — the same address may exist as a User in several scopes.
ScopeAdmin / SystemAdminEmail 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:

ClaimPresent for
idEveryone — the person’s PublicId
roleIdEveryone
scopeIdUser only
ownedScopeIdsScopeAdmin only, comma-separated
scopePermissionsAnyone acting within a scope that has flagged permissions (details )
mfaPendingChallenge tokens only
subjectRightsOnlySubject-rights-only tokens only — a restricted identity’s (NFR-24)
sstEvery 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:

EventUse case
Password resetUC-13
Email change, by the person or an administratorUC-08
Two-factor authentication confirmedUC-37
Two-factor authentication disabledUC-39
Recovery codes regeneratedUC-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:

Tokenaud
Full authentication tokenHEIMDALL_AUTH_TOKEN_AUDIENCE, exactly as configured
2FA challenge token (carries mfaPending)HEIMDALL_AUTH_TOKEN_AUDIENCE + .2fa-challenge
Subject-rights-only token (carries subjectRightsOnly)HEIMDALL_AUTH_TOKEN_AUDIENCE + .subject-rights

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.

Continue at Two-factor authentication .

7.2 - Two-factor authentication

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.

The lifecycle

stateDiagram-v2
    [*] --> None: no TwoFactorAuth row
    None --> Pending: UC-36 enable<br/>(row created, IsActive = false)
    Pending --> Pending: UC-36 again<br/>(AF-36d — selection overwritten)
    Pending --> Active: UC-37 confirm<br/>(every selected method proven)
    Active --> Active: UC-40 regenerate<br/>(ten fresh recovery codes)
    Active --> None: UC-39 disable
    Active --> Active: UC-38 verify<br/>(completes a gated login)

Setup — UC-36 and UC-37

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 disableUC-40 regenerate
RequiresBearer token + second factorBearer token + second factor
EffectRemoves the configurationReplaces all ten recovery codes
ResponseConfirmationThe ten new codes, shown once
AfterwardsEvery token revoked, the caller’s included: sign in againEvery token revoked, the caller’s included: keep the codes, then sign in again

Storage of secrets

Stored asReturned
TOTP secretEncrypted at rest via ASP.NET Core Data ProtectionOnce, at provisioning
Recovery codesSHA-256 only — 80 random bits need no per-code salt or slow hash (FR-2F-20)Once, when generated
Email codesHash + per-code salt — only 10⁶ possible valuesNever; mailed

That is NFR-16. See Domain model .

7.3 - Google Sign-In

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.

EndpointUse caseWho
GET /api/scopes/{scopeId}/google-usersUC-27System Admin, or an owner of the scope
GET /api/scopes/{scopeId}/google-users/{id}UC-27The above, or the Google User themselves
DELETE /api/scopes/{scopeId}/google-users/{id}UC-28System Admin, or an owner of the scope
DELETE /api/scopes/{scopeId}/google-users/{id}/hardUC-29System Admin only

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.

EndpointPathCreates
POST /api/personsbA ScopeAdmin or SystemAdmin with no scope
POST /api/scopes/{scopeId}/personsaA User in that scope, with a SCOPE_USER row
POST /api/scopes/{scopeId}/ownerscA 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.

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.

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.

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.

Managing them — UC-31 to UC-35

EndpointUse caseWho
POST /api/scopes/{scopeId}/permissionsUC-31System Admin, or an owner of the scope
GET /api/scopes/{scopeId}/permissions/{id}UC-32Same
GET /api/scopes/{scopeId}/permissionsUC-32Same
PUT /api/scopes/{scopeId}/permissions/{id}UC-33Same
DELETE /api/scopes/{scopeId}/permissions/{id}UC-34Same
DELETE /api/scopes/{scopeId}/permissions/{id}/hardUC-35System Admin only

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 operationEffect 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

Registration is the whole mechanism:

services.AddScoped<THandler>();
services.AddScoped<ICommandHandlerAsync<TCommand, TOutput>>(provider =>
    new AuditingCommandHandler<TCommand, TOutput>(
        provider.GetRequiredService<THandler>(),
        provider.GetRequiredService<IAuditLogWriter>(),
        provider.GetRequiredService<ILogger<AuditingCommandHandler<TCommand, TOutput>>>()));

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.

What an entry records

ColumnValue
PublicIdThe entry’s own GUID
ActorPersonIdThe acting person’s PublicId, or null for an anonymous write
ActorRoleThe acting person’s role value, or null for an anonymous write
ActionThe command’s CLR type name, e.g. "CreateApplicationCommand"
TargetIdBest-effort PublicId of the affected entity, or null if none could be resolved
CreatedAtUTC 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.

curl -X POST http://localhost:5177/api/scopes/$SCOPE_ID/applications/$APPLICATION_ID/credentials \
  -H "Authorization: Bearer $ADMIN_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"expiresAt":"2030-01-01T00:00:00Z"}'

The answer is 201 Created:

{
  "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:

  1. issue a second credential (POST …/credentials);
  2. deploy its secret to the application;
  3. 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\"}"
{
  "data": {
    "id": "0c9a3e7e-5a53-4a61-8e57-6b3f0e0d7a10",
    "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9…",
    "expiresAt": "2026-10-14T12:10:00Z"
  },
  "messages": ["Application token issued."],
  "errors": [],
  "success": true
}

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).

ClaimValue
issHEIMDALL_AUTH_TOKEN_ISSUER, when one is configured
audHEIMDALL_APPLICATION_TOKEN_AUDIENCE — never the person audience
exp, iat, nbfExpiry, issue time and not-before
tokenKindapplication
idThe application’s identifier
scopeIdThe identifier of the application’s scope
credentialIdThe credential the token was issued from
nameThe 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:

curl http://localhost:5177/api/applications/self \
  -H "Authorization: Bearer $APPLICATION_TOKEN"
{
  "data": {
    "applicationId": "0c9a3e7e-5a53-4a61-8e57-6b3f0e0d7a10",
    "name": "Cerberus",
    "scopeId": "3b1e5d46-1d3c-4f4e-8a35-0f6d6b6f8e22",
    "credentialId": "6f1c0d52-8f0e-4a43-9c52-2d6f6a9b1d11",
    "expiresAt": "2026-10-14T12:10:00Z"
  },
  "messages": ["Application token accepted."],
  "errors": [],
  "success": true
}

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

EndpointStatusWhen
POST …/credentials201Credential issued; the secret is in the body
400expiresAt is not in the future, is not a date-time, or the body is not JSON (AF-53c)
401 / 403Anonymous; a User, or a Scope Admin who does not own the application or no longer owns its scope (AF-53b)
404No live application with that id in the addressed, live scope (AF-53a)
409Two live credentials already (AF-53d)
GET …/credentials200The credentials, newest first; an empty list when there are none (AF-54c)
401 / 403 / 404As above (AF-54a, AF-54b)
DELETE …/credentials/{credentialId}200Credential revoked
401 / 403 / 404As above, and 404 when the credential is not on this application (AF-55a to AF-55c)
409Already revoked (AF-55d)
POST /api/applications/token200Token issued
400A field missing, the secret over 100 characters, or a body that is not readable (AF-56a)
401Any other reason, always the same message (AF-56b)
429Rate limit; the Retry-After header gives the seconds to wait
GET /api/applications/self200Token valid and still live
401Any 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.

EnvironmentWhereDeployed byASPNETCORE_ENVIRONMENTAPI atDatabase
localThe developer’s Windows machine, Docker DesktopBy hand, docker compose --env-file docker/local.env up -d --buildDevelopmenthttp://localhost:8080heimdall_local, PostgreSQL on Windows
developmentThe VPS, on demandJenkins, on every push to developDevelopmenthttps://heimdall-api-dev.example.comheimdall_development, PostgreSQL on the VPS
homologationThe VPS, on demandJenkins, on every push of a release/x.y.z branchStaginghttps://heimdall-api-hml.example.comheimdall_homologation, PostgreSQL on the VPS
productionThe VPS, always onJenkins, on a green release/x.y.z → main pull request — it then merges and tagsProductionhttps://heimdall-api.example.comheimdall, 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 UIServedNot servedNot served
Developer exception pageOnOffOff
EF Core parameter loggingOnOnOff
Verification and reset e-mails without MailgunLogged instead of sentLogged instead of sentStart-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.

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.

1. Check the engine

In PowerShell, from the repository root:

docker version --format '{{.Server.Version}}'
docker context ls

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 -U postgres -c "CREATE ROLE heimdall_svc LOGIN PASSWORD '<pick-a-password>';"
psql -U postgres -c "CREATE DATABASE heimdall_local OWNER heimdall_svc;"

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:

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'

And that the credentials themselves work, end to end:

docker run --rm --add-host host.docker.internal:host-gateway postgres:18-alpine psql "postgresql://heimdall_svc:<password>@host.docker.internal:5432/heimdall_local" -c "select 1"

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.

4. Write the env file

Copy-Item docker/local.env.example docker/local.env -Confirm

-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:

VariableValue
DB_USER / DB_PASSWORDThe login from step 2
DB_NAMEheimdall_local
HEIMDALL_AUTH_TOKEN_SECRETA 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_AUDIENCERequired, 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 / _PASSWORDThe 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:

[Convert]::ToBase64String((1..48 | ForEach-Object { Get-Random -Maximum 256 }))

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.

5. Build and start

docker compose --env-file docker/local.env up -d --build

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

docker compose --env-file docker/local.env ps

Wait for STATUS to read Up (healthy) — the health check has a 30 s start period, so starting is expected at first. Then:

curl.exe http://localhost:8080/healthcheck

The start-up log should show the migrations applied and then the API listening:

docker compose --env-file docker/local.env logs -f api

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

TaskCommand
Follow the logsdocker compose --env-file docker/local.env logs -f api
Restart after an env changedocker compose --env-file docker/local.env up -d
Rebuild after a code changedocker compose --env-file docker/local.env up -d --build
Stop, keeping the log volumedocker compose --env-file docker/local.env down
Stop and discard the logsdocker compose --env-file docker/local.env down -v
A shell in the containerdocker 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.

PushDeploys toAfterwards
developdevelopmentLeft stopped if it was stopped
release/x.y.zhomologationLeft stopped if it was stopped
Pull request release/x.y.z → main, every check greenproductionJenkins 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:

sudo -u postgres createuser --pwprompt heimdall_development_owner
sudo -u postgres createuser --pwprompt heimdall_development_svc
sudo -u postgres createdb --owner heimdall_development_owner heimdall_development

sudo -u postgres createuser --pwprompt heimdall_homologation_owner
sudo -u postgres createuser --pwprompt heimdall_homologation_svc
sudo -u postgres createdb --owner heimdall_homologation_owner heimdall_homologation

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:

host    heimdall                 heimdall_svc                   172.16.0.0/12    scram-sha-256
host    heimdall                 heimdall_owner                 172.16.0.0/12    scram-sha-256
host    heimdall_development     heimdall_development_svc       172.16.0.0/12    scram-sha-256
host    heimdall_development     heimdall_development_owner     172.16.0.0/12    scram-sha-256
host    heimdall_homologation    heimdall_homologation_svc      172.16.0.0/12    scram-sha-256
host    heimdall_homologation    heimdall_homologation_owner    172.16.0.0/12    scram-sha-256

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

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:

docker run --rm --add-host host.docker.internal:host-gateway postgres:18-alpine psql "postgresql://heimdall_development_svc:<password>@host.docker.internal:5432/heimdall" -c "select 1"

4. Write each environment’s env file

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:

cp docker/development.env.example /etc/yggdrasil/development/heimdall-api.env

Replace example.com with the real domain, then fill in at least:

VariableValue
DB_USER / DB_PASSWORDThat environment’s runtime login (…_svc) from step 2
HEIMDALL_MIGRATIONS_CONNECTIONSTRINGOptional: the owner role’s whole connection string, when the environment has one (Database roles )
HEIMDALL_AUTH_TOKEN_SECRETA 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_AUDIENCERequired, 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 / _PASSWORDThat environment’s first system administrator, seeded on the first run against its empty database
MAILGUN_API_KEY / MAILGUN_DOMAINProduction only — it refuses to start without them
MAILGUN_FROMThe 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:

Variabledevelopmenthomologationproduction
PUBLIC_HOSTheimdall-api-dev.example.comheimdall-api-hml.example.comheimdall-api.example.com
UI_HOSTheimdall-dev.example.comheimdall-hml.example.comheimdall.example.com
ASPNETCORE_ENVIRONMENTDevelopmentStagingProduction
HEIMDALL_ALLOW_SENSITIVE_DIAGNOSTICSemptyemptyempty
DB_NAMEheimdall_developmentheimdall_homologationheimdall
HEIMDALL_EMAIL_VERIFICATION_URLhttps://heimdall-dev.example.com/verify-emailhttps://heimdall-hml.example.com/verify-emailhttps://heimdall.example.com/verify-email
HEIMDALL_PASSWORD_RESET_URLhttps://heimdall-dev.example.com/password-resethttps://heimdall-hml.example.com/password-resethttps://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.

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:

curl https://heimdall-api-dev.example.com/healthcheck
curl https://heimdall-dev.example.com/api/healthcheck

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

TaskCommand, on the VPS
Turn development or homologation on / offscripts/ygg.sh env start development / scripts/ygg.sh env stop development
What runs in each environmentscripts/ygg.sh env status
Follow the logsdocker compose -p heimdall-api-<environment> logs -f api
Change the env file and redeployscripts/ygg.sh config heimdall-api
A shell in the containerdocker 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

SymptomCauseFix
set DB_USER in the env file at upCompose interpolation found the variable emptyFill 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 PostgreSQLRe-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 ASCIIA 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 ownerRe-run the grants in Database roles
No developer exception page, and no EF parameter values in the logExpected when the instance serves a public host: NFR-28 keeps them off, and start-up logs one warning naming the hostsSet HEIMDALL_ALLOW_SENSITIVE_DIAGNOSTICS=true only if the environment holds made-up people and the diagnostics are wanted; see Operations
no pg_hba.conf entry for host "..."The connection arrives from an address no rule coversAdd a host rule for that address, then systemctl reload postgresql
relation "role" already exists on the second runThe login is named heimdall, so "$user" resolves to the entities’ schemaRename the login, or keep Search Path=public in every connection string
Browser front end gets a CORS errorHEIMDALL_CORS_ALLOWED_ORIGINS is emptyList the front end’s origin exactly as the browser sends it
Every request is logged from the same address, and callers hit 429 togetherHEIMDALL_TRUSTED_PROXIES does not cover the proxySet it to the address or network the proxy connects from (Operations → Client addresses behind a proxy)
docker compose ps shows nothing (local)--env-file omitted, so COMPOSE_PROJECT_NAME is unsetPass the env file on every Compose command
docker compose ps shows nothing (VPS)The project is named after the environmentdocker compose -p heimdall-api-<environment> ps
heimdall-api-dev.example.com answers 404Development is stopped, so Traefik has no router for itscripts/ygg.sh env start development
Health check never leaves startingThe API is up but /healthcheck is not answeringlogs -f api; the start period is 30 s, five retries at 15 s after that

Next

9 - Operations

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.
WITH ids (tbl, public_id) AS (
    SELECT 'person', public_id FROM heimdall.person
    UNION ALL SELECT 'google_user', public_id FROM heimdall.google_user
    UNION ALL SELECT 'scope', public_id FROM heimdall.scope
    UNION ALL SELECT 'application', public_id FROM heimdall.application
    UNION ALL SELECT 'application_credential', public_id FROM heimdall.application_credential
    UNION ALL SELECT 'scope_permission', public_id FROM heimdall.scope_permission
    UNION ALL SELECT 'role', public_id FROM heimdall.role
    UNION ALL SELECT 'pending_registration', public_id FROM heimdall.pending_registration
)
SELECT public_id, string_agg(tbl, ', ' ORDER BY tbl) AS held_by
FROM ids
GROUP BY public_id
HAVING count(*) > 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' AS tbl, p.public_id, count(*) AS earlier_entries
FROM heimdall.person p
JOIN heimdall.audit_log a ON a.target_id = p.public_id
WHERE a.created_at < p.created_at - interval '1 minute'
GROUP BY p.public_id
UNION ALL
SELECT 'google_user', g.public_id, count(*)
FROM heimdall.google_user g
JOIN heimdall.audit_log a ON a.target_id = g.public_id
WHERE a.created_at < g.created_at - interval '1 minute'
GROUP BY g.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:

RoleExampleOwnsUsed by
Ownerheimdall_ownerThe database, the heimdall schema, every table, sequence and trigger function, and the migrations historyMigrations only: the container entrypoint through HEIMDALL_MIGRATIONS_CONNECTIONSTRING, or scripts/migrations.py run out of band
Runtimeheimdall_svcNothingThe 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.
GRANT CONNECT ON DATABASE heimdall TO heimdall_svc;
REVOKE CREATE ON SCHEMA public FROM PUBLIC;
CREATE SCHEMA IF NOT EXISTS heimdall AUTHORIZATION heimdall_owner;
GRANT USAGE ON SCHEMA heimdall TO heimdall_svc;

-- What exists already.
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA heimdall TO heimdall_svc;
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA heimdall TO heimdall_svc;
GRANT SELECT ON ALL TABLES IN SCHEMA public TO heimdall_svc;

-- What later migrations create, as the owner, gets the same.
ALTER DEFAULT PRIVILEGES FOR ROLE heimdall_owner IN SCHEMA heimdall
    GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO heimdall_svc;
ALTER DEFAULT PRIVILEGES FOR ROLE heimdall_owner IN SCHEMA heimdall
    GRANT USAGE, SELECT ON SEQUENCES TO heimdall_svc;
ALTER DEFAULT PRIVILEGES FOR ROLE heimdall_owner IN SCHEMA public
    GRANT SELECT ON TABLES TO heimdall_svc;

-- Part 2. The audit trail is never deleted from. Its triggers decide which updates it accepts.
REVOKE DELETE ON heimdall.audit_log FROM heimdall_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.
SELECT classid::regclass AS catalog, objid FROM pg_shdepend
WHERE deptype = 'o' AND refobjid = 'heimdall_svc'::regrole
  AND dbid IN (0, (SELECT oid FROM pg_database WHERE datname = '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' AS kind, datname AS name, pg_get_userbyid(datdba) AS owner
FROM pg_database WHERE datname = 'heimdall' AND datdba <> 'heimdall_owner'::regrole
UNION ALL
SELECT 'schema', nspname, pg_get_userbyid(nspowner)
FROM pg_namespace WHERE nspname = 'heimdall' AND nspowner <> 'heimdall_owner'::regrole
UNION ALL
SELECT 'relation', n.nspname || '.' || c.relname, pg_get_userbyid(c.relowner)
FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE n.nspname IN ('heimdall', 'public') AND c.relowner <> 'heimdall_owner'::regrole
UNION ALL
SELECT 'function', n.nspname || '.' || p.proname, pg_get_userbyid(p.proowner)
FROM pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace
WHERE n.nspname = 'heimdall' AND p.proowner <> 'heimdall_owner'::regrole;

-- 3. The runtime login's privileges and flags.
SELECT has_table_privilege(r.oid, 'heimdall.audit_log', 'DELETE') AS audit_delete,
       has_table_privilege(r.oid, 'heimdall.person', 'TRUNCATE') AS truncate,
       has_schema_privilege(r.oid, 'heimdall', 'CREATE') AS create_in_heimdall,
       has_schema_privilege(r.oid, 'public', 'CREATE') AS create_in_public,
       has_database_privilege(r.oid, 'heimdall', 'CREATE') AS create_schema,
       pg_has_role(r.oid, 'heimdall_owner', 'MEMBER') AS member_of_owner,
       r.rolsuper, r.rolcreaterole, r.rolcreatedb, r.rolbypassrls
FROM pg_roles r WHERE r.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:

BEGIN; ALTER TABLE heimdall.audit_log DISABLE TRIGGER USER; ROLLBACK;

A new deployment

  1. Create the two logins and the database, owned by the owner:

    sudo -u postgres createuser --pwprompt heimdall_owner
    sudo -u postgres createuser --pwprompt heimdall_svc
    sudo -u postgres createdb --owner heimdall_owner heimdall
    

    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).

  2. Run part 1 of the grants.

  3. 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.

  4. Run part 2 of the grants.

  5. 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.

  1. First deploy a release whose entrypoint reads HEIMDALL_MIGRATIONS_CONNECTIONSTRING — this one or later. On an older image the variable is ignored.

  2. Create the owner login: sudo -u postgres createuser --pwprompt heimdall_owner, and its pg_hba.conf rule as above.

  3. 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.

  4. As a superuser, connected to the environment’s database (sudo -u postgres psql -d heimdall):

    BEGIN;
    SET LOCAL lock_timeout = '5s';
    ALTER DATABASE heimdall OWNER TO heimdall_owner;
    REASSIGN OWNED BY heimdall_svc TO heimdall_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.

  5. 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.

  6. 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:

BEGIN;
SET LOCAL lock_timeout = '5s';
ALTER DATABASE heimdall OWNER TO heimdall_svc;
REASSIGN OWNED BY heimdall_owner TO heimdall_svc;
COMMIT;

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

EndpointWhoWhat it does
GET /api/healthcheckAnonymousLiveness. Confirms the process is up and responding. Reads no database.
GET /api/healthcheck/detailedSystem AdminReports 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.

{
  "status": "Healthy",
  "services": [
    { "name": "Database", "status": "Healthy" }
  ]
}

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".

VariableDefaultEffect
HEIMDALL_METRICS_PORT9464The 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.

A minimal scrape job:

scrape_configs:
  - job_name: heimdall-api
    static_configs:
      - targets: ["api:9464"]

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:

TargetBudgetOn exhaustion
Password (UC-11)10 consecutive failuresThe 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 codeThe 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 codeSingle useTWO_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 redemptionTWO_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 challengeThe 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 challengeSecond-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 accountThese 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.

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.
UPDATE heimdall.person
SET failed_second_factor_attempts = 0,
    second_factor_locked_out_until = NULL,
    second_factor_lockouts = 0
WHERE public_id = '00000000-0000-0000-0000-000000000000';

-- Re-authentication pause (FR-AU-13): the count, the window and the pause.
UPDATE heimdall.person
SET failed_reauthentication_attempts = 0,
    reauthentication_window_started_at = NULL,
    reauthentication_paused_until = NULL
WHERE public_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.

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:

MailBound
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:

HeaderValue
X-Heimdall-Caller-KeyOne of the configured keys
X-Heimdall-Client-AddressThe 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.

VariableDefaultMeaning
HEIMDALL_DATA_REQUIRE_TLSfalsetrue 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.

VariableDefaultAccepted rangeMeaning
HEIMDALL_RETENTION_TOKEN_GRACE_DAYS7more than 0, up to 3650Days a single-use token is kept past expiry
HEIMDALL_RETENTION_PURGE_INTERVAL_MINUTES601 to 10080 (7 days)Interval between purge runs
HEIMDALL_RETENTION_PURGE_BATCH_SIZE500any positive integerMost rows removed from one table per run
HEIMDALL_RETENTION_PURGE_ENABLEDtruetrue / falseSet false to stop scheduling the purge
HEIMDALL_RETENTION_ERASURE_DEADLINE_DAYS30more than 0, up to 30Days before a requested erasure is anonymised
HEIMDALL_RETENTION_DELETION_WINDOW_DAYS90more than 0, up to 730Days before an administrative deletion is anonymised
HEIMDALL_RETENTION_ANONYMISATION_ENABLEDtruetrue / falseSet false to stop scheduling the anonymisation
HEIMDALL_RETENTION_AUDIT_ACTOR_DAYS548 (18 months)30 to 3650Days an audit entry stays attributed
HEIMDALL_RETENTION_AUDIT_PSEUDONYMISATION_ENABLEDtruetrue / falseSet false to stop scheduling it
HEIMDALL_RETENTION_LOG_DAYS365 (12 months)1 to 3650Days 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.

KindRaised when
REPEATED_REFUSALSOne identity exceeds the refusal threshold within the window
LOCKOUT_SPIKEMore accounts are locked out at once than the threshold
CREDENTIAL_VERIFICATION_SHEDDINGThe hash gate refuses more often than the threshold (TH-03)
VariableDefaultAccepted rangeMeaning
HEIMDALL_MONITORING_ENABLEDtruetrue / falseSet false to stop detecting
HEIMDALL_MONITORING_WINDOW_MINUTES151 to 1440How far back each signal looks, and how often it runs
HEIMDALL_MONITORING_REFUSAL_THRESHOLD20any positive integerRefusals by one identity before alerting
HEIMDALL_MONITORING_LOCKOUT_THRESHOLD10any positive integerAccounts 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)

To see what has been raised so far:

docker logs heimdall-api 2>&1 | grep SECURITY_SIGNAL
grep -h SECURITY_SIGNAL /path/to/logs/log-*.json

Collecting them — scripts/security_signals.py

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 nothing
python3 scripts/security_signals.py               # report new signals by e-mail
python3 scripts/security_signals.py --stdout --all  # ignore the watermark, print everything
VariableRequiredMeaning
HEIMDALL_LOG_DIRECTORYIf not ./logsWhere the API writes log-*.json — the same variable the API reads
HEIMDALL_SIGNAL_ALERT_TOYes, unless --stdoutWhere alerts go
HEIMDALL_SIGNAL_ALERT_FROMNoSender, default heimdall-signals@$MAILGUN_DOMAIN
HEIMDALL_SIGNAL_STATE_FILENoThe watermark, default <log directory>/.security-signals-state
MAILGUN_API_KEY, MAILGUN_DOMAINYes, unless --stdoutThe API’s own credentials, unchanged
MAILGUN_API_BASE_URLNoDefault 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:

MAILTO=you@example.com
*/15 * * * * cd /srv/heimdall && /usr/bin/python3 scripts/security_signals.py --quiet

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:

docker run --rm \
  --volumes-from heimdall-api \
  -e HEIMDALL_LOG_DIRECTORY=/app/logs \
  -e HEIMDALL_SIGNAL_ALERT_TO -e MAILGUN_API_KEY -e MAILGUN_DOMAIN \
  -v "$PWD/scripts:/scripts:ro" python:3-slim \
  python3 /scripts/security_signals.py --quiet

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 APIValue
From a container on a Docker network shared with apiThat 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_PORTThe 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:

VariableValue
MAILGUN_API_KEYYour Mailgun private API key
MAILGUN_DOMAINThe Mailgun sending domain
MAILGUN_API_VERSIONOptional; defaults to v3
MAILGUN_FROMOptional; 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>
VariableValue
HEIMDALL_EMAIL_VERIFICATION_URLFront-end page that verifies an email address
HEIMDALL_PASSWORD_RESET_URLFront-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).
StateBehaviour
ConfiguredEmails are sent.
Unconfigured, not ProductionEach 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, ProductionStart-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

VariableValue
HEIMDALL_GOOGLE_CLIENT_IDSComma-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.

  1. 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.
  2. 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.
  3. 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

VariableRequiredDefault
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)
HEIMDALL_MASTER_USER_NAME / _EMAIL / _PASSWORD✅—
HEIMDALL_AUTH_TOKEN_ISSUER / _AUDIENCEempty
HEIMDALL_AUTH_TOKEN_EXPIRATION_IN_SECONDS3600
HEIMDALL_AUTH_TOKEN_SECRET_PREVIOUSempty (when set, at least 32 bytes of ASCII)
HEIMDALL_APPLICATION_TOKEN_EXPIRATION_IN_SECONDS600
HEIMDALL_AUTH_MAX_CONCURRENT_PASSWORD_HASHES4
HEIMDALL_EMAIL_VERIFICATION_TOKEN_EXPIRATION_IN_SECONDS86400
HEIMDALL_PASSWORD_RESET_TOKEN_EXPIRATION_IN_SECONDS3600
HEIMDALL_ADMIN_EMAIL_COOLDOWN_IN_SECONDS300; 0 → no cooldown; outside 0–86400 → 300
HEIMDALL_RETENTION_TOKEN_GRACE_DAYS7
HEIMDALL_RETENTION_PURGE_INTERVAL_MINUTES60
HEIMDALL_RETENTION_PURGE_BATCH_SIZE500
HEIMDALL_RETENTION_PURGE_ENABLEDtrue
HEIMDALL_RETENTION_ERASURE_DEADLINE_DAYS30
HEIMDALL_RETENTION_DELETION_WINDOW_DAYS90
HEIMDALL_RETENTION_ANONYMISATION_ENABLEDtrue
HEIMDALL_RETENTION_AUDIT_ACTOR_DAYS548
HEIMDALL_RETENTION_AUDIT_PSEUDONYMISATION_ENABLEDtrue
HEIMDALL_DATA_REQUIRE_TLSfalse
HEIMDALL_MIGRATIONS_CONNECTIONSTRINGunset → the entrypoint migrates with HEIMDALL_DATA_CONNECTIONSTRING; read by the entrypoint only (see Database roles )
PUBLIC_HOST / UI_HOSTunset → these two name no public host (the verification and reset URLs and the CORS origins are read too; see Diagnostics on a public host )
HEIMDALL_ALLOW_SENSITIVE_DIAGNOSTICSfalse → with a public host, EF Core’s sensitive logging and detailed errors and the developer exception page are off
HEIMDALL_RETENTION_LOG_DAYS365
HEIMDALL_MONITORING_ENABLEDtrue
HEIMDALL_MONITORING_WINDOW_MINUTES15
HEIMDALL_MONITORING_REFUSAL_THRESHOLD20
HEIMDALL_MONITORING_LOCKOUT_THRESHOLD10
HEIMDALL_LOG_DIRECTORYlogs
HEIMDALL_METRICS_PORT9464; 0 → metrics off
HEIMDALL_CORS_ALLOWED_ORIGINSunset → every cross-origin request is refused
HEIMDALL_TRUSTED_PROXIESunset → forwarded headers ignored; every caller is the proxy
HEIMDALL_TRUSTED_CALLER_KEYSunset → no client system may name its users’ addresses; keys under 32 characters stop start-up
HEIMDALL_GOOGLE_CLIENT_IDSunset → Google sign-in refuses every token
MAILGUN_API_KEY / MAILGUN_DOMAINunset → tokens logged (fails start-up in Production)
MAILGUN_API_VERSIONv3
HEIMDALL_EMAIL_VERIFICATION_URL / HEIMDALL_PASSWORD_RESET_URLunset → the email carries the bare token

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 full operational specification is the Operations & Infrastructure Document .

10 - Requirements

The specification: vision, system requirements, use cases, technology stack, data protection, testing, workflow, and operations.

These fourteen documents are the specification for Heimdall. Everything else on this site describes what was built; these say what must be true.

They are also the working documents of the project, not a retrospective write-up: a feature is implemented by picking a use case, following its main and alternative flows, and citing the requirement identifiers in the code.

DocumentWhat it settles
Vision DocumentWhy the system exists, who it serves, and what success looks like.
System Requirements DocumentThe functional (FR-…) and non-functional (NFR-…) requirements, the data model, the endpoints, the authorization matrix, and the deletion strategy.
Use Case Specification DocumentEvery use case (UC-…) with its main flow and numbered alternative flows (AF-11a, AF-11b, …).
Technology Stack DocumentThe technologies, libraries, and pinned versions. The single source of truth for versions.
Data Retention Schedule DocumentHow long each category of personal data is kept and why — the record NFR-19 requires, and the source of truth the retention passes are configured against.
Data Protection DocumentWho controls what, on what basis, shared with whom, and across which borders — the record LGPD Art. 37 and GDPR Art. 30 require.
Data Protection Impact AssessmentThe risks this system creates for the people in it, rated for them rather than for the system.
Privacy NoticeWhat Heimdall does with personal data, told to the people it belongs to. Versioned, and the Encarregado’s published contact.
Data Processing AgreementThe terms on which Heimdall processes a tenant’s users’ data, and the sub-processor list.
Incident Response DocumentWhat counts as a personal data breach, who declares one, the clocks that start, and who has to be told.
Testing Specification DocumentWhat to test for each use case, the unit and functional standards, and the required coverage.
Development Workflow DocumentHow a use case goes from backlog to merged — branch, issue status, testing gate, pull request.
Operations & Infrastructure DocumentThe technical foundation and the health-check feature.

Identifier conventions

PrefixMeaningExample
FR-XX-nnFunctional requirement, grouped by capabilityFR-AU-04 — the claims an auth token carries
NFR-nnNon-functional requirementNFR-15 — internal ids never reach a caller
UC-nnUse caseUC-11 — Login
AF-nnaAlternative flow within a use caseAF-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).

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

TermDefinition
ScopeA logical tenant boundary that isolates owners, users, and applications belonging to different client systems
PersonA 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
ApplicationA 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 OwnerA Scope Admin who owns a scope; a scope may have multiple owners and a Scope Admin may own multiple scopes
Scope UserA User belonging to exactly one scope
RoleA permission level assigned to a person (User, Scope Admin, System Admin), stored as a reference to a dedicated Role table
SaltA random byte array unique to each person, combined with their password before hashing
Google UserA 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 IdAn auto-incrementing bigint primary key used only inside the database, for joins and foreign keys. Never exposed in API responses, URLs, or tokens
Public IdA 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 DeletionSoft-deleting a record by flagging it as inactive without removing it from the database
Hard DeletionPermanently 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

AttributeDescription
ForDevelopment teams and organizations operating multiple systems
WhoNeed centralized user identity and authentication management
The Heimdall APIIs a .NET Web API
ThatProvides scoped person management, role-based access, authentication, password recovery, and email verification
UnlikeSystem-specific user management modules that create data silos
Our productOffers a single, multi-tenant identity service with clear scope isolation and flexible deletion strategies

4. Stakeholders

StakeholderRoleConcern
System AdminGlobal administrator, belongs to no scopeFull control over all scopes, persons, and applications
Scope AdminOwner of one or more scopesManagement of the users and applications within the scopes they own
End UserConsumer of client systems, belongs to exactly one scopeSeamless authentication, password recovery, and email verification
Client SystemsExternal applicationsReliable 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

IDFeatureDescription
F-01Person CRUDCreate, read, update, and delete person records
F-02Scope CRUDCreate, read, update, and delete scope records
F-03Logical DeletionSoft-delete persons and scopes via a boolean flag
F-04Hard DeletionPermanently remove persons and scopes from storage
F-05Scope IsolationEvery User belongs to exactly one scope; every scope may have one or more owners (Scope Admins) and one or more Users
F-06Role AssignmentEach person is assigned a role: User, Scope Admin, or System Admin, each with a distinct relationship to scopes
F-07AuthenticationVerify credentials and issue authentication tokens
F-08Password RecoveryAllow users to reset their password via email
F-09Email VerificationConfirm person email addresses through a verification flow
F-10Application ManagementCreate, read, update, and delete Application records representing non-person systems, each owned by a Scope Admin who owns the application’s scope
F-11Google Sign-InAllow 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
RoleScope RelationshipPermissions
System AdminBelongs to no scopeFull access: manage all scopes, all persons, and all applications across the entire system
Scope AdminOwns one or more scopesManage 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
UserBelongs to exactly one scopeAuthenticate, 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

TermDefinition
PersonA registered identity containing id, name, email, role, and deletion status. Has no direct scope attribute — its relationship to scopes depends on its role
ApplicationA 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
ScopeA logical boundary that groups owners, persons, and applications belonging to a specific client system
Scope OwnerA 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 UserA Person with the User role who belongs to exactly one scope (many-to-one relationship)
Scope PermissionA 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 UserA 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 SecretA 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 CodeOne 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 TokenA 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 DeletionSetting the IsDeleted flag to true without removing the record
Hard DeletionPermanently removing the record from the database
RoleA named permission level (User, ScopeAdmin, or SystemAdmin) stored in the Role table and referenced by a person via RoleId
SaltA randomly generated byte array combined with a person’s password prior to hashing, stored alongside the password hash
Google ID TokenA 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 IdAn 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 IdA 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

IDRequirementPriority
FR-SC-01The 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-02The 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 toHigh
FR-SC-03The 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-02High
FR-SC-04The system shall allow System Admins to update scope informationHigh
FR-SC-05The system shall allow System Admins to logically delete a scope by setting IsDeleted = trueHigh
FR-SC-06The system shall allow System Admins to hard delete a scope, permanently removing it and its associated users and applicationsHigh
FR-SC-07Logically deleted scopes shall not appear in default query results unless explicitly requestedMedium
FR-SC-08A 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 countHigh
FR-SC-09The 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 scopeHigh
FR-SC-10The system shall allow System Admins and existing owners of a scope to remove an owner from a scope, provided at least one owner remainsHigh
FR-SC-11A scope shall be able to contain one or more Persons with the User roleHigh
FR-SC-12The 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 scopeHigh
FR-SC-13The 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 membershipHigh

3.2 Person Management

IDRequirementPriority
FR-PE-01The system shall allow creation of a person with: Id, Name, Email, PasswordHash, Salt, RoleId, IsDeleted. A person has no direct ScopeId attributeHigh
FR-PE-02A person with the User role must be associated with exactly one scope, via scope membership, at creation timeHigh
FR-PE-03The system shall allow reading a person’s details by IDHigh
FR-PE-04The 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-05The system shall allow updating person information (name, email, role)High
FR-PE-06The system shall allow logical deletion of a person by setting IsDeleted = trueHigh
FR-PE-07The system shall allow hard deletion of a person, permanently removing the recordHigh
FR-PE-08Logically deleted persons shall not appear in default query results unless explicitly requestedMedium
FR-PE-09A 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-insensitiveHigh
FR-PE-10A person with the SystemAdmin role must not be associated with any scope, either as an owner or as a userHigh
FR-PE-11A 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 §8High
FR-PE-12The 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 ownersHigh
FR-PE-13The 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-12Medium
FR-PE-14A 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 trimmedHigh
FR-PE-15On 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 updateHigh
FR-PE-16The 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 anonymisationMedium
FR-PE-17On 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-18A 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 affectedMedium
FR-PE-19A 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 tenantsHigh

3.3 Role Assignment

IDRequirementPriority
FR-RO-01Every person must reference exactly one role via RoleId, pointing to a row in the Role table (User, ScopeAdmin, or SystemAdmin)High
FR-RO-02Only System Admins shall be able to assign or change a person’s role to SystemAdminHigh
FR-RO-03System 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-04The system shall store passwords as a PasswordHash byte array together with a Salt byte array used to compute the hashHigh
FR-RO-05Scope Admins (owners of a scope) shall be able to assign the User role to persons within that scopeHigh
FR-RO-06System Admins have all permissions across the entire system and are exempt from scope-based authorization checksHigh

3.4 Authentication

IDRequirementPriority
FR-AU-01The system shall authenticate a User using email, password, and scope ID (email is only unique within a scope for Users)High
FR-AU-02The 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-03Upon successful authentication, the system shall return an authentication token (e.g., JWT)High
FR-AU-04The 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 tokenHigh
FR-AU-05The system shall reject authentication attempts for logically deleted personsHigh
FR-AU-06The system shall reject authentication attempts for a User whose scope is logically deletedHigh
FR-AU-07The system shall reject authentication attempts for a ScopeAdmin if all scopes they own are logically deletedHigh
FR-AU-08Upon 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 claimedMedium
FR-AU-09The 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 timeHigh
FR-AU-10The 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 messageHigh
FR-AU-11Every 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 oneHigh
FR-AU-12The 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-13The 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 inHigh

3.5 Password Recovery

IDRequirementPriority
FR-PR-01The system shall allow a person to request a password reset by providing their emailHigh
FR-PR-02The 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-03The system shall allow the person to set a new password using a valid reset tokenHigh
FR-PR-04Expired or already-used reset tokens shall be rejectedHigh
FR-PR-05The 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 acceptedMedium
FR-PR-06At 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 sentMedium
FR-PR-07At 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 recoveryMedium
FR-PR-08When 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 passwordHigh

3.6 Email Verification

IDRequirementPriority
FR-EV-01Upon person creation, the system shall send a verification email to the person’s email addressHigh
FR-EV-02The verification email shall contain a time-limited verification tokenHigh
FR-EV-03The system shall mark the person’s email as verified upon receiving a valid verification tokenHigh
FR-EV-04The system shall allow re-sending the verification emailMedium
FR-EV-05On successful authentication, the system shall report whether the authenticated person’s email address is verifiedMedium
FR-EV-06The 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 acceptedMedium
FR-EV-07At 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 insteadMedium
FR-EV-08When 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 withHigh

3.7 Application Management

IDRequirementPriority
FR-AP-01The system shall allow creation of an application with: Id, Name, ScopeId, OwnerId, IsDeletedHigh
FR-AP-02Every application must be associated with exactly one scope at creation timeHigh
FR-AP-03Every 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 applicationHigh
FR-AP-04The system shall allow reading an application’s details by IDHigh
FR-AP-05The system shall allow listing applications within a scope (with pagination and filtering)High
FR-AP-06The system shall allow updating an application’s name and ownerHigh
FR-AP-07The system shall allow logical deletion of an application by setting IsDeleted = trueHigh
FR-AP-08The system shall allow hard deletion of an application, permanently removing the recordHigh
FR-AP-09Logically deleted applications shall not appear in default query results unless explicitly requestedMedium
FR-AP-10The 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 expiryHigh
FR-AP-11An application shall hold at most two live credentials (not revoked, not expired), so a new secret can be deployed before the old one is revokedHigh
FR-AP-12The same actors shall be able to list an application’s credentials — never the secret or its hash — and revoke any one of themHigh
FR-AP-13The 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 roleHigh
FR-AP-14Every refused exchange shall answer the same 401 with the same message, revealing nothing about which applications, scopes or credentials existHigh
FR-AP-15The system shall let a consumer check an application token live, confirming the application, its scope and the issuing credential are still liveHigh
FR-AP-16Revoking or expiring a credential, or deleting the application or its scope, shall make every token issued from it fail the live check at onceHigh

3.8 Google Sign-In

IDRequirementPriority
FR-GO-01A scope shall have a boolean GoogleSignInEnabled flag, default false, controlling whether Google sign-up/sign-in is available for that scopeHigh
FR-GO-02Only System Admins and existing owners of a scope shall be able to enable or disable GoogleSignInEnabled for that scopeHigh
FR-GO-03The system shall allow Google sign-up/sign-in only for scopes where GoogleSignInEnabled = trueHigh
FR-GO-04Only accounts equivalent to the User role may sign up or sign in via Google; Google authentication shall never create or authenticate a ScopeAdmin or SystemAdminHigh
FR-GO-05The system shall register a Google User with: Id, GoogleId, Name, Email, EmailVerified, ProfilePictureUrl, IsDeleted, ScopeIdHigh
FR-GO-06A Google User must be associated with exactly one scope, matching the scope in which the sign-up/sign-in was initiatedHigh
FR-GO-07A 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-08A Google User’s GoogleId (Google’s sub claim) must be unique within its scopeHigh
FR-GO-09On 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 claimsHigh
FR-GO-10On subsequent sign-ins with the same Google account and scope, the system shall authenticate the existing Google User by GoogleId, without creating a duplicate recordHigh
FR-GO-11The system shall verify the Google ID token’s signature, issuer, audience, and expiration before trusting its claimsHigh
FR-GO-12The system shall reject Google sign-in attempts for logically deleted Google UsersHigh
FR-GO-13The system shall reject Google sign-in attempts for a scope that is logically deleted or has GoogleSignInEnabled = falseHigh
FR-GO-14The 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-15The system shall allow logical deletion of a Google User by setting IsDeleted = trueHigh
FR-GO-16The system shall allow hard deletion of a Google User, permanently removing the recordHigh
FR-GO-17Logically deleted Google Users shall not appear in default query results unless explicitly requestedMedium
FR-GO-18The 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-19On 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 unchangedMedium
FR-GO-20A 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

IDRequirementPriority
FR-SR-01A scope shall have a boolean SelfRegistrationEnabled flag, default false, controlling whether anonymous callers may register password Users in itHigh
FR-SR-02Only System Admins and existing owners of a scope shall be able to enable or disable SelfRegistrationEnabled for that scopeHigh
FR-SR-03The 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 UsersHigh
FR-SR-04A self-registered address shall be unique in the scope among live persons and Google Users, compared case-insensitivelyHigh
FR-SR-05When 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 deletedHigh
FR-SR-06The system shall answer a registration for a free and a taken address identically, in content and in timeHigh
FR-SR-07No account shall exist for a self-registered address until the link mailed to it is followed; the account is then created with its address verifiedHigh
FR-SR-08Pending registrations shall expire with the email-verification token lifetime and be purged with the other single-use tokensHigh
FR-SR-09Confirming 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 10High
FR-SR-10A 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 charactersMedium
FR-SR-11The 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 returnedMedium
FR-SR-12The 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 targetMedium
FR-SR-13An 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-14An 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

IDRequirementPriority
FR-SP-01The system shall allow creation of a scope permission with: Id, PublicId, Name, Description (optional), IncludeAsJwtClaim, ScopeId, IsDeletedHigh
FR-SP-02Every 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 authorizationHigh
FR-SP-03A 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-04The 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 ownHigh
FR-SP-05The 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 ownHigh
FR-SP-06The system shall allow updating a scope permission’s Name, Description, and IncludeAsJwtClaim flagHigh
FR-SP-07The system shall allow logical deletion of a scope permission by setting IsDeleted = true. Nothing cascades: a scope permission owns no dependent rowHigh
FR-SP-08The system shall allow hard deletion of a scope permission, permanently removing the record. Restricted to System Admins; nothing cascadesHigh
FR-SP-09Logically deleted scope permissions shall not appear in default query results unless explicitly requestedMedium

3.10 Two-Factor Authentication

IDRequirementPriority
FR-2F-01The 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 securityHigh
FR-2F-02The 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 initiationHigh
FR-2F-03The 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-04Setup 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 requestHigh
FR-2F-05Confirming 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’sHigh
FR-2F-06Each 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 rejectedHigh
FR-2F-07When 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 personHigh
FR-2F-08If the email method is enabled for the person, issuing a challenge token shall also send a fresh email code, per FR-2F-03High
FR-2F-09The 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 describeHigh
FR-2F-10The 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 tooHigh
FR-2F-11Disabling 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 codesHigh
FR-2F-12Regenerating 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-05High
FR-2F-13The 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 expiredHigh
FR-2F-14The 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 windowHigh
FR-2F-15The 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 codeHigh
FR-2F-16The 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 enabledHigh
FR-2F-17The 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 insteadHigh
FR-2F-18Initiating 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-13High
FR-2F-19A 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 chargedMedium
FR-2F-20A 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-16High

3.11 Audit Trail

IDRequirementPriority
FR-AT-01The 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 auditedHigh
FR-AT-02The 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 dataHigh

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

FieldTypeConstraints
IdBigIntPrimary key, auto-increment, internal only
PublicIdGUIDRequired, 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)
NameStringRequired, max 200 characters
EmailStringRequired, 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
PasswordHashByte arrayRequired, stores the hash computed from the password and Salt
SaltByte arrayRequired, randomly generated per person, used to hash the password
SecurityStampGUIDRequired, 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
FailedSecondFactorAttemptsIntegerDefault: 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
SecondFactorLockedOutUntilDateTimeNull unless the second factor is or was locked; while in the future, every second factor at sign-in is refused (FR-AU-12)
SecondFactorLockoutsIntegerDefault: 0; second-factor locks since the last successful second factor, which set how long the next one lasts (FR-AU-12)
FailedReauthenticationAttemptsIntegerDefault: 0; failed re-authentications in the current window (FR-AU-13)
ReauthenticationWindowStartedAtDateTimeNull unless a window is open; when its first failure was counted (FR-AU-13)
ReauthenticationPausedUntilDateTimeNull unless re-authentication is or was paused; while in the future, every re-authentication is refused (FR-AU-13)
AccountEmailsSentIntegerDefault: 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
AccountEmailWindowEndsAtDateTimeNull before the first such send; when the current one-day window ends (FR-PE-16)
AccountEmailLastSentAtDateTimeNull before the first such send; when the last was reserved, which the cooldown counts from (FR-PE-16)
IsDeletedBooleanDefault: false
EmailVerifiedBooleanDefault: false
RoleIdBigIntForeign key to Role.Id (internal), required
CreatedAtDateTimeAuto-set on creation
UpdatedAtDateTimeAuto-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

FieldTypeConstraints
IdBigIntPrimary key, auto-increment, internal only
PublicIdGUIDRequired, unique, auto-generated on creation; external identifier; immutable, enforced by trigger, and never the PublicId of another entity’s row when inserted (#199)
NameStringRequired, unique — User, ScopeAdmin, or SystemAdmin
DescriptionStringOptional, human-readable explanation of the role’s purpose and permissions

4.4 Application Fields

FieldTypeConstraints
IdBigIntPrimary key, auto-increment, internal only
PublicIdGUIDRequired, unique, auto-generated on creation; external identifier; immutable, enforced by trigger, and never the PublicId of another entity’s row when inserted (#199)
NameStringRequired, max 200 characters
IsDeletedBooleanDefault: false
ScopeIdBigIntForeign key to Scope.Id (internal), required
OwnerIdBigIntForeign key to Person.Id (internal), required
CreatedAtDateTimeAuto-set on creation
UpdatedAtDateTimeAuto-set on update

4.5 Scope Owner Fields

FieldTypeConstraints
ScopeIdBigIntForeign key to Scope.Id (internal), part of composite primary key
PersonIdBigIntForeign 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

FieldTypeConstraints
ScopeIdBigIntForeign key to Scope.Id (internal), required
PersonIdBigIntForeign 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

FieldTypeConstraints
IdBigIntPrimary key, auto-increment, internal only
PublicIdGUIDRequired, 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)
GoogleIdStringRequired, from Google’s sub claim; unique within the scope
NameStringFrom Google’s name claim, max 200 characters
EmailStringRequired, from Google’s email claim; unique within the scope, considered jointly with User persons’ emails in that scope
EmailVerifiedBooleanFrom Google’s email_verified claim
ProfilePictureUrlStringOptional, from Google’s picture claim
IsDeletedBooleanDefault: false
ScopeIdBigIntForeign key to Scope.Id (internal), required
CreatedAtDateTimeAuto-set on creation
UpdatedAtDateTimeAuto-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

FieldTypeConstraints
IdBigIntPrimary key, auto-increment, internal only
PublicIdGUIDRequired, unique, auto-generated on creation; external identifier; immutable, enforced by trigger, and never the PublicId of another entity’s row when inserted (#199)
NameStringRequired, max 200 characters
DescriptionStringOptional, max 500 characters
IncludeAsJwtClaimBooleanDefault: false; controls whether Name is added as a JWT claim at issue (FR-SP-03, FR-AU-08)
IsDeletedBooleanDefault: false
ScopeIdBigIntForeign key to Scope.Id (internal), required; cascade-deleted when the scope is hard-deleted
CreatedAtDateTimeAuto-set on creation
UpdatedAtDateTimeAuto-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

FieldTypeConstraints
IdBigIntPrimary key, auto-increment, internal only
PersonIdBigIntForeign key to Person.Id (internal), required and unique — one row per person
AppEnabledBooleanDefault: false
EmailEnabledBooleanDefault: false; at least one of AppEnabled/EmailEnabled must be true
TotpSecretEncryptedByte arrayRequired when AppEnabled = true, otherwise null; encrypted at rest, never returned once FR-2F-04 confirmation succeeds
IsActiveBooleanDefault: false; set true only once every selected method is confirmed (FR-2F-04)
LastTotpTimeStepUsedBigIntNull 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)
EmailCodeReissueCountIntegerDefault: 0; reissues the outstanding challenge has spent, reset when a login issues a new challenge (FR-2F-13, FR-2F-16)
ChallengeIdGUIDNull 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)
ChallengeAttemptsIntegerDefault: 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)
CreatedAtDateTimeAuto-set on creation
UpdatedAtDateTimeAuto-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

FieldTypeConstraints
IdBigIntPrimary key, auto-increment, internal only
TwoFactorAuthIdBigIntForeign key to TwoFactorAuth.Id (internal), required; cascade-deleted when the two-factor configuration is removed
CodeHashByte arrayRequired, the SHA-256 digest of the code’s canonical form (FR-2F-20) — the plaintext code exists only in the FR-2F-05 response
UsedBooleanDefault: false
UsedAtDateTimeSet when Used transitions to true; null until then
CreatedAtDateTimeAuto-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

MethodEndpointDescriptionAuth Required
POST/api/scopesCreate a new scope with at least one initial ownerSystemAdmin
GET/api/scopesList scopes — all of them for a SystemAdmin, the ones they own for a ScopeAdminSystemAdmin, ScopeAdmin
GET/api/scopes/{id}Get scope by IDAuthenticated
PUT/api/scopes/{id}Update a scopeSystemAdmin
DELETE/api/scopes/{id}Logically delete a scopeSystemAdmin
DELETE/api/scopes/{id}/hardHard delete a scopeSystemAdmin
POST/api/scopes/{id}/owners/{personId}Add an existing Scope Admin as an owner of the scopeSystemAdmin, existing Owner
POST/api/scopes/{id}/ownersCreate a brand-new ScopeAdmin person, directly made an owner of the scopeSystemAdmin, existing Owner
POST/api/scopes/{id}/users/{personId}/promotePromote an existing User of the scope to ScopeAdmin, making them a co-ownerSystemAdmin, existing Owner
GET/api/scopes/{id}/ownersList the owners of a scopeSystemAdmin, 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-signinEnable or disable Google Sign-In for the scope ({ enabled: bool })SystemAdmin, existing Owner
PUT/api/scopes/{id}/self-registrationOpen or close self-registration for a scope (FR-SR-01, FR-SR-02)SystemAdmin, existing Owner
PUT/api/scopes/{id}/email-linksSet, 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-registrationsList the scope’s self-registrations awaiting confirmation (FR-SR-11)SystemAdmin, existing Owner
DELETE/api/scopes/{id}/pending-registrations/{registrationId}Discard a pending self-registration; its link stops working (FR-SR-12)SystemAdmin, existing Owner

5.2 Person Endpoints

MethodEndpointDescriptionAuth Required
POST/api/personsCreate a SystemAdmin or ScopeAdmin person (no scope)SystemAdmin
POST/api/scopes/{scopeId}/personsCreate a User person within a scopeScopeAdmin (owner)+
GET/api/scopes/{scopeId}/personsList Users within a scopeScopeAdmin (owner)+
GET/api/persons/{id}Get person by IDAuthenticated
GET/api/persons/scope-adminsList the persons who may own a scope (FR-PE-12)SystemAdmin, ScopeAdmin
GET/api/persons/system-adminsList the SystemAdmin persons (FR-PE-13)SystemAdmin
PUT/api/persons/{id}Update a person; an own email change also needs the current password, and the second factor when active (FR-PE-14)ScopeAdmin (owner)+ / self
DELETE/api/persons/{id}Logically delete a personScopeAdmin (owner)+
DELETE/api/persons/{id}/hardHard delete a personSystemAdmin
POST/api/persons/{id}/verification-emailSend a fresh verification link to the person’s own address (FR-EV-06, FR-EV-07)SystemAdmin, ScopeAdmin (Users of an owned scope)
POST/api/persons/{id}/password-resetSend a password-reset link to the person’s own address (FR-PR-05, FR-PR-06)SystemAdmin, ScopeAdmin (Users of an owned scope)

5.3 Application Endpoints

MethodEndpointDescriptionAuth Required
POST/api/scopes/{scopeId}/applicationsCreate an application in a scopeSystemAdmin / ScopeAdmin (owner of the scope, self as owner)
GET/api/scopes/{scopeId}/applicationsList applications in a scope — every one for a SystemAdmin, only the caller’s own for a ScopeAdminSystemAdmin / ScopeAdmin (owner of the scope)
GET/api/scopes/{scopeId}/applications/{id}Get application by IDSystemAdmin / ScopeAdmin (owner of the application)
PUT/api/scopes/{scopeId}/applications/{id}Update an applicationSystemAdmin / ScopeAdmin (owner of the application)
DELETE/api/scopes/{scopeId}/applications/{id}Logically delete an applicationSystemAdmin / ScopeAdmin (owner of the application)
DELETE/api/scopes/{scopeId}/applications/{id}/hardHard delete an applicationSystemAdmin
POST/api/scopes/{scopeId}/applications/{id}/credentialsIssue a credential; the secret is returned onceSystemAdmin / ScopeAdmin (owner of the application)
GET/api/scopes/{scopeId}/applications/{id}/credentialsList the application’s credentialsSystemAdmin / ScopeAdmin (owner of the application)
DELETE/api/scopes/{scopeId}/applications/{id}/credentials/{credentialId}Revoke a credentialSystemAdmin / ScopeAdmin (owner of the application)
POST/api/applications/tokenExchange a credential for an application tokenNo (the secret authenticates)
GET/api/applications/selfCheck an application token liveApplication token (validated by the endpoint)

5.4 Authentication Endpoints

MethodEndpointDescriptionAuth Required
POST/api/auth/loginAuthenticate by email + passwordNo
POST/api/auth/password-recoveryRequest password reset emailNo
POST/api/auth/password-resetReset password with tokenNo
POST/api/auth/verify-emailVerify 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/registerRegister a password User in an open scope (FR-SR-03 … FR-SR-09)No (anonymous)
POST/api/auth/resend-verificationResend verification emailAuthenticated
POST/api/auth/2fa/enableInitiate two-factor setup, selecting App, Email, or both; needs the current password (FR-2F-18)Authenticated
POST/api/auth/2fa/confirmConfirm setup with a code per selected method; activates 2FA and returns the ten recovery codesAuthenticated
POST/api/auth/2fa/verifyExchange a challenge token plus a code or recovery code for a full authentication tokenNo (holds a challenge token)
POST/api/auth/2fa/challenge/resendReissue the email code for an outstanding challenge, inside its existing windowNo (holds a challenge token)
POST/api/auth/2fa/disableDisable two-factor authentication (requires password + a valid second factor)Authenticated
POST/api/auth/2fa/recovery-codes/regenerateInvalidate the current recovery codes and issue ten new onesAuthenticated

5.5 Google Sign-In Endpoints

MethodEndpointDescriptionAuth Required
POST/api/auth/googleSign up or sign in with Google ({ scopeId, idToken }); creates a Google User on first use, authenticates on subsequent usesNo
POST/api/auth/google/sign-outSign out a Google User, invalidating/discarding their tokenAuthenticated (Google User)
GET/api/scopes/{scopeId}/google-usersList Google Users within a scopeScopeAdmin (owner)+
GET/api/scopes/{scopeId}/google-users/{id}Get a Google User by IDAuthenticated
DELETE/api/scopes/{scopeId}/google-users/{id}Logically delete a Google UserScopeAdmin (owner)+
DELETE/api/scopes/{scopeId}/google-users/{id}/hardHard delete a Google UserSystemAdmin

5.6 Scope Permission Endpoints

MethodEndpointDescriptionAuth Required
POST/api/scopes/{scopeId}/permissionsCreate a scope permission within a scopeSystemAdmin / ScopeAdmin (owner of the scope)
GET/api/scopes/{scopeId}/permissionsList the permissions of a scopeSystemAdmin / ScopeAdmin (owner of the scope)
GET/api/scopes/{scopeId}/permissions/{id}Get a scope permission by ID within its scopeSystemAdmin / ScopeAdmin (owner of the scope)
PUT/api/scopes/{scopeId}/permissions/{id}Update a scope permissionSystemAdmin / ScopeAdmin (owner of the scope)
DELETE/api/scopes/{scopeId}/permissions/{id}Logically delete a scope permissionSystemAdmin / ScopeAdmin (owner of the scope)
DELETE/api/scopes/{scopeId}/permissions/{id}/hardHard delete a scope permissionSystemAdmin

5.7 Audit Trail Endpoints

MethodEndpointDescriptionAuth Required
GET/api/audit-logList 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.

IDCategoryRequirement
NFR-01TechnologyThe API shall be built using ASP.NET Core on the .NET platform (specific framework/language versions and libraries: see the Technology Stack Document )
NFR-02SecurityPasswords shall be hashed using a strong algorithm (e.g., bcrypt, Argon2) combined with a per-person random Salt, both stored as byte arrays
NFR-03SecurityAuthentication tokens shall be signed and have configurable expiration
NFR-04SecurityEvery 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-05PerformanceEvery 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-06AvailabilityThe 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-07Data IntegrityAfter 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-08Data IntegrityHard deletion of a scope must cascade to its SCOPE_USER/SCOPE_OWNER rows, its Users, and its Applications
NFR-09LoggingEvery 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-10ValidationAll 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-11Data IntegrityHard deletion of a person must cascade to the applications they own
NFR-12Data IntegrityA scope must always retain at least one owner; removing the last owner or hard-deleting the last owning person must be rejected
NFR-13SecurityGoogle 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-14Data IntegrityHard deletion of a scope must also cascade to its Google Users
NFR-15SecurityInternal 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-16SecurityA 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-17SecurityA 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-18PerformanceAn 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-19Data ProtectionPersonal 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-20Data ProtectionA 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-21Data ProtectionAn 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-22Data ProtectionApplication 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-23Data ProtectionEvery 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-24Data ProtectionA 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-25SecurityThe 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-26SecurityThe 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-27SecuritySingle-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-28SecurityAn 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-29SecurityThe 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-30SecurityEvery 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-31SecurityThe 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.

EndpointClassMedian (idle)Slowest (idle)Median (loaded)Slowest (loaded)
GET /HealthCheckunauthenticated0.3 ms0.5 ms0.4 ms1.0 ms
GET /api/persons/{id}authenticated read3.0 ms3.3 ms3.4 ms8.3 ms
POST /api/scopes/{id}/permissionsauthenticated write5.5 ms8.2 ms16.8 ms38.2 ms
GET /api/scopes/{id}/personslist, 50 rows, page of 255.7 ms17.2 ms14.4 ms18.8 ms
POST /api/auth/loginpassword verification346.6 ms533.6 ms491.4 ms581.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.

CallersScenarioAttempts2xx2xx/sp50p95p99Max429Failed
32GET /HealthCheck347,535347,53517,3771.6 ms3.7 ms5.0 ms18.2 ms00
32GET /api/scopes82,46582,4654,1237.5 ms10.4 ms12.6 ms32.3 ms00
32POST /api/scopes24,96924,9691,24925.3 ms33.9 ms38.1 ms47.3 ms00
32POST /api/auth/login581,363100.52,787 ms——2,880 ms581,3530
128GET /HealthCheck438,858438,85821,9435.5 ms9.9 ms12.6 ms29.7 ms00
128GET /api/scopes93,36493,3644,66826.9 ms38.1 ms44.4 ms73.6 ms00
128POST /api/scopes26,57726,5771,32994.7 ms133.9 ms154.5 ms229.9 ms00
128POST /api/auth/login962,514100.52,890 ms——2,980 ms962,5040

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
ActionSystemAdminScopeAdminUserAnonymous
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-InN/AN/AN/A✅ (unauthenticated call)
Google Sign-OutN/AN/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 ChallengeN/AN/AN/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

FeatureRequirements
Person CRUDFR-PE-01 through FR-PE-19
Application CRUD and authenticationFR-AP-01 through FR-AP-16
Scope CRUD & OwnershipFR-SC-01 through FR-SC-13
Role AssignmentFR-RO-01 through FR-RO-06
AuthenticationFR-AU-01 through FR-AU-13
Password RecoveryFR-PR-01 through FR-PR-08
Email VerificationFR-EV-01 through FR-EV-08
Google Sign-InFR-GO-01 through FR-GO-20
Self-RegistrationFR-SR-01 through FR-SR-14
Scope Permission CRUDFR-SP-01 through FR-SP-09
Two-Factor AuthenticationFR-2F-01 through FR-2F-20
Audit TrailFR-AT-01 through FR-AT-02
Health & monitoringFR-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-cuttingNFR-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

ActorDescription
System AdminHas full access to all scopes, persons, and applications across the entire system; belongs to no scope
Scope AdminOwns 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
UserAn authenticated person belonging to exactly one scope, with basic access to their own profile
AnonymousAn unauthenticated caller (can only access public endpoints)
Email ServiceExternal system that delivers emails for verification and password recovery
Google UserA 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 PlatformExternal 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

FieldValue
IDUC-01
NameCreate Scope
ActorsSystem Admin
DescriptionAllows a System Admin to create a new scope to onboard a client system, designating at least one Scope Admin as its initial owner
PreconditionsActor is authenticated and has the SystemAdmin role; each specified initial owner is an existing, non-logically-deleted person with the ScopeAdmin or SystemAdmin role
PostconditionsA 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 }
  1. System Admin sends a request with scope name, optional description, and at least one owner ID.
  2. The system validates the input fields.
  3. The system verifies the scope name is unique.
  4. The system verifies each referenced owner is an existing, non-logically-deleted person with the ScopeAdmin or SystemAdmin role (FR-SC-08).
  5. The system creates the scope record with IsDeleted = false and a SCOPE_OWNER row for each initial owner.
  6. The system returns the created scope.

Alternative Flows:

IDConditionOutcome
AF-01aScope name already existsReturn 409 Conflict
AF-01bInvalid input data, or no owner specifiedReturn 400 Bad Request with validation errors
AF-01cActor is not System AdminReturn 403 Forbidden
AF-01dAn owner ID does not reference an existing, non-logically-deleted ScopeAdmin or SystemAdminReturn 400 Bad Request

UC-02: View Scope

FieldValue
IDUC-02
NameView Scope
ActorsSystem Admin, Scope Admin, User
DescriptionRetrieve 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)
PreconditionsActor is authenticated
PostconditionsScope information is returned

Main Flow (read a — scope by ID):

  1. Actor requests scope details by ID.
  2. The system loads the scope, filtering out logically deleted scopes unless explicitly requested.
  3. 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.
  4. The system returns the scope data.

Main Flow (read b — list scopes):

  1. 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).
  2. For a Scope Admin, the system keeps only the scopes they own, as the scope-owner records stand now.
  3. The system filters out logically deleted scopes unless explicitly requested.
  4. The system returns the page of scopes, its total counting only what the caller may see.

Alternative Flows:

IDConditionOutcome
AF-02aScope not found, or logically deleted and not explicitly requested (read a)Return 404 Not Found
AF-02bActor 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

FieldValue
IDUC-03
NameUpdate Scope
ActorsSystem Admin
DescriptionModify an existing scope’s name or description
PreconditionsActor is authenticated with SystemAdmin role; scope exists and is not logically deleted
PostconditionsScope record is updated

Main Flow:

  1. System Admin sends an update request with the scope ID and new field values.
  2. The system validates the input.
  3. The system verifies the scope exists and is not logically deleted.
  4. The system applies the updates and sets UpdatedAt.
  5. The system returns the updated scope.

Alternative Flows:

IDConditionOutcome
AF-03aScope not found or logically deletedReturn 404 Not Found
AF-03bName conflicts with another scopeReturn 409 Conflict

UC-04: Logical Delete Scope

FieldValue
IDUC-04
NameLogical Delete Scope
ActorsSystem Admin
DescriptionSoft-delete a scope by setting IsDeleted = true
PreconditionsActor is authenticated with SystemAdmin role; scope exists
PostconditionsScope 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
  1. System Admin sends a delete request for a scope.
  2. The system locates the scope.
  3. The system sets IsDeleted = true on the scope.
  4. 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.
  5. 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:

IDConditionOutcome
AF-04aScope not foundReturn 404 Not Found
AF-04bScope already logically deletedReturn 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

FieldValue
IDUC-05
NameHard Delete Scope
ActorsSystem Admin
DescriptionPermanently remove a scope, its Users, its applications, and its scope permissions
PreconditionsActor is authenticated with SystemAdmin role; scope exists
PostconditionsScope, 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:

  1. System Admin sends a hard delete request.
  2. The system locates the scope.
  3. 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.
  4. 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.
  5. The system removes all SCOPE_OWNER and SCOPE_USER rows referencing the scope, and cascade-deletes any remaining scope permissions.
  6. The system permanently deletes the scope record.
  7. The system returns success, including the count of scope permissions removed.

Alternative Flows:

IDConditionOutcome
AF-05aScope not foundReturn 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

FieldValue
IDUC-06
NameCreate Person
ActorsSystem Admin, Scope Admin
DescriptionRegister 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
PreconditionsActor is authenticated; for paths (a) and (c), the target scope exists and is not logically deleted
PostconditionsA 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 }
  1. Scope Admin (an owner of the scope) or System Admin sends a request with person data (name, email, password) targeting a scope.
  2. The system validates all fields and verifies the scope exists and is not logically deleted.
  3. The system checks that the email is unique within the scope.
  4. The system generates a random Salt and hashes the password using it.
  5. 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.
  6. The system generates a verification token and sends a verification email.
  7. The system returns the created person (excluding PasswordHash and Salt).

Main Flow (path b — Create Scope Admin / System Admin, no scope):

  1. System Admin sends a request to POST /api/persons with person data (name, email, password, roleId referencing ScopeAdmin or SystemAdmin).
  2. 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).
  3. The system generates a random Salt and hashes the password using it.
  4. 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).
  5. The system generates a verification token and sends a verification email.
  6. 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 }
  1. A Scope Admin who owns the scope, or a System Admin, sends a request with person data (name, email, password) targeting the scope.
  2. The system validates all fields and verifies the target scope exists and is not logically deleted.
  3. 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).
  4. The system generates a random Salt and hashes the password using it.
  5. 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.
  6. The system generates a verification token and sends a verification email.
  7. The system returns the created person.

Alternative Flows:

IDConditionOutcome
AF-06aEmail 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-09Return 409 Conflict
AF-06bScope not found or logically deleted (paths a, c)Return 404 Not Found
AF-06cActor other than System Admin attempts path (b)Return 403 Forbidden
AF-06dInvalid inputReturn 400 Bad Request
AF-06eScope Admin attempts path (a) or (c) on a scope they do not ownReturn 403 Forbidden

UC-07: View Person

FieldValue
IDUC-07
NameView Person
ActorsSystem Admin, Scope Admin, User
DescriptionRetrieve 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
PreconditionsActor 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
PostconditionsPerson information is returned, never including PasswordHash or Salt

Main Flow (read a — person by ID):

  1. Actor requests a person by ID.
  2. The system loads the person, excluding logically deleted persons unless includeDeleted is explicitly requested (FR-PE-08).
  3. 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.
  4. 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):

  1. 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).
  2. The system verifies the scope exists and is not logically deleted.
  3. The system verifies the actor may read the scope: a System Admin always may; a Scope Admin must own it.
  4. 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):

  1. 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).
  2. The system applies the same scope and authorization checks as read (b).
  3. 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):

  1. 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).
  2. 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.
  3. 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):

  1. A System Admin requests the system’s SystemAdmin persons, optionally filtering by name or email and paging the result (FR-PE-13).
  2. 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:

IDConditionOutcome
AF-07aPerson 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-07bActor 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

FieldValue
IDUC-08
NameUpdate Person
ActorsSystem Admin, Scope Admin, User
DescriptionModify person’s name, email, or role
PreconditionsActor is authenticated; person exists and is not logically deleted
PostconditionsPerson 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:

  1. 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).
  2. The system validates the input.
  3. 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.
  4. 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.
  5. 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).
  6. The system applies updates and sets UpdatedAt.
  7. 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:

IDConditionOutcome
AF-08aPerson not found or logically deletedReturn 404 Not Found
AF-08bNew email conflicts within the person’s scope, or among admins system-wide (FR-PE-09)Return 409 Conflict
AF-08cUnauthorized role change (only System Admin may change RoleId)Return 403 Forbidden
AF-08dActor not authorized to update the person at allReturn 403 Forbidden
AF-08eInvalid inputReturn 400 Bad Request
AF-08fRole change to a role that would require naming a target scopeReturn 400 Bad Request
AF-08gRole change would leave a scope with no owner (NFR-12)Return 409 Conflict
AF-08hActor 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-08iActor 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-08jActor 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 spentReturn 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-08kActor 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 acceptedReturn 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

FieldValue
IDUC-09
NameLogical Delete Person
ActorsSystem Admin, Scope Admin
DescriptionSoft-delete a person by setting IsDeleted = true
PreconditionsActor is authenticated; person exists; the actor is not the person being deleted
PostconditionsPerson’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:

  1. Actor sends DELETE /api/persons/{id} for a person.
  2. 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.
  3. The system checks authorization: System Admin may delete any person; a Scope Admin may only delete a User within a scope they own.
  4. The system sets IsDeleted = true on the person record and stamps UpdatedAt.
  5. The system returns success, reporting whether the person was already deleted.

Alternative Flows:

IDConditionOutcome
AF-09aPerson not foundReturn 404 Not Found
AF-09bAlready logically deletedReturn 200 OK (idempotent)
AF-09cActor 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-09dActor is the person being deletedReturn 403 Forbidden
AF-09ePerson 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

FieldValue
IDUC-10
NameHard Delete Person
ActorsSystem Admin
DescriptionPermanently remove a person record from the database
PreconditionsActor 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
PostconditionsPerson record, all associated tokens, their SCOPE_USER/SCOPE_OWNER rows, and any applications they own are permanently removed

Main Flow:

  1. System Admin sends a hard delete request to DELETE /api/persons/{id}/hard.
  2. 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.
  3. If the person owns any scope, the system verifies that every scope they own has at least one other owner.
  4. 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.
  5. The system permanently deletes all tokens (password reset, email verification) associated with the person.
  6. The system permanently deletes any applications owned by the person.
  7. The system removes the person’s SCOPE_USER row (if a User) or SCOPE_OWNER rows (if they own any scope).
  8. The system permanently deletes the person record.
  9. The system returns success, reporting how many applications and tokens went with the person.

Alternative Flows:

IDConditionOutcome
AF-10aPerson not foundReturn 404 Not Found
AF-10bPerson is the sole owner of one or more scopesReturn 409 Conflict — “Cannot remove the last owner of a scope”
AF-10cActor is the person being deletedReturn 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

FieldValue
IDUC-11
NameLogin (Authenticate)
ActorsAnonymous, User, Scope Admin, System Admin
DescriptionAuthenticate 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
PreconditionsNone
PostconditionsAn 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 }
  1. 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.
  2. 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.
  3. The system hashes the provided password using the person’s stored Salt and compares it to the stored PasswordHash.
  4. The system confirms the person is not logically deleted.
  5. 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.
  6. 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:

IDConditionOutcome
AF-11aPerson not foundReturn 401 Unauthorized
AF-11bPassword mismatchReturn 401 Unauthorized
AF-11cPerson is logically deletedReturn 401 Unauthorized
AF-11dUser’s scope is logically deletedReturn 401 Unauthorized
AF-11eScope Admin’s owned scopes are all logically deletedReturn 401 Unauthorized
AF-11fEmail missing or malformed, or password missingReturn 400 Bad Request
AF-11gPerson 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-11hThe 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 lockReturn 401 Unauthorized, the same answer and the same message as AF-11a through AF-11e
AF-11iThe password checks out and processing of the person is restricted (NFR-24, UC-44), whether or not they are logically deletedReturn 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

FieldValue
IDUC-12
NameRequest Password Recovery
ActorsAnonymous
DescriptionRequest a password reset email
PreconditionsNone
PostconditionsA 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"
  1. Caller provides their email (and scope ID, if they are a User, since email is only unique within a scope for Users).
  2. The system locates the person.
  3. The system generates a time-limited password reset token.
  4. The system stores the token and sends a recovery email.
  5. 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:

IDConditionOutcome
AF-12aEmail not found, or the person is logically deleted, restricted (NFR-24), or cannot sign in because their scope or scopes are deletedReturn 200 OK with the same generic message, in the same time (prevents enumeration). No token is issued and nothing is sent
AF-12bA 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

FieldValue
IDUC-13
NameReset Password
ActorsAnonymous
DescriptionSet a new password using a valid reset token
PreconditionsA valid, non-expired reset token exists
PostconditionsPerson’s password is updated; the token is marked as used; every token the person held is revoked (FR-AU-11)

Main Flow:

  1. Caller provides the reset token and a new password.
  2. 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).
  3. The system generates a new random Salt and hashes the new password using it.
  4. 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).
  5. 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:

IDConditionOutcome
AF-13aToken expiredReturn 400 Bad Request — “Token expired”
AF-13bToken already used — including by a request carrying the same token at the same time (NFR-27): exactly one of them sets a passwordReturn 400 Bad Request — “Token already used”
AF-13cToken not foundReturn 400 Bad Request — “Invalid token”
AF-13dNew password fails validationReturn 400 Bad Request with validation errors
AF-13eThe 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

FieldValue
IDUC-14
NameVerify Email
ActorsAnonymous (via email link)
DescriptionConfirm a person’s email address
PreconditionsA valid, non-expired verification token exists
PostconditionsPerson’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"
  1. Person clicks the verification link which calls the API with the token.
  2. 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).
  3. 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.
  4. The system marks the token as used.
  5. The system returns success.

Alternative Flows:

IDConditionOutcome
AF-14aToken expiredReturn 400 Bad Request — “Token expired”
AF-14bToken already used — including by a request carrying the same token at the same time (NFR-27)Return 400 Bad Request — “Token already used”
AF-14cToken not foundReturn 400 Bad Request — “Invalid token”
AF-14dThe 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

FieldValue
IDUC-15
NameResend Verification Email
ActorsUser, Scope Admin, System Admin
DescriptionResend the email verification message
PreconditionsActor is authenticated; email is not already verified
PostconditionsA new verification token is generated and emailed

Main Flow:

  1. Authenticated person requests a new verification email.
  2. The system checks that the email is not already verified, and reserves the send against the person’s account-email budget (FR-PE-16).
  3. The system invalidates any existing verification tokens.
  4. The system generates a new time-limited verification token.
  5. The system sends the verification email.
  6. The system returns success.

Alternative Flows:

IDConditionOutcome
AF-15aEmail already verifiedReturn 400 Bad Request — “Email already verified”
AF-15bThe 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-15cThe 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

FieldValue
IDUC-16
NameCreate Application
ActorsSystem Admin, Scope Admin
DescriptionRegister a new application (a non-person identity representing another system) within a scope
PreconditionsActor 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)
PostconditionsA 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 }
  1. Caller sends a request with application data (name, ownerId) targeting a scope.
  2. The system validates all fields.
  3. The system verifies the target scope exists and is not logically deleted.
  4. The system verifies the owner is an existing, non-logically-deleted ScopeAdmin or SystemAdmin who owns the scope.
  5. The system creates the application record with IsDeleted = false.
  6. The system returns the created application.

Alternative Flows:

IDConditionOutcome
AF-16aScope not found or logically deletedReturn 404 Not Found
AF-16bOwner 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-16cScope Admin attempts to set an owner other than themselfReturn 403 Forbidden
AF-16dInvalid inputReturn 400 Bad Request
AF-16eScope Admin does not own the target scopeReturn 403 Forbidden

UC-17: View Application

FieldValue
IDUC-17
NameView Application
ActorsSystem Admin, Scope Admin
DescriptionRetrieve an application’s details or list applications within a scope
PreconditionsActor is authenticated
PostconditionsApplication information is returned

Main Flow:

  1. Actor requests an application by ID, or a list of the applications within a scope.
  2. 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.
  3. Logically deleted applications are excluded unless explicitly requested.
  4. The system returns application data.

Alternative Flows:

IDConditionOutcome
AF-17aApplication not found, or — when listing — the scope is not found or is logically deletedReturn 404 Not Found
AF-17bActor 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 UserReturn 403 Forbidden

UC-18: Update Application

FieldValue
IDUC-18
NameUpdate Application
ActorsSystem Admin, Scope Admin
DescriptionModify an application’s name or owner
PreconditionsActor is authenticated; application exists and is not logically deleted
PostconditionsApplication record is updated

Main Flow:

  1. Actor sends an update request with the application ID and new values.
  2. The system validates the input.
  3. The system checks authorization:
    • System Admin: can update any application.
    • Scope Admin: can update only the applications they own.
  4. 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.
  5. The system applies the updates and sets UpdatedAt.
  6. The system returns the updated application.

Alternative Flows:

IDConditionOutcome
AF-18aApplication not found or logically deletedReturn 404 Not Found
AF-18bNew owner not found, logically deleted, neither a ScopeAdmin nor a SystemAdmin, or not an owner of the application’s scopeReturn 400 Bad Request
AF-18cActor not authorizedReturn 403 Forbidden

UC-19: Logical Delete Application

FieldValue
IDUC-19
NameLogical Delete Application
ActorsSystem Admin, Scope Admin
DescriptionSoft-delete an application by setting IsDeleted = true
PreconditionsActor is authenticated; application exists
PostconditionsApplication’s IsDeleted is true

Main Flow:

  1. Actor sends a delete request for an application.
  2. The system checks authorization (System Admin, or the ScopeAdmin who owns the application).
  3. The system sets IsDeleted = true on the application record.
  4. The system returns success.

Alternative Flows:

IDConditionOutcome
AF-19aApplication not foundReturn 404 Not Found
AF-19bAlready logically deletedReturn 200 OK (idempotent)
AF-19cActor not authorizedReturn 403 Forbidden

UC-20: Hard Delete Application

FieldValue
IDUC-20
NameHard Delete Application
ActorsSystem Admin
DescriptionPermanently remove an application record from the database
PreconditionsActor is authenticated with SystemAdmin role; application exists
PostconditionsApplication record is permanently removed, and with it, by cascade, every credential issued to it (UC-53), live, revoked or expired

Main Flow:

  1. System Admin sends a hard delete request.
  2. The system permanently deletes the application record.
  3. The system returns success.

Alternative Flows:

IDConditionOutcome
AF-20aApplication not foundReturn 404 Not Found

UC-21: Add Scope Owner

FieldValue
IDUC-21
NameAdd Scope Owner
ActorsSystem Admin, Scope Admin (existing owner)
DescriptionAdd 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)
PreconditionsActor 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
PostconditionsA 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
  1. Actor sends a request to add a person as an owner of a scope.
  2. The system verifies the scope exists and is not logically deleted.
  3. The system verifies the person exists, is not logically deleted, and has the ScopeAdmin or SystemAdmin role.
  4. The system inserts a SCOPE_OWNER row linking the scope to the person (no-op if it already exists).
  5. The system returns success.

Alternative Flows:

IDConditionOutcome
AF-21aScope not found or logically deletedReturn 404 Not Found
AF-21bPerson not found, logically deleted, or neither a ScopeAdmin nor a SystemAdminReturn 400 Bad Request
AF-21cActor not authorized (not System Admin nor an existing owner)Return 403 Forbidden
AF-21dPerson is already an owner of the scopeReturn 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

FieldValue
IDUC-22
NameRemove Scope Owner
ActorsSystem Admin, Scope Admin (existing owner)
DescriptionRemove a Scope Admin’s ownership of a scope
PreconditionsActor is authenticated with SystemAdmin role, or is an existing owner of the target scope; the scope has more than one owner
PostconditionsThe SCOPE_OWNER row linking the scope to the person is removed

Main Flow:

  1. Actor sends a request to remove a person’s ownership of a scope.
  2. The system verifies the scope exists and that the person is currently an owner.
  3. The system verifies the scope has more than one owner.
  4. The system removes the SCOPE_OWNER row.
  5. The system returns success.

Alternative Flows:

IDConditionOutcome
AF-22aScope not found, or person is not an owner of itReturn 404 Not Found
AF-22bScope has only one owner (this one)Return 409 Conflict — “Cannot remove the last owner of a scope”
AF-22cActor not authorized (not System Admin nor an existing owner)Return 403 Forbidden

UC-23: Promote User to Scope Owner

FieldValue
IDUC-23
NamePromote User to Scope Owner
ActorsSystem Admin, Scope Admin (existing owner)
DescriptionPromote an existing User of a scope to ScopeAdmin, making them a co-owner of that scope
PreconditionsActor 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)
PostconditionsThe 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 }
  1. Actor sends a request to promote a person to owner of a scope.
  2. The system verifies the scope exists and is not logically deleted.
  3. The system verifies the person exists, is not logically deleted, and is currently a User belonging to that scope.
  4. The system changes the person’s RoleId to ScopeAdmin.
  5. The system removes the person’s SCOPE_USER row and inserts a SCOPE_OWNER row linking them to the scope.
  6. The system returns the updated person.

Alternative Flows:

IDConditionOutcome
AF-23aScope not found or logically deletedReturn 404 Not Found
AF-23bPerson not found, logically deleted, or not a User of that scopeReturn 400 Bad Request
AF-23cActor not authorized (not System Admin nor an existing owner)Return 403 Forbidden
AF-23dPerson is already a ScopeAdminReturn 409 Conflict — “Person already holds the ScopeAdmin role”
AF-23ePerson’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 namespaceReturn 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

FieldValue
IDUC-24
NameEnable/Disable Google Sign-In
ActorsSystem Admin, Scope Admin (existing owner)
DescriptionTurn Google Sign-In on or off for a scope, controlling whether Users of that scope may sign up/sign in with a Google account
PreconditionsActor is authenticated with SystemAdmin role, or is an existing owner of the target scope; the scope exists
PostconditionsThe 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 }
  1. Actor sends a request to enable or disable Google Sign-In for a scope.
  2. The system validates the input fields.
  3. The system verifies the scope exists.
  4. The system checks authorization: System Admin, or an existing owner of the scope.
  5. The system sets GoogleSignInEnabled to the requested value.
  6. The system returns the updated scope.

Alternative Flows:

IDConditionOutcome
AF-24aScope not foundReturn 404 Not Found
AF-24bActor not authorized (not System Admin nor an existing owner)Return 403 Forbidden
AF-24cenabled not suppliedReturn 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

FieldValue
IDUC-25
NameSign Up / Sign In via Google
ActorsAnonymous, Google Identity Platform
DescriptionAuthenticate 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
PreconditionsThe target scope exists, is not logically deleted, and has GoogleSignInEnabled = true; the caller holds a valid Google ID token
PostconditionsA 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 }
  1. The caller authenticates with Google (outside this API) and obtains a signed ID token.
  2. 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.
  3. 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).
  4. The system verifies the target scope exists, is not logically deleted, and has GoogleSignInEnabled = true.
  5. The system looks up a Google User by GoogleId (the token’s sub claim) within the scope.
  6. 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).
  7. 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.
  8. 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:

IDConditionOutcome
AF-25aID token invalid, expired, or fails verificationReturn 401 Unauthorized
AF-25bScope not found, logically deleted, or GoogleSignInEnabled = falseReturn 403 Forbidden
AF-25cEmail from the token already used by another Google User or User person in the scopeReturn 409 Conflict
AF-25dExisting Google User is logically deleted, and not under a restriction on processingReturn 401 Unauthorized
AF-25eExisting Google User is under a restriction on processing (NFR-24), whether or not logically deletedReturn 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-25fNo Google User exists yet and the token’s email_verified claim is not true — false or absent (FR-GO-07); checked before AF-25cReturn 403 Forbidden — “Google sign-up requires a verified email address.” Nothing is created
AF-25gThe 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 readAs AF-25a: 401 Unauthorized — “Google authentication failed.”

UC-26: Sign Out via Google

FieldValue
IDUC-26
NameSign Out via Google
ActorsGoogle User
DescriptionEnd a Google User’s session in the client
PreconditionsThe caller holds a valid authentication token issued via UC-25
PostconditionsThe 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:

  1. The Google User sends a sign-out request with their current token.
  2. 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.
  3. The system returns success.

Alternative Flows:

IDConditionOutcome
AF-26aToken missing or already invalidReturn 401 Unauthorized

UC-27: View Google User

FieldValue
IDUC-27
NameView Google User
ActorsSystem Admin, Scope Admin, Google User
DescriptionRetrieve a Google User’s details or list Google Users within a scope
PreconditionsActor is authenticated
PostconditionsGoogle User information is returned

Main Flow:

  1. Actor requests a Google User by ID or a list of Google Users within a scope.
  2. 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.
  3. Logically deleted Google Users are excluded unless explicitly requested.
  4. The system returns Google User data.

Alternative Flows:

IDConditionOutcome
AF-27aGoogle User not foundReturn 404 Not Found
AF-27bActor not authorizedReturn 403 Forbidden

UC-28: Logical Delete Google User

FieldValue
IDUC-28
NameLogical Delete Google User
ActorsSystem Admin, Scope Admin
DescriptionSoft-delete a Google User by setting IsDeleted = true
PreconditionsActor is authenticated; Google User exists
PostconditionsGoogle User’s IsDeleted is true

Main Flow:

  1. Actor sends a delete request for a Google User.
  2. The system checks authorization: System Admin, or an owner of the Google User’s scope.
  3. The system sets IsDeleted = true on the Google User record.
  4. The system returns success.

Alternative Flows:

IDConditionOutcome
AF-28aGoogle User not foundReturn 404 Not Found
AF-28bAlready logically deletedReturn 200 OK (idempotent)
AF-28cActor not authorizedReturn 403 Forbidden

UC-29: Hard Delete Google User

FieldValue
IDUC-29
NameHard Delete Google User
ActorsSystem Admin
DescriptionPermanently remove a Google User record from the database
PreconditionsActor is authenticated with SystemAdmin role; Google User exists
PostconditionsGoogle User record is permanently removed

Main Flow:

  1. System Admin sends a hard delete request.
  2. 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.
  3. The system permanently deletes the Google User record.
  4. The system returns success.

Alternative Flows:

IDConditionOutcome
AF-29aGoogle User not foundReturn 404 Not Found

UC-31: Create Scope Permission

FieldValue
IDUC-31
NameCreate Scope Permission
ActorsSystem Admin, Scope Admin
DescriptionRegister 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
PreconditionsActor is authenticated; target scope exists and is not logically deleted; for a Scope Admin, the actor must own the target scope
PostconditionsA 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 }
  1. Caller sends a request with the permission Name, an optional Description, and an optional IncludeAsJwtClaim flag, targeting a scope.
  2. The system validates the input shape: Name is required and at most 200 characters; Description, when supplied, is at most 500 characters.
  3. The system verifies the target scope exists and is not logically deleted.
  4. The system verifies the actor may manage the scope — a System Admin bypasses the check; a Scope Admin must own it.
  5. The system creates the scope permission record with IsDeleted = false and IncludeAsJwtClaim as supplied (defaulting to false when omitted).
  6. The system returns the created permission.

Alternative Flows:

IDConditionOutcome
AF-31aScope not found or logically deletedReturn 404 Not Found
AF-31dInvalid input — Name missing or over 200 characters, or Description over 500 charactersReturn 400 Bad Request with validation errors
AF-31eScope Admin does not own the target scopeReturn 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

FieldValue
IDUC-32
NameView Scope Permission
ActorsSystem Admin, Scope Admin
DescriptionRetrieve 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
PreconditionsActor is authenticated
PostconditionsScope permission information is returned

Main Flow (read a — permission by ID):

  1. Actor requests a scope permission by ID within a scope.
  2. 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).
  3. The system checks authorization: a System Admin sees any permission; a Scope Admin must own the permission’s scope.
  4. The system returns the permission data.

Main Flow (read b — list a scope’s permissions):

  1. 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).
  2. The system verifies the scope exists and is not logically deleted.
  3. The system verifies the actor may manage the scope: a System Admin always may; a Scope Admin must own it.
  4. 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:

IDConditionOutcome
AF-32aPermission not found under the addressed scope, or logically deleted and not explicitly requested (read a)Return 404 Not Found
AF-32bScope not found or logically deleted (read b — reuses AF-31a)Return 404 Not Found
AF-32eActor not authorized — a Scope Admin who does not own the target scope (reads a, b)Return 403 Forbidden

UC-33: Update Scope Permission

FieldValue
IDUC-33
NameUpdate Scope Permission
ActorsSystem Admin, Scope Admin
DescriptionModify a scope permission’s Name, Description, and IncludeAsJwtClaim flag
PreconditionsActor is authenticated; permission exists under the addressed scope and is not logically deleted
PostconditionsScope permission record is updated

Main Flow:

  1. Actor sends an update request to PUT /api/scopes/{scopeId}/permissions/{id} with the new Name, Description, and IncludeAsJwtClaim values.
  2. The system validates the input shape, reusing UC-31’s rules.
  3. The system loads the permission inside the addressed scope, excluding logically deleted permissions.
  4. The system checks authorization: a System Admin may update any permission; a Scope Admin must own the permission’s scope.
  5. The system applies the updates and stamps UpdatedAt.
  6. The system returns the updated permission.

Alternative Flows:

IDConditionOutcome
AF-33aPermission not found under the addressed scope, or logically deletedReturn 404 Not Found
AF-33dInvalid input (reuses AF-31d)Return 400 Bad Request with validation errors
AF-33eScope Admin does not own the permission’s scopeReturn 403 Forbidden

UC-34: Logical Delete Scope Permission

FieldValue
IDUC-34
NameLogical Delete Scope Permission
ActorsSystem Admin, Scope Admin
DescriptionSoft-delete a scope permission by setting IsDeleted = true. Nothing cascades — a scope permission owns no dependent row
PreconditionsActor is authenticated; the permission exists under the addressed scope
PostconditionsThe permission’s IsDeleted is true

Main Flow:

  1. Actor sends DELETE /api/scopes/{scopeId}/permissions/{id}.
  2. 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.
  3. 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.
  4. If the permission is already logically deleted, the system returns success with alreadyDeleted = true and writes nothing.
  5. Otherwise the system sets IsDeleted = true, stamps UpdatedAt, and returns success with alreadyDeleted = false.

Alternative Flows:

IDConditionOutcome
AF-34aPermission not found under the addressed scopeReturn 404 Not Found
AF-34bAlready logically deletedReturn 200 OK (idempotent), with alreadyDeleted = true
AF-34eScope Admin does not own the permission’s scopeReturn 403 Forbidden

UC-35: Hard Delete Scope Permission

FieldValue
IDUC-35
NameHard Delete Scope Permission
ActorsSystem Admin
DescriptionPermanently remove a scope permission record from the database
PreconditionsActor is authenticated with SystemAdmin role; the permission exists under the addressed scope
PostconditionsThe scope permission record is permanently removed. Nothing cascades — a scope permission is a leaf in the data model

Main Flow:

  1. System Admin sends DELETE /api/scopes/{scopeId}/permissions/{id}/hard.
  2. 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.
  3. The system permanently deletes the record. No dependent is removed first — no entity carries a foreign key to a scope permission.
  4. The system returns success.

Alternative Flows:

IDConditionOutcome
AF-35aPermission 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

FieldValue
IDUC-36
NameEnable Two-Factor Authentication (Initiate Setup and Read Status)
ActorsUser, Scope Admin, System Admin
DescriptionBegin 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
PreconditionsActor 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
PostconditionsFor (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? }
  1. Caller sends POST /api/auth/2fa/enable with the method(s) they want: App, Email, or both, and their currentPassword (FR-2F-18).
  2. 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.
  3. 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.
  4. 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.
  5. 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):

  1. 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.
  2. The system loads the caller’s TWO_FACTOR_AUTH row, if one exists.
  3. 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.
  4. 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:

IDConditionOutcome
AF-36aTwo-factor authentication is already active for the personReturn 409 Conflict
AF-36bCaller 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-36cNeither App nor Email selectedReturn 400 Bad Request
AF-36ecurrentPassword 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-36dRe-initiating setup while a prior, unconfirmed setup existsOverwrite 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-36fEmail 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-19Return 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-36gAs AF-36f, but five account emails have been sent in the current day-long windowReturn 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

FieldValue
IDUC-37
NameConfirm Two-Factor Authentication Setup
ActorsUser, Scope Admin, System Admin
DescriptionProve control of every method selected in UC-36 to activate two-factor authentication and receive the one-time set of recovery codes
PreconditionsA pending (IsActive = false) TWO_FACTOR_AUTH row exists for the person, from UC-36
PostconditionsTWO_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] }
  1. 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).
  2. 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.
  3. 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).
  4. 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).
  5. The system sets IsActive = true.
  6. 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:

IDConditionOutcome
AF-37aNo pending setup exists for the personReturn 404 Not Found
AF-37bappCode 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-37cemailCode missing, incorrect, expired, already used, or retired after five wrong guesses (FR-2F-13), when EmailEnabledReturn 401 Unauthorized — “The password or code is incorrect.”, the same answer as AF-37b. The code is not spent
AF-37dSetup 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)

FieldValue
IDUC-38
NameVerify Second Factor
ActorsAnonymous (holding a challenge token from UC-11)
DescriptionComplete 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
PreconditionsUC-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
PostconditionsA 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 }
  1. Caller sends the challenge token from UC-11’s login response, together with either a current app/email code or a recovery code.
  2. 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).
  3. 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).
  4. If a recovery code was used, the system marks it consumed — it cannot be used again.
  5. 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:

IDConditionOutcome
AF-38aChallenge 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-38bCode 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 codeReturn 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-38cRecovery code already usedReturn 401 Unauthorized, same message as AF-38b (does not reveal that the code existed)
AF-38dThe submitted factor checks out, but the person’s scope eligibility (AF-11d/AF-11e) no longer holds by the time this runsReturn 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

FieldValue
IDUC-39
NameDisable Two-Factor Authentication
ActorsUser, Scope Admin, System Admin
DescriptionTurn off two-factor authentication for the caller’s own account
PreconditionsActor is authenticated; TWO_FACTOR_AUTH.IsActive = true for the person
PostconditionsThe 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
  1. Caller sends their current password together with a valid second factor — an app/email code or a recovery code.
  2. The system verifies the password, exactly as UC-11 does.
  3. The system verifies the second factor, exactly as UC-38 does.
  4. 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.
  5. 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:

IDConditionOutcome
AF-39aTwo-factor authentication is not active for the personReturn 404 Not Found
AF-39bPassword mismatch, or the re-authentication budget is paused (FR-AU-13)Return 401 Unauthorized — “The password or code is incorrect.”
AF-39cSecond 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

FieldValue
IDUC-40
NameRegenerate Recovery Codes
ActorsUser, Scope Admin, System Admin
DescriptionInvalidate the caller’s current recovery codes and issue a fresh set of ten
PreconditionsActor is authenticated; TWO_FACTOR_AUTH.IsActive = true for the person
PostconditionsThe 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] }
  1. Caller sends a valid second factor — an app/email code, or one of their remaining recovery codes.
  2. The system verifies it, exactly as UC-38 does.
  3. 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.
  4. 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.
  5. 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:

IDConditionOutcome
AF-40aTwo-factor authentication is not active for the personReturn 404 Not Found
AF-40bSecond 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

FieldValue
IDUC-46
NameResend Second-Factor Email Code
ActorsAnonymous (holding a challenge token from UC-11)
DescriptionReissue 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
PreconditionsUC-11 issued a challenge token for a person whose TWO_FACTOR_AUTH has EmailEnabled = true, and that token has not yet expired
PostconditionsThe 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
  1. Caller sends the challenge token UC-11’s login response returned. Nothing else — no address, no scope, no credential.
  2. The system validates the challenge token exactly as UC-38 step 2 does: signature, expiration, and the MFA-pending claim (FR-2F-10).
  3. The system resolves the person the token names and their active two-factor configuration, and confirms the email method is enabled for them.
  4. The system confirms the challenge has reissues left (FR-2F-13) and charges one against it.
  5. The system retires the person’s outstanding email codes and stores a fresh one, hashed, expiring per FR-2F-03.
  6. The system mails the code to the person’s stored address.
  7. The system answers 200 OK, carrying no data.

Alternative Flows:

IDConditionOutcome
AF-46aChallenge 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-46bThe 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-46cThe person has no active two-factor configuration, or has one without the email method — an authenticator-app holder has no code to resendReturn 200 OK, identical to the main flow. Nothing is sent
AF-46dThe challenge has already authorized its maximum reissues (FR-2F-13)Return 200 OK, identical to the main flow. Nothing is sent
AF-46eA full authentication token is presented in place of a challenge tokenTreated 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

FieldValue
IDUC-47
NameRegister a Password User
ActorsAnonymous
DescriptionCreate a password User in a scope without an administrator, where the scope’s owners have opened self-registration
PreconditionsThe scope exists, is not logically deleted, and has SelfRegistrationEnabled = true (FR-SR-01, FR-SR-02)
PostconditionsEither 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
  1. Caller sends a name, an email address, a password and the scope’s PublicId.
  2. The system validates the request with the rules UC-06 path a applies (FR-SR-03).
  3. The system confirms the scope exists, is live and is open to self-registration.
  4. The system hashes the password, queues the request and answers 202 Accepted with a fixed message.
  5. 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).
  6. 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.
  7. The person follows the link (UC-14) and enters the password chosen at sign-up (FR-SR-09).
  8. 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:

IDConditionOutcome
AF-47aThe scope is missing, logically deleted, or has self-registration off403 Forbidden with one message for all three. Nothing is queued
AF-47bThe address is already held in the scope by a live person or a Google User202 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-47cThe request is malformed400 Bad Request with the field’s message. Nothing is queued
AF-47dThe scope is closed or deleted while the request waitsNothing is created or sent
AF-47eThe link is followed after it expiredUC-14 AF-14a: 400 Bad Request — “Token expired”. No User is created
AF-47fThe scope is closed or deleted, or the address is taken, before the link is followedUC-14 AF-14c: 400 Bad Request — “Invalid token”. No User is created
AF-47gThe registrant signs in before following the linkUC-11 AF-11a: 401 Unauthorized, identical to a wrong password — no Person exists to find
AF-47hThe link is followed without the password chosen at sign-up422 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-47iThe link is followed with a wrong password401 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-47jThe link is followed again after the account existsUC-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-47kThe 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-47lThe 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

IDUC-41
NameExport Own Personal Data
ActorAny authenticated identity — a person of any role, or a Google User
DescriptionA 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).
PreconditionsThe caller holds a valid authentication token.
PostconditionsNone; the use case discloses. One audit entry records that it did.

Main flow

  1. The system resolves the subject from the authentication token — never from the request, which carries no identifier for one.
  2. 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.
  3. 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.
  4. The system returns the document with Cache-Control: no-store.

Alternative flows

IDConditionOutcome
AF-41aThe token names no person and no Google UserNotEligible (403)
AF-41bMore audit entries exist than the document carriesThe 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

IDUC-42
NameRequest Erasure of Own Identity
ActorAny authenticated identity — a person of any role, or a Google User
DescriptionA 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.
PreconditionsThe caller holds a valid authentication token and has not already requested erasure.
PostconditionsThe 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

  1. The caller submits their credential: a password if they are a person, a freshly issued Google ID token if they are a Google User.
  2. The system resolves the subject from the authentication token — never from the request, which carries no identifier for one.
  3. The system verifies the credential against that identity.
  4. 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.
  5. 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).
  6. The system suspends the identity, marking the deletion as subject-requested so the shorter deadline applies.
  7. The system returns the request timestamp, the deadline, and whether a restriction was lifted.

Alternative flows

IDConditionOutcome
AF-42aThe 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-42bThe token names no live person and no live Google UserNotEligible (403), answered alike for every such condition.
AF-42cAn erasure has already been requestedErasureAlreadyRequested (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-42dThe 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

IDUC-44
NameRestrict Processing of Own Identity
ActorAny authenticated identity — a person of any role, or a Google User
DescriptionA 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.
PreconditionsThe caller holds a valid authentication token and is not already restricted.
PostconditionsThe identity is restricted. Nothing is deleted, and no retention window starts.

Main flow

  1. The caller names one of Art. 18(1)’s four grounds.
  2. The system resolves the subject from the authentication token — never from the request.
  3. The system records the restriction and the ground.
  4. 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

IDConditionOutcome
AF-44aAlready restrictedProcessingAlreadyRestricted (409)
AF-44bThe ground is not one of Art. 18(1)’s fourRestrictionGroundUnknown (400)
AF-44cThe token names no live identityNotEligible (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

IDUC-45
NameLift a Restriction on Processing
ActorThe subject, for their own; a System Admin, for anyone’s
DescriptionEnds a restriction, after informing the subject where the law requires it (GDPR Art. 18(3)).
PreconditionsThe named identity is restricted.
PostconditionsThe restriction is cleared. Where somebody else lifted it, the notification is recorded.

Main flow

  1. The system resolves the subject — the caller’s own identity, or the one named where a System Admin is acting.
  2. Where the caller is not the subject, the system informs the subject before proceeding.
  3. The system clears the restriction and records that the notification happened.

Alternative flows

IDConditionOutcome
AF-45aThe identity is not restrictedNotRestricted (404)
AF-45bThe subject could not be informedRestrictionLiftNotificationFailed (503). The restriction stands
AF-45cA non-System-Admin names somebody else, or a System Admin who is themselves restricted or suspended, or who holds a subject-rights-only token, doesNotEligible (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

IDUC-43
NameList Outstanding Erasure Requests
ActorSystem Admin
DescriptionLists the erasure requests that have been made and not yet carried out, soonest deadline first, flagging those already overdue.
PreconditionsThe caller is a System Admin.
PostconditionsNone; the use case reads.

Main flow

  1. The system reads the outstanding requests from both identity tables — those with a request recorded and no anonymisation yet.
  2. The system marks each as overdue or not, against the moment the queue is read.
  3. 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

FieldValue
IDUC-48
NameList the Audit Trail
ActorsSystem Admin
DescriptionRead 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
PreconditionsActor is authenticated as a System Admin
PostconditionsNone; 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
  1. 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.
  2. 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.
  3. 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:

IDConditionOutcome
AF-48aA 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 bounds400 Bad Request — “The end of the date range must not be earlier than its start.” or the pagination message
AF-48bThe caller is not a System Admin403 Forbidden; an anonymous caller gets 401 Unauthorized
AF-48cNo entry matches200 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

FieldValue
IDUC-49
NameList Pending Registrations
ActorsSystem Admin, Scope Admin
DescriptionList the self-registrations (UC-47) of a scope that are awaiting confirmation, via GET /api/scopes/{id}/pending-registrations
PreconditionsActor is authenticated; the scope exists and is not logically deleted
PostconditionsNone; 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
  1. A System Admin or a Scope Admin requests a scope’s pending registrations, paging the result (FR-SR-11).
  2. The system confirms the scope exists and is not logically deleted.
  3. The system confirms the actor may manage the scope: a System Admin always may; a Scope Admin must own it.
  4. 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:

IDConditionOutcome
AF-49aThe scope is not found or is logically deleted404 Not Found — “Scope not found.”
AF-49bThe actor is neither a System Admin nor an owner of the scope403 Forbidden — “You are not an owner of the target scope.”; a User is refused 403 by the role gate, an anonymous caller 401
AF-49cNothing is pending200 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

FieldValue
IDUC-50
NameDiscard a Pending Registration
ActorsSystem Admin, Scope Admin
DescriptionRemove one of a scope’s pending self-registrations, so its link stops working, via DELETE /api/scopes/{id}/pending-registrations/{registrationId}
PreconditionsActor is authenticated; the scope exists and is not logically deleted; the registration belongs to it
PostconditionsThe 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
  1. A System Admin or a Scope Admin asks to discard a scope’s pending registration, by the identifier UC-49 returned (FR-SR-12).
  2. The system confirms the scope exists, is not logically deleted, and that the actor may manage it.
  3. The system finds the registration among that scope’s.
  4. 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:

IDConditionOutcome
AF-50aThe scope is not found or is logically deleted404 Not Found — “Scope not found.”
AF-50bThe actor is neither a System Admin nor an owner of the scope403 Forbidden — “You are not an owner of the target scope.”; a User 403, an anonymous caller 401
AF-50cNo registration with that identifier belongs to the scope — it never existed, belongs to another scope, or was confirmed or purged a moment earlier404 Not Found — “Pending registration not found.” Nothing is removed

UC-51: Send a Verification Email on a Person’s Behalf

FieldValue
IDUC-51
NameSend a Verification Email on a Person’s Behalf
ActorsSystem Admin, Scope Admin, Email Service
DescriptionAn administrator has a fresh verification link mailed to a person’s own address, via POST /api/persons/{id}/verification-email
PreconditionsActor is authenticated; the person exists; a Scope Admin acts only on a User of a scope they own
PostconditionsThe 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
  1. 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).
  2. 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.
  3. The system refuses a person whose processing is restricted (NFR-24), who is logically deleted, or whose address is already verified.
  4. 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).
  5. 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.
  6. The system answers with a fixed message; the token is never returned, logged or accepted.

Alternative Flows:

IDConditionOutcome
AF-51aNo person has that identifier (including a Google User’s identifier)404 Not Found — “Person not found.”
AF-51bThe actor may not act on this person — a Scope Admin aiming at anyone but a User of a scope they own403 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-51cThe person’s processing is restricted, or they are logically deleted409 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-51dThe address is already verified400 Bad Request — “Email already verified.”
AF-51eA 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

FieldValue
IDUC-52
NameSend a Password Reset on a Person’s Behalf
ActorsSystem Admin, Scope Admin, Email Service
DescriptionAn administrator has a password-reset link mailed to a person’s own address, via POST /api/persons/{id}/password-reset
PreconditionsActor 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
PostconditionsA 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
  1. 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).
  2. The system finds the person and confirms the actor may act on them, as UC-51.
  3. 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.
  4. 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).
  5. 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.
  6. The system answers with a fixed message; neither a token nor a password is returned, logged or accepted.

Alternative Flows:

IDConditionOutcome
AF-52aNo person has that identifier404 Not Found — “Person not found.”
AF-52bThe actor may not act on this person403 Forbidden — “You are not allowed to send email to this person.”; a User 403, an anonymous caller 401
AF-52cThe person’s processing is restricted, or they are logically deleted409 Conflict — the restricted or deleted message of AF-51c
AF-52dThe account cannot sign in — a User whose scope is logically deleted, or a ScopeAdmin with no live scope409 Conflict — “This person’s account cannot sign in, so no password reset was sent.”
AF-52eA reset email went to this person within the cooldown429 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

FieldValue
IDUC-53
NameIssue an Application Credential
ActorsSystem Admin, Scope Admin
DescriptionGive 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
PreconditionsActor 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
PostconditionsA 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
  1. A System Admin or a Scope Admin asks for a credential for an application, optionally with an expiry (expiresAt) (FR-AP-10).
  2. 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.
  3. The system resolves the application in the addressed scope; it must be live, and so must the scope.
  4. 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).
  5. 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.
  6. 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:

IDConditionOutcome
AF-53aNo 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-53bA Scope Admin who does not own the application, or no longer owns its scope403 Forbidden — “You are not allowed to manage this application’s credentials.”; a User 403, an anonymous caller 401
AF-53cexpiresAt 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-53dThe application already holds two live credentials409 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

FieldValue
IDUC-54
NameList an Application’s Credentials
ActorsSystem Admin, Scope Admin
DescriptionRead 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
PreconditionsAs UC-53: the actor may manage the application, which is live in a live scope
PostconditionsNone; 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
  1. A System Admin or a Scope Admin asks for an application’s credentials (FR-AP-12).
  2. The system resolves the application and checks the actor may manage it, exactly as UC-53 does, not-found before authorization.
  3. 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:

IDConditionOutcome
AF-54aAs AF-53a404 Not Found — “Application not found.”
AF-54bAs AF-53b403 Forbidden — “You are not allowed to manage this application’s credentials.”; a User 403, an anonymous caller 401
AF-54cThe application has no credentials200 OK with an empty list

UC-55: Revoke an Application Credential

FieldValue
IDUC-55
NameRevoke an Application Credential
ActorsSystem Admin, Scope Admin
DescriptionWithdraw one credential via DELETE /api/scopes/{scopeId}/applications/{id}/credentials/{credentialId}
PreconditionsAs 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
PostconditionsThe 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
  1. A System Admin or a Scope Admin asks to revoke a credential of an application (FR-AP-12).
  2. The system resolves the application and checks the actor may manage it, as UC-53 does.
  3. The system finds the credential on that application; one that belongs to another application is not found.
  4. The system sets RevokedAt and answers 200 OK with the credential’s id and revokedAt (FR-AP-16, NFR-09).

Alternative Flows:

IDConditionOutcome
AF-55aAs AF-53a404 Not Found — “Application not found.”
AF-55bAs AF-53b403 Forbidden — “You are not allowed to manage this application’s credentials.”; a User 403, an anonymous caller 401
AF-55cNo credential with that id on this application404 Not Found — “Application credential not found.”
AF-55dThe credential is already revoked409 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

FieldValue
IDUC-56
NameObtain an Application Token
ActorsApplication (anonymous; the secret authenticates)
DescriptionExchange a live credential’s secret for an application token via POST /api/applications/token, with the body applicationId, scopeId and secret
PreconditionsThe credential is live (not revoked, not expired), belongs to the application, the application is live, and sits in the given live scope
PostconditionsA 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
  1. The application sends its applicationId, the scopeId and the secret.
  2. The system validates their shape: each field is present and the secret is at most 100 characters.
  3. 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.
  4. The system confirms the credential belongs to that application, the application to that scope, and that the application and scope are live.
  5. 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).
  6. 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:

IDConditionOutcome
AF-56aA 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-56bAnything else is wrong: an unknown, revoked or expired secret; another application’s secret; the application or scope deleted; the wrong scope401 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

FieldValue
IDUC-57
NameCheck an Application Token
ActorsConsumer service, holding the application’s token
DescriptionConfirm, 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>
PreconditionsA token obtained through UC-56, still within its lifetime
PostconditionsNone; 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
  1. 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.
  2. 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).
  3. The system answers 200 OK with applicationId, name, scopeId, credentialId and expiresAt.

Alternative Flows:

IDConditionOutcome
AF-57aNo or malformed token; wrong signature, issuer, audience or kind; expired; the application, scope or credential no longer live; or the scope not the token’s401 Unauthorized — “The application token was not accepted.”, always the same

Revocation policy (FR-AP-16). Revoking or expiring a credential, or deleting the application or its scope, takes effect at the next check for every token already issued. A token’s exp is fixed at issue, so a consumer validating it offline alone would accept it for up to its whole lifetime after its credential expires or is revoked. Only this check reflects that at once, which is why consumers re-check here before every sensitive release.

Clock skew. Tokens are validated with zero skew. Instances whose clocks differ can refuse a freshly issued token for the length of the drift; keep them synchronised (NTP).


3. Use Case — Requirements Traceability

Use CaseRequirements Covered
UC-01: Create ScopeFR-SC-01, FR-SC-08
UC-02: View ScopeFR-SC-02, FR-SC-03, FR-SC-07
UC-03: Update ScopeFR-SC-04
UC-04: Logical Delete ScopeFR-SC-05, FR-SC-07
UC-05: Hard Delete ScopeFR-SC-06
UC-06: Create PersonFR-PE-01, FR-PE-02, FR-PE-09, FR-PE-10, FR-PE-11, FR-RO-01, FR-RO-02, FR-RO-03, FR-SC-12, FR-EV-01, FR-EV-02
UC-07: View PersonFR-PE-03, FR-PE-04, FR-PE-08, FR-PE-12, FR-PE-13, FR-PE-19
UC-08: Update PersonFR-PE-05, FR-PE-14, FR-PE-15, FR-PE-17, FR-PE-19, FR-AU-11, FR-AU-13, FR-RO-02, FR-RO-03, FR-RO-05
UC-09: Logical Delete PersonFR-PE-06, FR-PE-08
UC-10: Hard Delete PersonFR-PE-07
UC-11: LoginFR-AU-01, FR-AU-02, FR-AU-03, FR-AU-04, FR-AU-05, FR-AU-06, FR-AU-07, FR-AU-09, FR-AU-11, FR-2F-07, NFR-27
UC-12: Password RecoveryFR-PR-01, FR-PR-02
UC-13: Reset PasswordFR-PR-03, FR-PR-04, FR-PR-08, FR-PE-17, FR-AU-11, NFR-24, NFR-27
UC-14: Email VerificationFR-EV-03, FR-EV-08, FR-PE-17, NFR-24, NFR-27
UC-15: Resend Verification EmailFR-EV-04
UC-16: Create ApplicationFR-AP-01, FR-AP-02, FR-AP-03
UC-17: View ApplicationFR-AP-04, FR-AP-05, FR-AP-09
UC-18: Update ApplicationFR-AP-06
UC-19: Logical Delete ApplicationFR-AP-07, FR-AP-09
UC-20: Hard Delete ApplicationFR-AP-08
UC-21: Add Scope OwnerFR-SC-08, FR-SC-09
UC-22: Remove Scope OwnerFR-SC-08, FR-SC-10
UC-23: Promote User to Scope OwnerFR-SC-08, FR-SC-13, FR-RO-03
UC-24: Enable/Disable Google Sign-InFR-GO-01, FR-GO-02
UC-25: Sign Up / Sign In via GoogleFR-GO-03 through FR-GO-11, FR-GO-19, FR-GO-20
UC-26: Sign Out via GoogleFR-GO-18
UC-27: View Google UserFR-GO-14, FR-GO-17
UC-28: Logical Delete Google UserFR-GO-15, FR-GO-17
UC-29: Hard Delete Google UserFR-GO-16
UC-31: Create Scope PermissionFR-SP-01, FR-SP-02, FR-SP-03
UC-32: View Scope PermissionFR-SP-04, FR-SP-05, FR-SP-09
UC-33: Update Scope PermissionFR-SP-06
UC-34: Logical Delete Scope PermissionFR-SP-07, FR-SP-09
UC-35: Hard Delete Scope PermissionFR-SP-08
UC-36: Enable Two-Factor AuthenticationFR-2F-01, FR-2F-02, FR-2F-03, FR-2F-15, FR-2F-18, FR-AU-13
UC-37: Confirm Two-Factor Authentication SetupFR-2F-04, FR-2F-05, FR-2F-20, FR-AU-11, FR-AU-13
UC-38: Verify Second FactorFR-2F-06, FR-2F-08, FR-2F-09, FR-2F-10, FR-2F-17, FR-2F-20, FR-AU-11, FR-AU-12, NFR-27
UC-39: Disable Two-Factor AuthenticationFR-2F-11, FR-AU-11, FR-AU-13
UC-40: Regenerate Recovery CodesFR-2F-12, FR-2F-20, FR-AU-11, FR-AU-13, NFR-27
UC-46: Resend Second-Factor Email CodeFR-2F-03, FR-2F-13, FR-2F-16, FR-AU-11, NFR-27
UC-47: Register a Password UserFR-SR-01 through FR-SR-10, FR-EV-01, FR-EV-02, NFR-23, NFR-24
UC-48: List the Audit TrailFR-AT-01, FR-AT-02, NFR-09, NFR-21
UC-49: List Pending RegistrationsFR-SR-11
UC-50: Discard a Pending RegistrationFR-SR-12, NFR-09
UC-51: Send a Verification Email on a Person’s BehalfFR-EV-06, FR-EV-07, NFR-24
UC-52: Send a Password Reset on a Person’s BehalfFR-PR-05, FR-PR-06, NFR-24
UC-53: Issue an Application CredentialFR-AP-10, FR-AP-11, FR-AP-12, NFR-09
UC-54: List an Application’s CredentialsFR-AP-12
UC-55: Revoke an Application CredentialFR-AP-12, FR-AP-16, NFR-09
UC-56: Obtain an Application TokenFR-AP-13, FR-AP-14, NFR-09
UC-57: Check an Application TokenFR-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.

4.6 Two-Factor Authentication Lifecycle

stateDiagram-v2
    [*] --> Pending: UC-36 Enable (Initiate)
    Pending --> Pending: UC-36 Re-initiate (change method selection)
    Pending --> Active: UC-37 Confirm Setup
    Active --> Active: UC-40 Regenerate Recovery Codes
    Pending --> [*]: UC-39 Disable (n/a — nothing to disable until Active)
    Active --> [*]: UC-39 Disable

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:

Rule: when a technology choice changes, it changes here first. Other documents link to this one rather than duplicating the detail.


2. Platform & Language

ConcernChoiceNotes
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.
LanguageC# 14C# 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 featuresNullable enabled, ImplicitUsings enabledApplied 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.

PackageVersionReferenced byRole
ArturRios.Util2.1.0Command, Domain, Shared, DataCore 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.WebApi5.1.0WebApiWeb 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.Jwt1.3.0WebApiToken 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.Mediator2.0.0Command, Query, WebApiLightweight CQRS mediator. Provides CommandMediator / QueryMediator and the handler contracts (ICommandHandlerAsync, IQueryHandlerAsync, IPaginatedQueryHandlerAsync) that dispatch a command/query to its single handler.
ArturRios.Data.Relational.Core5.0.0Command, Query, DomainProvider-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.PostgreSql3.0.2DataPostgreSQL binding for the relational core. Provides AddPostgreSqlProvider(), which wires EF Core to Npgsql so the relational layer runs against PostgreSQL.
ArturRios.Messaging1.3.0WebApiOutbound 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.Test5.0.0all test projectsTesting 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

ConcernChoice
Relational databasePostgreSQL — the sole supported relational database for the project.
Provider integrationArturRios.Data.PostgreSql → AddPostgreSqlProvider() (EF Core over Npgsql).
Connection configurationRead 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)

ConcernChoiceVersion
ORMEntity Framework Core (code-first)10.x
Design-time / migrationsMicrosoft.EntityFrameworkCore.Design (enables dotnet ef; the Data library is its own startup project)10.0.12
Naming conventionEFCore.NamingConventions — maps entities to snake_case, singular table/column names10.0.1
ContextAppDbContext, based on the ArturRios.Data.Relational.Core context base, configured via entity maps (ArturRios.Heimdall.Data.EntityMaps)—
DiagnosticsDbContextDiagnosticsOptions — sensitive-data logging & detailed errors are on only outside Production (they would expose password hashes, salts, e-mails)—
Startup seedingDatabaseSeeder 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

ConcernTechnologyVersionHow it is used
Input validationFluentValidation12.1.1Command inputs have IValidator<TCommand> implementations (e.g. CreateScopeCommandValidator), registered in DI and invoked inside the handlers.
LoggingSerilog (Serilog, Serilog.AspNetCore)4.4.0 / 10.0.0Structured logging wired through Host.UseSerilog(), with JSON formatting; the log directory and the retention period are configurable via environment variables (NFR-22).
Authentication / authorizationJWT 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 verificationGoogle.Apis.Auth1.77.0UC-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 validationMicrosoft.IdentityModel.JsonWebTokens8.23.0JsonWebTokenHandler, 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 modelDataOutput<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 documentationSwagger / 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 emailMailgun 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.
MetricsOpenTelemetry (OpenTelemetry.Extensions.Hosting, .Instrumentation.AspNetCore, .Instrumentation.Http, .Exporter.Prometheus.AspNetCore)1.19.1 (instrumentation 1.19.0) / exporter 1.19.1-beta.1ASP.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.

ConcernTechnologyVersionHow it is used
Test frameworkxUnit v3 (xunit.v3.mtp-off, xunit.runner.visualstudio)4.0.2 / 4.0.1The 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 / runnerMicrosoft.NET.Test.Sdk18.10.1Test host/runner integration for dotnet test and IDEs.
Coveragecoverlet.collector10.1.0Collects code coverage during test runs.
Test helpers & doublesArturRios.Util.Test5.0.0Category 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.
MockingMoq4.21.0The single mocking library for stubbing non-repository collaborators (validators, mediators, services). Do not introduce a second mocking framework.
Test data generationBogus35.6.5The standard way to generate entities/commands/DTOs (Faker<T>) instead of large inline literals or shared fixtures.
Functional databaseTestcontainers (Testcontainers.PostgreSql)4.16.0Provisions 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

CategoryPackage / ToolVersion
Platform.NET10 (net10.0)
LanguageC#14 (framework default)
First-partyArturRios.Util2.1.0
First-partyArturRios.Util.WebApi5.1.0
First-partyArturRios.Messaging1.3.0
First-partyArturRios.Util.Test5.0.0
First-partyArturRios.Mediator2.0.0
First-partyArturRios.Data.Relational.Core5.0.0
First-partyArturRios.Data.PostgreSql3.0.2
DataMicrosoft.EntityFrameworkCore.Design10.0.12
DataEFCore.NamingConventions10.0.1
AuthenticationGoogle.Apis.Auth1.77.0
AuthenticationMicrosoft.IdentityModel.JsonWebTokens8.23.0
ValidationFluentValidation12.1.1
LoggingSerilog4.4.0
LoggingSerilog.AspNetCore10.0.0
MetricsOpenTelemetry.Extensions.Hosting1.19.1
MetricsOpenTelemetry.Instrumentation.AspNetCore1.19.0
MetricsOpenTelemetry.Instrumentation.Http1.19.0
MetricsOpenTelemetry.Exporter.Prometheus.AspNetCore1.19.1-beta.1
Testingxunit.v3.mtp-off4.0.2
Testingxunit.runner.visualstudio4.0.1
TestingMicrosoft.NET.Test.Sdk18.10.1
Testingcoverlet.collector10.1.0
TestingTestcontainers.PostgreSql4.16.0
TestingMoq4.21.0
TestingBogus35.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

VersionDateChange
1.310 September 2026Backup 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.210 September 2026GDPR 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.110 September 2026R-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.09 September 2026First 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 ModelThis document
AsksWhat can an attacker do to the system?What can the system do to a person?
Organised byTrust boundaryRisk to a data subject
A control working as designed isNot a threatSometimes 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

TestAssessment
Lawful basisContract 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 limitationEvery 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 minimisationReviewed. One field — GoogleUser.ProfilePictureUrl — is recorded as the weakest justification in the system and marked for removal if no client reads it
Storage limitationEvery category has a period; all but the backup regime are enforced by scheduled passes
Alternatives consideredFederating 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 personCredentials, addresses and a full action history disclosed. Because the same identity spans every client system, a single disclosure follows them everywhere they use it
SeverityHigh · Likelihood Low · Residual Low

Controls: Argon2id with per-person salts, TOTP secrets encrypted at rest, tokens stored as digests, scope isolation re-read per request, PublicId values only across the boundary — and, confirmed by the controller on 10 September 2026, encryption at rest for the database volume. That was the gap that held this at Medium: everything not protected at the application level sits in clear text within the database, and only the volume beneath it stands between a stolen disk and all of it.

Backup encryption was confirmed separately on the same date, so the copy of the database that lives outside the server is protected too.

Resolved, 10 September 2026. The service is MEGA, and the copy is encrypted on this side before MEGA receives it — MEGA holds no key that decrypts it. The backups do leave Brazil, which is a transfer rather than a security question and is grounded in §7.3 of the Data Protection Document .

What that leaves is not a gap in this risk but a shift in where it lives: the whole argument that a full copy of the database is safe outside the country rests on the MEGA account credentials, because the keys are derived from the account password. That is now an operational control rather than an open question, recorded in §0.1 of the Operations document — unique password, two-factor authentication, held in the same custody as the production database credentials.

This is the risk the product’s value creates. It cannot be designed out without abandoning the purpose, which is why the controls around it carry more weight than they would elsewhere.

R-02 · Lockout denies a person access to everything at once

Risk to the personTen 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
SeverityMedium · 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 personEvery 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
SeverityMedium · 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 personAsking to be forgotten and not being. Before this year’s work, logical deletion kept name, address and password hash indefinitely, and nothing purged them
SeverityHigh · 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 personA Scope Admin reads the identities in their scope. Where the tenant is an employer, that is an employment relationship with an inherent power imbalance
SeverityMedium · 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 personRights that exist on paper and require an administrator’s goodwill in practice
SeverityMedium · 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 personTheir address reaches the United States on every transactional email, into a jurisdiction whose protections differ from Brazil’s
SeverityMedium · Likelihood High (it happens on every send) · Residual Low

Controls: LGPD Art. 33 IX resting on Art. 7 V — the transfer is necessary to perform the service the person is party to, since the email that activates or restores their account is the service being performed. Where GDPR applies, Sinch’s Data Processing Agreement incorporates the EU Standard Contractual Clauses automatically, with no separate signature.

This was the highest residual risk in version 1.0, rated High, and it has come down for two reasons — one a correction and one a decision.

The correction: Google was recorded as a second transfer and is not one. The ID token is validated offline against cached public certificates and is never sent to Google, so there is nothing to transfer. Google is a source of personal data. That removed half the exposure by establishing it had never existed.

The decision: the controller declined to pursue new contractual arrangements, which closed the Art. 33 II route through the ANPD’s own clauses. Art. 33 IX is a self-standing statutory ground requiring no counterparty agreement, and it rests on the same Art. 7 V basis §5 of the record already records for the underlying processing — which is what makes it coherent rather than a ground found for the occasion.

Residual is Low rather than absent because Art. 33 IX is a necessity test, and necessity is a judgement that could be read more narrowly than it is here. If the ANPD recognises the EU SCCs as equivalent, Art. 33 II becomes available through Sinch’s existing DPA with nothing to sign, and is the stronger ground.

The EEA → Brazil question in R-07’s original framing remains open and is tracked separately: Brazil holds no EU adequacy decision, so if any scope serves EEA subjects, the hosting is a restricted transfer. See #124 .

R-08 · A breach nobody notices

Risk to the personNot 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
SeverityHigh · Likelihood Was Medium · Residual Low

Controls: signal detection (NFR-26), collection and alerting (scripts/security_signals.py), the Incident Response Document , the Art. 33(5) register.

Closed, 10 September 2026. scripts/security_signals.py collects the signals on a schedule and e-mails whatever is new to a named address, through the Mailgun account the API already uses. The detection now reaches a person, which is what Art. 33’s clock needs it to do.

Two properties of the collector matter to this rating rather than to its operation. It keeps a watermark, so each signal is reported once and the alert stays readable — an alert that resends its whole history is one that gets filtered, and a filtered alert is the same non-detection in a different costume. And it exits non-zero when it cannot deliver, printing the signals it was holding to stderr, so a broken collector is itself noticed rather than looking like a quiet week.

Residual, and why it is not zero. Delivery depends on Mailgun and on somebody reading the mailbox. The first is visible — a failure exits non-zero where cron reports it. The second is not: nothing here can tell whether the message was opened. That is a control the controller operates, not one this repository can enforce, which is what keeps the residual at Low rather than removing it.

5. Summary

RiskResidual
R-02 · Lockout as denial of accessMedium (accepted)
R-05 · Tenant administrator visibilityMedium
R-01 · ConcentrationLow
R-03 · Behavioural recordLow
R-04 · ErasureLow
R-06 · Exercising rightsLow
R-07 · Cross-border email deliveryLow
R-08 · A breach nobody noticesLow

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

CategoryWhere it livesRetention periodWhy that periodEnforced
Active identityPERSON, GOOGLE_USERLife of the accountPerformance of the service the account exists for. An identity provider cannot authenticate an identity it has deletedBy deletion (UC-09/UC-10, UC-28/UC-29)
Logically deleted identityPERSON, GOOGLE_USER where is_deleted30 days where the subject asked, 90 days where an administrator did — §4A soft delete is a restriction of processing, not an erasure, so it needs a terminal state. The shorter deadline is the law’s, not a choice; the longer one is a reversal window. Both end in anonymisation✅ Scheduled anonymisation (§4.4)
Authentication materialPERSON.password_hash, PERSON.salt, TWO_FACTOR_AUTH, TWO_FACTOR_RECOVERY_CODELife of the accountA 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 keysBy deletion cascade
Single-use tokensPASSWORD_RESET_TOKEN, EMAIL_VERIFICATION_TOKEN, TWO_FACTOR_EMAIL_CODE, APPLICATION_CREDENTIAL (revoked or expired)Expiry + 7 days (default, configurable)§5✅ Scheduled purge (§6)
Pending registrationsPENDING_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 budgetsATTEMPT_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 trailAUDIT_LOG18 months identifiable, then pseudonymised and kept — §7Accountability (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 tombstonesERASED_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.2Held 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 logsSerilog file sink, including every request’s client IP address12 months — §8Security 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 backupsThe backup store, outside the schema35 days (proposed) — §9A backup is a full copy of every category above. Longer than the 30-day erasure deadline, which is what makes restore-time reconciliation mandatory⚠️ Regime proposed, reconciliation implemented — §9
Network dataRate limiter partition key (RemoteIpAddress)The fixed window, in memory onlyAn 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 — §8By construction
Data Protection key ringDATA_PROTECTION_KEYSLife of the encrypted materialNot personal data itself, but the TOTP secrets of NFR-16 are undecryptable without it. Listed so nobody purges it as housekeepingNever 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:

OutcomeWhen
TokenInvalidNo row matches the presented value
TokenExpiredThe row exists and ExpiresAt has passed
TokenAlreadyUsedThe 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:

OperationPermitted
DELETE, TRUNCATENever
UPDATE changing any column other than the attribution and the targetNever
UPDATE setting the attribution to a different identityNever
UPDATE clearing the attribution, entry older than 30 daysYes
UPDATE clearing the attribution, actor anonymisedYes, at any age
UPDATE clearing the attribution, recent entry, live actorNever
UPDATE setting the target to a different value, or setting one where there was noneNever
UPDATE clearing a target that names no PERSON or GOOGLE_USER row and is not in ERASED_SUBJECTNever
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 daysYes
UPDATE clearing a person target, that person anonymisedYes, at any age
UPDATE clearing a person target, recent entry, live personNever

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:

  1. 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.
  2. 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

VariableDefaultAccepted rangeMeaning
HEIMDALL_RETENTION_TOKEN_GRACE_DAYS7more than 0, up to 3650Days a single-use token is kept past expiry
HEIMDALL_RETENTION_PURGE_INTERVAL_MINUTES601 to 10080 (7 days)Interval between purge runs
HEIMDALL_RETENTION_PURGE_BATCH_SIZE500any positive integerMost rows removed from one table per run
HEIMDALL_RETENTION_PURGE_ENABLEDtruetrue / falseSet false to stop scheduling the purge
HEIMDALL_RETENTION_ERASURE_DEADLINE_DAYS30more than 0, up to 30Days before a requested erasure is anonymised
HEIMDALL_RETENTION_DELETION_WINDOW_DAYS90more than 0, up to 730Days before an administrative deletion is anonymised
HEIMDALL_RETENTION_ANONYMISATION_ENABLEDtruetrue / falseSet false to stop scheduling the anonymisation
HEIMDALL_RETENTION_AUDIT_ACTOR_DAYS548 (18 months)30 to 3650Days an audit entry stays attributed
HEIMDALL_RETENTION_AUDIT_PSEUDONYMISATION_ENABLEDtruetrue / falseSet false to stop scheduling it
HEIMDALL_RETENTION_LOG_DAYS365 (12 months)1 to 3650Days 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.

2. Controller and Encarregado

ControllerArtur Rios
Contactarturdev@duck.com
Encarregado / DPOArtur Rios — arturdev@duck.com
Hosting regionBrazil

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.

DataRoleWhy
Persons and Google Users within a tenant scopeHeimdall is processor; the tenant organisation is controllerThe tenant decides the purpose — its own users, its own service. Heimdall stores and authenticates them on the tenant’s instructions
System Admin accountsHeimdall is controllerThey exist to operate Heimdall itself; no tenant determines their purpose
Audit trail (AUDIT_LOG)Heimdall is controllerKept 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 telemetryHeimdall is controllerSame 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

CategoryWhere
Persons — User, ScopeAdmin, SystemAdminPERSON
Google-authenticated usersGOOGLE_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).

CategoryFieldsPurposeLGPD basisGDPR basis
IdentityName, EmailIdentify the account and address itArt. 7 V (execução de contrato)Art. 6(1)(b) contract
Authentication materialPasswordHash, Salt, SecurityStamp, TwoFactorAuth.TotpSecretEncrypted, recovery code hashesVerify that the account holder is presentArt. 7 V, Art. 46 (segurança)Art. 6(1)(b), Art. 32
Third-party identifiersGoogleUser.GoogleId, ProfilePictureUrlResolve a Google sign-in to a stored identityArt. 7 VArt. 6(1)(b)
Behavioural / securityFailedLoginAttempts, LockedOutUntil, the second-factor and re-authentication budgets, AUDIT_LOGDefend accounts against brute force; demonstrate what the system didArt. 7 IX (legítimo interesse), Art. 37Art. 6(1)(f) legitimate interests, Art. 5(2)
Deletion and erasure stateDeletedAt, DeletionKind, AnonymisedAt, ErasureRequestedAt, ErasureDueAt, ErasureBlockedReasonHonour erasure within the statutory deadline and show that it was honouredArt. 7 II (obrigação legal)Art. 6(1)(c) legal obligation
NetworkClient 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 accountsArt. 7 IX, Art. 46Art. 6(1)(f), Art. 32
DeliveryRecipient address passed to MailgunSend verification, reset and second-factor emails, and the notice of an email change to the previous address (FR-PE-15)Art. 7 VArt. 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

ColumnPurpose
PublicIdThe identifier every other table and every token refers to. Random, so it discloses nothing by itself
NameAddressing the person in email, and downstream profile provisioning by client systems
EmailThe address the account is reached at, and the identifier a person signs in with
PasswordHash, SaltVerifying the person is present. Never reversible to the password
IsDeletedSuspending an identity without destroying it
EmailVerifiedWhether the address has been proven reachable (FR-EV-01)
FailedLoginAttempts, LockedOutUntilBounding credential guessing per account (FR-AU-09)
FailedSecondFactorAttempts, SecondFactorLockedOutUntil, SecondFactorLockoutsBounding second-factor guessing at sign-in per account, across challenges (FR-AU-12). Cleared by a successful second factor and by anonymisation (NFR-20)
FailedReauthenticationAttempts, ReauthenticationWindowStartedAt, ReauthenticationPausedUntilBounding password and code guessing under a bearer token per account (FR-AU-13). Cleared by a successful re-authentication and by anonymisation (NFR-20)
AccountEmailsSent, AccountEmailWindowEndsAt, AccountEmailLastSentAtBounding 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)
SecurityStampRevoking 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, ScopeIdAuthorisation, and the per-scope uniqueness index FR-PE-09 needs
CreatedAt, UpdatedAtOrdinary record keeping; UpdatedAt is why DeletedAt had to exist separately
DeletedAt, DeletionKind, AnonymisedAtMeasuring and enforcing the retention window (NFR-20)
ErasureRequestedAt, ErasureDueAt, ErasureBlockedReasonHonouring an erasure request within its deadline, and showing that it was honoured (UC-42, UC-43)
LegalBasis, PrivacyNoticeVersion, BasisRecordedAtDemonstrating the lawful basis and what the person was told when the account was created (NFR-23). Not data about the person so much as about the processing of them, and held for the same accountability duty that requires this document
ProcessingRestrictedAt, RestrictionGround, RestrictionLiftNotifiedAtHonouring a restriction of processing and evidencing that Art. 18(3)’s notification happened before it was lifted (NFR-24)

GOOGLE_USER

ColumnPurpose
PublicId, ScopeId, IsDeleted, CreatedAt, UpdatedAtAs above
GoogleIdGoogle’s sub. The only stable way to resolve a returning sign-in to a stored identity (FR-GO-08)
Name, Email, EmailVerifiedAs on PERSON
ProfilePictureUrlDownstream profile provisioning — see the decision below
DeletedAt, DeletionKind, AnonymisedAt, ErasureRequestedAt, ErasureDueAt, ErasureBlockedReason, LegalBasis, PrivacyNoticeVersion, BasisRecordedAt, ProcessingRestrictedAt, RestrictionGround, RestrictionLiftNotifiedAtAs above

AUDIT_LOG

ColumnPurpose
ActorPersonId, ActorRoleAttributing an action, for as long as NFR-21 permits and no longer
TargetIdWhat the action was taken on. When that is a person or Google User — their own account for a self-service command, or the person an administrator acted on — the entry is personal data about them whoever acted, so NFR-21 clears it on the same schedule as the actor: past the attribution period, or as soon as that identity is anonymised (#184). Any other target (a scope, an application, a credential, a permission) names no person and is kept. A person target whose row was hard-deleted (UC-05, UC-10, UC-29) is cleared too, on the next pass and at any age: the id still names the person to anything holding a copy of the old row, so it stays personal data, and the hard delete tombstones it in ERASED_SUBJECT before the row goes so the pass and the trigger can tell it was a person’s (#195). An id that resolves to nobody and was never tombstoned — a hard-deleted scope’s, or a person’s removed before tombstones existed — is kept
Action, Succeeded, FailureReason, CreatedAt, PublicIdWhat 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

ColumnPurpose
PublicId, Kind, CreatedAtA 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

ColumnPurpose
PublicIdThe 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
ScopeIdThe scope the registrant asked to join — and so whose owners may see and discard the registration
Name, EmailWhat the account will be created with once the address is confirmed; the address is also where the confirmation link goes
PasswordHash, SaltThe 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
TokenHashRecognising the mailed link without storing it (Threat Model TH-14)
ExpiresAt, CreatedAtBounding how long the link works, and when the row is purged (Data Retention Schedule)
FailedAttemptsBounding 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

ColumnPurpose
AddressHashWhich 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, WindowEndsAtHow 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

RecipientWhat reaches themWhy
Mailgun (Sinch)Recipient email address, message bodyDelivering verification, password reset and second-factor emails
Tenant scope organisationsThe 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 providerEverything, at restRunning the database and the API

The current sub-processor list is maintained in the Data Processing Agreement §7, which is also where the notification commitment for changing it lives.

6.1 Google is a source, not a recipient

An earlier version of this document listed Google as a recipient. That was wrong, and the correction matters because it removes an international transfer that was recorded as needing a mechanism.

GoogleIdTokenVerifier calls GoogleJsonWebSignature.ValidateAsync, which validates the token offline: it verifies the signature against Google’s published certificates, which the library caches and refreshes hourly, and checks the issuer, audience and expiry locally. The token is never sent to Google. The only outbound request is for Google’s public certificates, which carry no personal data.

The direction of travel is the opposite of what a recipient means. Google issues the token to the person’s browser, which passes it to a client system, which presents it here — so Google is where GoogleId, Email, Name and ProfilePictureUrl come from, and GDPR Art. 14 and LGPD Art. 9 make that something the data subject is told. It is disclosed in the Privacy Notice and in every UC-41 export’s sources.

The person’s own sign-in at Google is a separate matter, between them and Google under Google’s own terms. Enabling Google Sign-In for a scope makes it possible; it does not make Heimdall the party transferring anything.

7. International transfers

LegFromToGoverned byStatus
Email deliveryBrazilUnited States (Mailgun)LGPD Art. 33 IX + Art. 7 V✅ Ground established — §7.1
Email delivery, for EEA subjects if anyEEAUnited 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 usersEEA (tenant)Brazil (hosting)GDPR Art. 46(2)(c)✅ SCCs Module Two in the DPA — §7.2
Daily backupsBrazilEurope 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 copyEEAEurope 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

RightLGPDGDPRWhere
Confirmation and accessArt. 18 I–IIArt. 15✅ UC-41, self-service
PortabilityArt. 18 VArt. 20✅ UC-41 — structured JSON, machine-readable
CorrectionArt. 18 IIIArt. 16✅ UC-08, self-service
Erasure / eliminationArt. 18 VIArt. 17✅ UC-42, completed by NFR-20
Restriction of processingArt. 18 IVArt. 18✅ UC-44, self-service
Information about sharingArt. 18 VIIArt. 15(1)(c)✅ §6 of this document, and in every UC-41 export
ObjectionArt. 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.

2. Who is responsible

ControllerArtur Rios
Encarregado (Data Protection Officer)Artur Rios
Contact for anything in this noticearturdev@duck.com

Published here because LGPD Art. 41 §1 requires the Encarregado’s identity and contact to be publicly disclosed.

If your account belongs to another organisation’s system, that organisation decides why your data is held, and it is the controller for it — Heimdall processes it on their instructions. You can raise anything with the address above, and it will be answered or passed to them; the Data Processing Agreement sets out which.

3. What is held about you

Your name and email addressTo identify your account and to write to you about it
Your password, stored only as a hashTo check it is you signing in. The password itself is never stored and cannot be recovered from what is
Two-factor settings and codesIf 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 pictureOnly if you sign in with Google, and only what Google gives us
Failed sign-in attempts and lockout stateTo stop somebody guessing your password
A record of actions taken on your accountTo 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 useSees the accounts in its own scope
The hosting provider (Brazil)Stores the database

Data is held in Brazil, and your email address is sent to the United States to deliver the messages above. That is an international transfer, made because sending you the email that activates or restores your account is part of running the account you asked for — LGPD Art. 33 IX with Art. 7 V, and where it applies, the standard contractual clauses in Sinch’s own terms. The detail is in §7 of the Data Protection Document .

If you sign in with Google, nothing about you is sent to Google by us. Google is where your name, address and picture came from — the token it issues when you sign in is checked here against Google’s published certificates, on this server. Your sign-in at Google itself is between you and Google, under their terms.

6. How long it is kept

The full schedule is the Data Retention Schedule . The short version:

Your accountWhile it exists
If you ask to be erasedAnonymised within 30 days
If an administrator deletes your accountAnonymised after 90 days, so a mistake can be undone
Password reset and verification linksA week after they expire
Records of actions on your account18 months, then stripped of anything identifying you
Logs of your requests, including your IP address12 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 canHowBy when
Be erasedPOST /api/auth/erasure-request — you will be asked for your password, or a fresh Google sign-in, because it cannot be undone30 days
Correct your detailsUpdate your own recordImmediately
Get a copy of your dataPOST /api/auth/data-export — everything held about you, plus who else sees it and how long it is keptImmediately
Have processing restricted, or objectPOST /api/auth/processing-restriction — suspends your account without deleting anything, while something about it is disputedImmediately
Ask anything, or complainarturdev@duck.com15 days

One of those is honestly marked unavailable. Publishing a notice that promised rights the system cannot yet deliver would be worse than admitting the gap: it is the kind of claim a regulator checks.

You can also complain to a supervisory authority. In Brazil that is the ANPD . If you are in the EEA, it is your national data protection authority — the GDPR applies to this service, and the rights in this notice are yours under it as well as under the LGPD.

If you ask to be erased and you are the last administrator of an organisation’s scope, the request is still recorded and the clock still runs, but it cannot be completed until that organisation appoints another administrator. You will be told if that applies to you.

8. Changes

This notice is versioned. Material changes raise the version, and the version you were shown is recorded against your account.

VersionDateChange
1.25 October 2026Your IP address is now recorded in the request logs, kept for 12 months
1.110 September 2026Confirmed the GDPR applies; corrected Google’s role from recipient to source
1.09 September 2026First 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.


1. Parties, subject matter, duration

Controller[organisation name]
ProcessorArtur Rios — arturdev@duck.com
Scope(s)[scope name(s) and PublicId(s)]
Subject matterIdentity management, authentication and authorisation for the Controller’s users
DurationFor as long as the Controller has a live scope, and through the deletion steps in §9
Nature and purposeStoring identities; verifying credentials; issuing tokens; sending transactional email; recording an audit trail
Categories of data subjectThe Controller’s end users, and its scope administrators
Categories of personal dataName, email address, password hash and salt, two-factor material, Google account identifier and profile picture, sign-in failure counters, audit records
Special categoriesNone

2. Processing only on documented instructions — Art. 28(3)(a)

2.1 The Processor processes the Controller’s personal data only on the Controller’s documented instructions. The instructions are the API: each endpoint the Controller calls is an instruction, and the API Reference with the Use Case Specification is the documented set of them. Anything outside that set requires a written instruction.

2.2 The Processor additionally performs, without a per-instance instruction, the processing this agreement itself provides for: the retention passes in §6, the audit trail in §4.3, and the security measures in §4.

2.3 If the Processor considers an instruction to infringe the LGPD, the GDPR or another applicable law, it will say so before acting on it (GDPR Art. 28(3), second paragraph).

2.4 The Processor does not use the Controller’s data for its own purposes. It is not sold, not used for advertising, not used to train anything, and not used to build any product other than the service provided to the Controller.


3. Confidentiality — Art. 28(3)(b)

3.1 Everybody authorised to process the Controller’s data is bound to confidentiality. At present that is the Processor alone; adding anyone brings them within this clause before they are given access.

3.2 Administrative access to production data is limited to what an operational task requires, and every write is recorded by the audit trail (§4.3).


4. Security measures — Art. 28(3)(c), Art. 32, LGPD Art. 46

4.1 The Processor maintains the measures set out in the Threat Model and required by NFR-02, NFR-03, NFR-13, NFR-15, NFR-16 and NFR-17. Specifically:

  • passwords stored as Argon2id hashes with a per-person salt;
  • TOTP secrets encrypted at rest; recovery codes stored only as hashes;
  • password reset and email verification tokens stored only as SHA-256 digests;
  • Google ID tokens verified for signature, issuer, audience and expiry before any claim is trusted;
  • internal database identifiers never exposed; only random PublicId values cross the boundary;
  • per-account lockout and per-IP rate limiting against credential guessing;
  • TLS on inbound connections.

4.2 Known gaps are disclosed rather than glossed. As at this template version:

  • Encryption at rest for the database and its backups is required and documented (NFR-25) but not verified from the repository — it is a property of the hosting, and the Controller should ask the Processor to evidence it before executing.
  • The database connection warns at start-up when it does not require TLS, rather than refusing to start. Confirming TLS and turning that into a refusal is outstanding.
  • Breach detection is in place (NFR-26) and writes matchable signals to the log, but where those alerts are routed is the Processor’s operational choice and should be confirmed: a signal nobody receives is a signal that has not been detected. The incident procedure, the deadlines and the register are the Incident Response Document .

4.3 Every attempted write produces an audit entry recording the actor, the operation, the outcome and — on a refusal — the reason (NFR-09). The table is append-only, enforced by database triggers.


5. Sub-processors — Art. 28(2) and 28(3)(d)

5.1 The Controller gives general authorisation for the sub-processors listed in §7.

5.2 The Processor will give the Controller at least 30 days’ notice before adding or replacing a sub-processor, during which the Controller may object. If the objection cannot be resolved, the Controller may terminate and take the deletion or return in §9.

5.3 Each sub-processor is bound by terms no less protective than these, and the Processor remains fully liable to the Controller for their performance.


6. Assisting the Controller — Art. 28(3)(e) and (f)

6.1 Data subject rights. Where a request reaches the Processor directly, it is handled as follows:

RequestHandled by
ErasureThe Processor, through UC-42, completed automatically within 30 days
CorrectionThe 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-processorPurposeLocationTransfer mechanism
Mailgun (Sinch)Transactional email deliveryUnited StatesLGPD Art. 33 IX with Art. 7 V; EU SCCs incorporated by Sinch’s own DPA — Data Protection Document §7.1
Hosting providerDatabase and API hostingBrazilNo transfer; data remains in Brazil
MEGA (Mega Cloud Services Limited)Encrypted off-server backups, dailyEurope or an Art. 45-adequate country; never the USLGPD 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 exporterThe Controller named in §1
Data importerThe Processor named in §1
Docking clause (18)Applies
Clause 9 — sub-processorsOption 2, general authorisation, with the 30 days’ notice §5.2 sets
Clause 11 — redressThe optional independent dispute resolution body is not selected
Clause 17 — governing lawThe law of an EEA Member State allowing third-party beneficiary rights; Ireland, absent another choice by the parties
Clause 18(b) — forumThe courts of that Member State
Annexes I, II, III§1 (parties, subject matter, categories), §4 (technical and organisational measures), and §7’s sub-processor list respectively

Where §11’s choice of Brazilian law would otherwise conflict, the Clauses prevail for the transfer they govern — Clause 5 requires that, and the Clauses cannot be varied in a way that contradicts them.

No separate signature is needed. Executing this agreement executes the Clauses, which is the point of incorporating them here rather than sending them separately.

7.2 The transfer impact assessment is the Controller’s

Schrems II requires the exporter to assess whether the importer’s jurisdiction undermines the Clauses in practice. That assessment is the Controller’s to make: it is an evaluation of Brazil from the exporter’s position, and the exporter is the only party who can make it.

The Processor supplies, on request and without charge, what an assessment needs: the location of processing and of backups, the sub-processors and their locations, the technical measures in §4, and a record of any government access request received. To the date of this template version, no government or law enforcement authority has requested access to any personal data processed under this agreement.

7.3 Direct sign-up is not a transfer

Where a person in the EEA creates an account with Heimdall themselves, there is no restricted transfer: EDPB Guidelines 05/2021 require an exporter subject to the GDPR, and a data subject sending their own data is not one. GDPR still applies to that processing under Art. 3(2) — every right in this agreement and in the Privacy Notice — but Chapter V does not engage, and §7.1’s Clauses are not needed for it.

This matters for a Controller deciding whether §7.1 applies to them: it turns on whether the Controller is subject to the GDPR and discloses its users’ data to the Processor, not on where the users happen to live.


8. Breach notification — Art. 28(3)(f), Art. 33(2), LGPD Art. 48

8.1 The Processor notifies the Controller of a personal data breach affecting the Controller’s data without undue delay and in any event within 24 hours of becoming aware of it. What counts as a breach, and when awareness is taken to begin, are defined in the Incident Response Document §3 and §4.

8.2 The 24 hours is deliberately shorter than the Controller’s own deadline. The Controller has 72 hours under GDPR Art. 33 and 3 working days under ANPD Resolution CD/ANPD nº 15/2024, both counted from the Controller’s awareness — which a notification from the Processor is what creates. A processor deadline equal to the controller’s would leave the controller no time at all.

8.3 The notification describes what is known: the nature of the breach, the categories and approximate number of data subjects and records, the likely consequences, and the measures taken. Where not all of it is available at once, it is given in phases rather than withheld until complete.

8.4 The Processor does not notify a supervisory authority or the data subjects on the Controller’s behalf unless instructed in writing.


9. Deletion or return at the end — Art. 28(3)(g)

9.1 On termination, at the Controller’s election:

  • Return — the Processor exports the scope’s identities and provides them to the Controller; or
  • Delete — the Processor hard-deletes the scope, which cascades to its users, Google Users, applications and permissions (NFR-08, NFR-14).

9.2 The election is made within 30 days of termination. Absent an election, the Processor deletes, because retaining personal data with no controller and no purpose is the worse default.

9.3 Two things are retained after deletion, and both are disclosed rather than assumed:

  • Audit entries naming the scope’s operations, for which the Processor is controller (Data Protection Document §3), retained for 18 months and then stripped of anything identifying a person.
  • Backups taken before termination, until they age out of the backup retention period — proposed at 35 days. A restore is followed by re-applying every erasure completed since the backup was taken, from a ledger kept outside the database; the mechanism is implemented and tested (NFR-25), and the retention figure awaits the Controller’s confirmation.

10. Audit — Art. 28(3)(h)

10.1 The Processor makes available the information necessary to demonstrate compliance with this agreement. Most of it is already public: this repository holds the requirements, the threat model, the retention schedule, the record of processing, and the test suite that enforces them.

10.2 The Controller may audit no more than once a year, on 30 days’ notice, at its own cost, unless an audit follows a breach — in which case neither the frequency limit nor the cost allocation applies.


11. Governing law

Brazilian law, with the courts of the Processor’s domicile, without prejudice to a data subject’s right to bring proceedings where they are entitled to under LGPD Art. 22 or GDPR Art. 79.


12. Signatures

ControllerProcessor
NameArtur Rios
TitleController / 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

RoleWhoWhat they do
Incident leadArtur Rios (controller)Declares an incident, decides whether it is a personal data breach, decides whether it is notifiable, and signs the register entry
EncarregadoArtur RiosNotifies the ANPD, and the data subjects where required
Processor contactArtur RiosNotifies 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 breachIs not, by itself
The database or a backup copied by somebody unauthorisedA failed sign-in, however many
An identity acting outside its authorisation and reaching another tenant’s dataA refused authorisation attempt — that is the control working
Password hashes, TOTP secrets or tokens disclosedAn expired token being rejected
An erasure that did not happen, or was undone by a restore and not reconciledA scheduled pass failing once and succeeding on the next tick
Log files containing personal data leaving the host unencryptedLogs reaching the operator’s own collector as designed
A backup restored over current data, losing records irrecoverablyA 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.

ObligationDeadlineFrom
Notify the ANPD (LGPD Art. 48, Res. 15/2024)3 working daysAwareness
Notify the supervisory authority (GDPR Art. 33)72 hoursAwareness
Notify affected data subjects (Art. 34, LGPD Art. 48)Without undue delay, where risk is highAwareness
Notify a tenant controller whose scope is affected24 hours — DPA §8Awareness

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

  1. 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.
  2. 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.
  3. 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.
  4. Decide: is it a personal data breach? Against §3. Record the reasoning either way.
  5. 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.
  6. Notify, per §4.
  7. Complete the register entry, per §7.
  8. 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:

KindRaised whenWhy it matters
REPEATED_REFUSALSOne identity has more refused writes than the threshold, within the windowSustained refusals are what somebody probing for what they may do looks like
LOCKOUT_SPIKEMore accounts are locked out at once than the thresholdOne is ordinary; many together is credential stuffing against a list of addresses
CREDENTIAL_VERIFICATION_SHEDDINGThe hash gate refuses more often than the thresholdThe 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:

FieldNotes
ReferenceSequential
Time of awarenessThe timestamp from step 2. Every deadline counts from here
Time of the incidentWhere known; often it is not, and “unknown” is a legitimate entry
How it came to attentionA SECURITY_SIGNAL, a report, a routine review — this is what tells you whether detection works
NatureDestruction, loss, alteration, disclosure, or unauthorised access
Categories and approximate number of data subjectsApproximate is what Art. 33(3)(a) asks for
Categories and approximate number of records
Likely consequencesArt. 33(3)(c)
Measures takenContainment, remediation, mitigation of adverse effects
Notifiable? With reasoningBoth answers need reasoning. “No” needs it most
Authority notified, whenOr why not
Subjects notified, whenOr why not
Tenant controllers notified, whenPer DPA §8
Signed off byThe 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

  1. Behavior-driven. Tests describe behavior, not implementation. Every test is written in the Given / When / Then style and is named accordingly.
  2. 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.
  3. 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.
  4. 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.
  5. 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 caseTest kindTest 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 caseFunctional*.WebApi.Tests

Notes:

  • Anemic domain entities (plain data holders with only properties and navigation collections, e.g. Scope ) carry no behavior and therefore get no unit tests — their behavior is observed through the handlers and functional tests. A domain class earns its own unit tests the moment it gains a method that makes a decision (guard clause, state transition, hashing, token validity, etc.).
  • Every use case that reaches the API must have functional coverage, even when its handler is already unit-tested. The two layers verify different things (see §6 and §7).

4. Solution & project layout

Tests live under a top-level tests/ directory that mirrors the src/ layer folders. Each production project has exactly one corresponding test project, named by appending .Tests to the project name. Each production class has one corresponding test class, named by appending Tests to the class name.

src/
  Application/
    ArturRios.Heimdall.Command/           →  CreateScopeCommandHandler.cs
    ArturRios.Heimdall.Query/             →  GetScopeByIdQueryHandler.cs
    ArturRios.Heimdall.Shared/            →  ScopeOwnershipChecker.cs
  Domain/
    ArturRios.Heimdall.Domain/
  Infrastructure/
    ArturRios.Heimdall.Data/              →  Seeding/MasterUserOptions.cs
  Presentation/
    ArturRios.Heimdall.WebApi/            →  ScopeController.cs

tests/
  Application/
    ArturRios.Heimdall.Command.Tests/     →  CreateScopeCommandHandlerTests.cs
    ArturRios.Heimdall.Query.Tests/       →  GetScopeByIdQueryHandlerTests.cs
    ArturRios.Heimdall.Shared.Tests/      →  ScopeOwnershipCheckerTests.cs
  Domain/
    ArturRios.Heimdall.Domain.Tests/
  Infrastructure/
    ArturRios.Heimdall.Data.Tests/        →  Seeding/MasterUserOptionsTests.cs
  Presentation/
    ArturRios.Heimdall.WebApi.Tests/      →  ScopeControllerCreateTests.cs (functional)

Rules:

  • One test project per production project, <ProjectName>.Tests.
  • One test class per production class under test, <ClassName>Tests, in a namespace that mirrors the production namespace.
  • The test project must reference the production project it tests via <ProjectReference> (a functional test project references the Web API project; a unit test project references the Application/Domain project it covers).
  • Test projects set <IsPackable>false</IsPackable> and are added to the solution under the matching Tests solution folder.

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):
    • [UnitFact] / [UnitTheory] → Category=Unit
    • [FunctionalFact] / [FunctionalTheory] → Category=Functional
    • Each accepts an optional list of EnvironmentTypes in which the test must not run, and an optional skip condition.
  • WebApiTest<TEntryPoint> — base class for functional tests. Spins up an in-memory host via WebApplicationFactory<T>, and exposes:
    • Gateway — an HttpGateway for issuing HTTP requests.
    • AuthenticateAsync, Authorize, AuthenticateAndAuthorizeAsync — for authenticated calls.
  • FakeRepository<T, long> — in-memory repository test double for unit tests (seed it in Given).
  • FakeScheduler — simulates delayed command/query dispatch.
  • CustomAssert — extra assertions (NullOrEmpty, NotNullOrEmpty, NullOrWhiteSpace, …).

Beyond the ArturRios.Util.Test helpers:

  • Moq is the standard mocking library — use it for every mock/stub of a collaborator that isn’t already covered by a purpose-built fake from ArturRios.Util.Test. Do not introduce a second mocking framework.
  • Bogus is the standard way to generate test data — use it whenever a test needs populated entities, commands, or DTOs, instead of hand-writing large object literals or relying on shared mutable fixtures.

Use dotnet test --filter "Category=Unit" or "Category=Functional" to run one kind at a time.

5.1 Every run records a .trx

tests/Directory.Build.props points every test project at tests/default.runsettings, which enables the trx logger. An ordinary dotnet test therefore writes a result file per project under TestResults/ (git-ignored) and prints its path.

This exists because a console summary is not evidence. A functional test failed once in a full-solution run, the summary was the only record, and the failing test’s name was lost — the failure never reproduced in 23 subsequent runs, so there was nothing left to investigate. A .trx costs nothing per run and means the next intermittent failure identifies itself.

To chase a failure that only appears occasionally, repeat the run and let the harness collect the names:

python scripts/flake_hunt.py --runs 25

It runs the whole solution in a loop, stops at the first failing run (--keep-going measures a rate instead), and prints every test any run recorded as failed. Hunt with the whole solution rather than a single project: a failure that only appears under the CPU contention of parallel test projects will not reproduce from one project alone.

5.2 The Command tests use a patient password-hash gate

Production Argon2id is 600 MB and 16 lanes, so the Command tests derive through PasswordHashGate.Shared, which bounds concurrency at four. A module initializer (Support/CommandTestGate.cs) replaces it for the whole assembly with a gate of the same permit count and a two-minute wait, so a run under CPU or memory pressure queues instead of failing on the production ten-second refusal. PasswordHashGateTests still pins the production defaults.

6. Unit testing standard (Commands, Queries, Domain behavior)

6.1 Scope of a unit test

A unit test instantiates the class under test directly, passing test doubles for every constructor dependency, invokes one method, and asserts the outcome. No web host, no database, no network, no real time.

Handlers in this project return an ArturRios.Output.DataOutput<T> and report failures as errors on that output rather than by throwing (see CreateScopeCommandHandler ). Unit tests therefore assert on output.Success, output.Errors, output.Messages, and output.Data — not on exceptions (except where the code is genuinely expected to throw).

6.2 Test doubles

  • Replace repository dependencies (IAsyncReadOnlyRepository<T, long>, IAsyncRepository<T, long>) with FakeRepository<T, long> from ArturRios.Util.Test, seeded in the Given step to represent the database state the scenario assumes.
  • Replace other collaborators (validators, mediators, e-mail/token/clock services) with a Moq mock configured to return the value the scenario needs (new Mock<IValidator<T>>(), mock.Setup(...).ReturnsAsync(...), and mock.Object as the dependency). Moq is the single mocking library for the project — don’t hand-roll one-off fakes when a purpose-built fake from ArturRios.Util.Test doesn’t already exist, and don’t mix in another mocking framework.
  • Generate any entities, commands, or DTOs a scenario needs with Bogus (Faker<T>), rather than large inline object literals or shared mutable fixtures. Set only the fields the behavior under test actually depends on; let Bogus fill the rest so tests stay focused and independent.
  • The goal is always the same: pin every collaborator to a known result so the only variable is the logic of the method under test.

6.3 Structure & naming

  • Test method name: GivenSomeCondition_WhenSomeAction_ThenSomeOutput.
  • Method body is split into three commented sections in order: // Given, // When, // Then.
  • One logical behavior per test. Use [UnitTheory] with [InlineData]/[MemberData] for the same behavior across multiple inputs.

6.4 Coverage per handler

For each Command/Query handler, cover:

  1. The main (success) flow of the use case.
  2. Every alternative/exception flow defined for that use case (the AF-xx rows in the Use Case Specification). Authorization flows enforced by the framework/middleware (e.g. 403 Forbidden via [RoleRequirement]) are verified in the functional tests instead, not here.

Example — UC-01 (Create Scope) has main flow + AF-01a (name exists), AF-01b (invalid input / no owner), AF-01d (owner is not a valid ScopeAdmin). AF-01c (not System Admin) is a functional concern. That yields unit tests such as:

using ArturRios.Util.Test.Attributes;
using ArturRios.Util.Test.Mock;
using Bogus;
using FluentValidation;
using FluentValidation.Results;
using Moq;

namespace ArturRios.Heimdall.Command.Tests;

public class CreateScopeCommandHandlerTests
{
    [UnitFact]
    public async Task GivenUniqueNameAndValidOwners_WhenHandlingCreateScope_ThenScopeIsCreated()
    {
        // Given
        var scopeReader = new FakeRepository<Scope, long>();
        var scopeWriter = scopeReader;                     // same in-memory store
        var roleReader = new FakeRepository<Role, long>();
        var scopeAdminRole = new Role { Name = nameof(Roles.ScopeAdmin) };
        roleReader.Create(scopeAdminRole);

        // Bogus builds the collaborator data; set only the fields the behavior depends on.
        var owner = new Faker<Person>()
            .RuleFor(p => p.PublicId, _ => Guid.NewGuid())
            .RuleFor(p => p.RoleId, _ => scopeAdminRole.Id)
            .RuleFor(p => p.IsDeleted, _ => false)
            .Generate();
        var personReader = new FakeRepository<Person, long>();
        personReader.Create(owner);

        // Moq stubs the non-repository collaborator (the validator).
        var validator = new Mock<IValidator<CreateScopeCommand>>();
        validator
            .Setup(v => v.ValidateAsync(It.IsAny<CreateScopeCommand>(), It.IsAny<CancellationToken>()))
            .ReturnsAsync(new ValidationResult());          // no failures = valid

        var handler = new CreateScopeCommandHandler(
            validator.Object, scopeReader, personReader, roleReader, scopeWriter);

        var command = new CreateScopeCommand { Name = "Acme", OwnerIds = [owner.PublicId] };

        // When
        var output = await handler.HandleAsync(command);

        // Then
        Assert.True(output.Success);
        Assert.NotNull(output.Data);
        Assert.Equal("Acme", output.Data!.Name);
        Assert.Contains(ScopeMessages.ScopeCreatedSuccessfully, output.Messages);
    }

    [UnitFact]
    public async Task GivenScopeNameAlreadyExists_WhenHandlingCreateScope_ThenReturnsNameAlreadyExistsError()
    {
        // Given a store already containing a scope named "Acme" …
        // When handling a CreateScopeCommand for "Acme" …
        // Then
        Assert.False(output.Success);
        Assert.Contains(ScopeMessages.NameAlreadyExists, output.Errors);
    }

    // GivenInvalidInput_… (AF-01b), GivenOwnerIsNotScopeAdmin_… (AF-01d), etc.
}

The example shows the pattern: construct the handler with fakes, drive one behavior, assert on DataOutput. Adapt the fakes to the collaborators of the handler you are testing.

6.5 Domain behavior tests

When a Domain class gains real behavior, unit-test that behavior in *.Domain.Tests, same GWT pattern, no infrastructure. Test the method’s contract: valid inputs produce the expected result; invalid inputs are rejected/guarded; boundaries and invariants hold.

7. Functional testing standard (Web API)

7.1 Scope of a functional test

A functional test is an end-to-end test of a use case through the API. It:

  1. Starts the Web API host (via WebApiTest<Program>).
  2. Points the app at a real PostgreSQL database running in a throwaway Testcontainers container.
  3. Issues real HTTP requests through Gateway.
  4. Asserts both:
    • the API response (HTTP status code, body/DataOutput payload, messages/errors), and
    • the database state after the operation (row created/updated/deleted, join rows present, IsDeleted toggled, etc.).

This is where authorization, routing, model binding, validation wiring, the mediator, EF Core mapping, and the database schema are all verified together. The existing HealthCheckTests is the reference for the request/response half of this pattern:

public class HealthCheckTests(EnvironmentType environment = EnvironmentType.Local)
    : WebApiTest<Program>(environment)
{
    [FunctionalFact]
    public async Task GivenApiWorking_WhenHealthCheckEndpointCalled_ThenEndpointReturnsOk()
    {
        var output = await Gateway.GetAsync<DataOutput<string>>("/api/healthcheck");

        Assert.Equal(HttpStatusCode.OK, output.StatusCode);
        Assert.Equal("Hello world!", output.Body?.Data);
    }
}

7.2 The database: Testcontainers PostgreSQL

Functional tests must not use an in-memory EF provider or a developer’s local database. They run against a real PostgreSQL instance created by Testcontainers at test time, so the environment closely matches production (the app uses the PostgreSQL provider — see Startup.AddDependencies ).

The app reads its connection from environment variables (HEIMDALL_DATA_CONNECTIONSTRING, HEIMDALL_DATA_DATABASETYPE) via AddDataConfigFromEnvironment<AppDbContext>. The functional suite therefore:

  1. Starts a PostgreSqlContainer once for the whole functional suite (an xUnit ICollectionFixture, so the container isn’t recreated per test class).

  2. Applies the schema/migrations to that container’s database.

  3. 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.

  4. Keeps tests independent by making their data unique, not by resetting the database. The container’s database accumulates rows for the whole run; nothing is truncated between tests and no test runs in a rolled-back transaction. Independence comes from every test seeding its own rows under identifiers no other test can produce — Guid.NewGuid() public identifiers, scope-{Guid:N} names, user-{Guid:N}@test.local addresses — and asserting only about those rows.

    This is a deliberate choice, not an omission. Truncation would delete the roles and the master system administrator that DatabaseSeeder writes at startup, which SeedingTests and the login tests legitimately depend on, and a per-test transaction cannot wrap work done by the API’s own DbContext inside the host.

    What this asks of a new test: never assert on a global count or an unfiltered query — the only two in the suite (Roles == 3, and the master user counted by e-mail) are safe because nothing else can create those rows. Filter every listing by something unique to the test, the way ScopeControllerViewTests filters by the scope name it just generated. A test that counts all scopes passes alone and fails the moment another class seeds one.

This shared container/fixture is built once as test infrastructure; individual use-case tests just derive from the functional base class and use it.

// Shared once for the whole functional suite.
public sealed class PostgresFixture : IAsyncLifetime
{
    private readonly PostgreSqlContainer _container = new PostgreSqlBuilder()
        .WithImage("postgres:18-alpine")
        .Build();

    public string ConnectionString => _container.GetConnectionString();

    public async ValueTask InitializeAsync()
    {
        await _container.StartAsync();
        Environment.SetEnvironmentVariable("HEIMDALL_DATA_CONNECTIONSTRING", ConnectionString);
        Environment.SetEnvironmentVariable("HEIMDALL_DATA_DATABASETYPE", "PostgreSql");
        // Create the schema by applying the real EF Core migrations — see §10.1.
        await using var context = new AppDbContext(/* options bound to ConnectionString */);
        await context.Database.MigrateAsync();
    }

    public ValueTask DisposeAsync() => _container.DisposeAsync();
}

[CollectionDefinition(nameof(FunctionalCollection))]
public sealed class FunctionalCollection : ICollectionFixture<PostgresFixture>;

7.3 Structure, naming, and assertions

  • Functional test classes derive from WebApiTest<Program>, join the functional collection, and use [FunctionalFact] / [FunctionalTheory].
  • Same GWT naming and // Given / // When / // Then sections as unit tests.
  • Authenticate with AuthenticateAndAuthorizeAsync when the endpoint requires a role, and add explicit tests for the authorization alternative flows (403 Forbidden, 401 Unauthorized).
  • In Then, assert the response and open the database (through the fixture / a scoped AppDbContext) to assert the persisted state.
[Collection(nameof(FunctionalCollection))]
public class ScopeControllerTests : WebApiTest<Program>
{
    public ScopeControllerTests(PostgresFixture db) : base(EnvironmentType.Local) { /* seed as needed */ }

    [FunctionalFact]
    public async Task GivenSystemAdminAndValidPayload_WhenPostScopes_ThenScopeIsCreatedAndReturned()
    {
        // Given an authenticated System Admin and an existing ScopeAdmin owner …
        // When
        var response = await Gateway.PostAsync<DataOutput<CreateScopeCommandOutput>>(
            "/api/scopes", new CreateScopeCommand { Name = "Acme", OwnerIds = [ownerPublicId] });

        // Then — response
        Assert.Equal(HttpStatusCode.Created, response.StatusCode);
        Assert.Equal("Acme", response.Body?.Data?.Name);

        // Then — database state
        // load the Scope + its SCOPE_OWNER rows from the DB and assert they exist as expected
    }

    // GivenDuplicateName_… → 409 (AF-01a)
    // GivenInvalidPayload_… → 400 (AF-01b)
    // GivenNonSystemAdmin_… → 403 (AF-01c)
    // GivenOwnerNotScopeAdmin_… → 400 (AF-01d)
}

7.4 Coverage per endpoint

For each controller endpoint of the use case, cover the main flow and every alternative flow from the Use Case Specification, including the authorization flows that unit tests deliberately skip.

8. Naming & style conventions (quick reference)

ItemConventionExample
Test project<Project>.TestsArturRios.Heimdall.Command.Tests
Test class<ClassUnderTest>TestsCreateScopeCommandHandlerTests
Test methodGiven…_When…_Then…GivenScopeNameAlreadyExists_WhenHandlingCreateScope_ThenReturnsNameAlreadyExistsError
Unit fact/theory[UnitFact] / [UnitTheory]—
Functional fact/theory[FunctionalFact] / [FunctionalTheory]—
Body sections// Given → // When → // Then—

9. Per-use-case workflow (apply every time)

After implementing a use case, do the following before considering it complete:

  1. Identify the artifacts the use case produced (handlers, validators, domain behavior, endpoints) using the table in §3.
  2. Create/locate the test projects that mirror those artifacts’ projects (§4), each referencing the project under test, with the standard package set (§5).
  3. Unit-test each Command/Query handler: main flow + every applicable AF-xx alternative flow, with all collaborators faked/mocked (§6).
  4. Unit-test any new Domain behavior (§6.5). Skip anemic entities.
  5. 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).
  6. Name everything per §8 and write every test in Given/When/Then form.
  7. Run the suite (dotnet test) and confirm both Category=Unit and Category=Functional pass.
  8. Only then is the use case done.

10. Current test inventory

Every test project uses the standard package set from §5, references the project it tests, and is registered in the solution under the Tests folder. Six projects mirror the src/ tree:

Test projectReferencesTest classes
tests/Application/ArturRios.Heimdall.Command.Tests…CommandHandler tests for every command — CreateScope, UpdateScope, DeleteScope, HardDeleteScope, CreateAdmin, CreateUser, CreateScopeOwner, AddScopeOwner, RemoveScopeOwner, PromoteScopeUser, SetGoogleSignIn, GoogleSignIn, GoogleSignOut, DeleteGoogleUser, HardDeleteGoogleUser, UpdatePerson, DeletePerson, HardDeletePerson, Login, PasswordRecovery, ResetPassword, VerifyEmail, ResendVerificationEmail, CreateApplication, UpdateApplication, DeleteApplication, HardDeleteApplication, CreateScopePermission, UpdateScopePermission, DeleteScopePermission, HardDeleteScopePermission — plus the validators that guard them, EmailVerificationServiceTests, and PasswordResetServiceTests
tests/Application/ArturRios.Heimdall.Query.Tests…QueryGetScopeByIdQueryHandlerTests, ListScopesQueryHandlerTests, GetPersonByIdQueryHandlerTests, ListScopePersonsQueryHandlerTests, ListScopeOwnersQueryHandlerTests, GetApplicationByIdQueryHandlerTests, ListScopeApplicationsQueryHandlerTests, GetGoogleUserByIdQueryHandlerTests, ListScopeGoogleUsersQueryHandlerTests, GetScopePermissionByIdQueryHandlerTests, ListScopePermissionsQueryHandlerTests, GetDetailedHealthQueryHandlerTests, DatabaseHealthCheckTests
tests/Application/ArturRios.Heimdall.Shared.Tests…SharedScopeOwnershipCheckerTests — the scope-authorization rule shared by UC-06 AF-06e and UC-07 AF-07b
tests/Domain/ArturRios.Heimdall.Domain.Tests…DomainEmpty by design — every entity is still anemic, so §3’s rule gives them no unit tests
tests/Infrastructure/ArturRios.Heimdall.Data.Tests…DataSeeding/MasterUserOptionsTests
tests/Presentation/ArturRios.Heimdall.WebApi.Tests…WebApiOne 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

IDMethodWhereStatus
NFR-01 (technology)InspectionDirectory.Packages.props, project filesVerified
NFR-02 (password hashing)TestLoginCommandHandlerTests, ResetPasswordCommandHandlerTests — a stored hash never equals the password, and a reset re-saltsVerified
NFR-03 (signed tokens, configurable expiry)TestAuthControllerLoginTests reads expiresAt; IdentityUserMapperTests covers the claim round tripVerified
NFR-04 (authentication required)TestEvery controller suite has a no-token case; ActorLivenessTests covers the liveness exemptionVerified
NFR-05 (response time)Measurement + regression ceilingsResponseTimeMeasurementTests for one caller; scripts/load_test.py for the 128-caller conditionMeasured; see SRD §6.1 and §6.3
NFR-06 (horizontal scaling)Test + inspectionDataProtectionKeyRingTests for the key ring; inspection for session-free authenticationVerified — and a defect found, see SRD §6.2
NFR-07 (integrity under logical deletion)TestNonFunctionalRequirementTests walks every foreign key in the catalogue after UC-04’s cascadeVerified
NFR-08 (hard delete cascades a scope)TestHardDeleteScopeCommandHandlerTests, ScopeControllerHardDeleteTestsVerified
NFR-09 (audit trail)TestAuditLoggingTests, AuditingCommandHandlerTests — both outcomesVerified
NFR-10 (input validation)TestEvery *ValidatorTests class, plus the 400 cases in the controller suitesVerified
NFR-11 (hard delete cascades a person)TestHardDeletePersonCommandHandlerTestsVerified
NFR-12 (a scope keeps an owner)TestUC-09, UC-10 and UC-22 suitesVerified
NFR-13 (Google token verification)Test + inspectionGoogleIdTokenValidationSettingsTests 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 validatorPartly verified — see 11.2
NFR-14 (hard delete cascades Google Users)TestHardDeleteScopeCommandHandlerTestsVerified
NFR-15 (no internal ids exposed)TestOpenApiContractTests over the published document; SchemaTests on the shapeVerified
NFR-16 (secrets at rest)TestAuthControllerEnableTwoFactorAuthTests — the secret is stored encrypted and never returned twice; recovery codes stored as hashesVerified
NFR-17 (challenge token)TestNonFunctionalRequirementTests — 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 secondsVerified
NFR-18 (password verification cost)MeasurementResponseTimeMeasurementTestsMeasured; see SRD §6.1
NFR-30 (token issuer and audience)TestTokenIssuerAndAudienceTests — 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 readerVerified
NFR-31 (signing secrets)TestTokenSigningSecretsTests — length, ASCII, the empty previous secret, and messages that never carry the value; TokenOriginTests for HS384 and HS512 refusedVerified

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.

It uses the same three verification methods as the Testing Specification Document §11, for the same reason:

  • Test — executed by the suite. Fails the build when the property stops holding.
  • Inspection — established by reading the code or configuration, and re-established when it changes.
  • Measurement — a number produced by a run and published.

And the same rule: a threat whose verification method is “none” is a finding. §11 collects them.

This is a design-level review. It is not a penetration test, and nothing here was established by attacking a deployment.

2. Method

Threats are numbered TH-nn and grouped by the trust boundary they cross, not by the feature they affect. Feature-organised threat models miss the crossings, and the crossings are where the defects are: two features can each be correct and still combine into a boundary that leaks.

Residual risk is what is left after the control, judged on this codebase rather than in the abstract:

  • Low — the control addresses the threat and something checks the control.
  • Medium — the control addresses the threat, but its coverage has a stated limit, or nothing checks it.
  • High — no control, or the control does not cover the case described.

3. Trust boundaries

flowchart LR
    Anon[Anonymous caller]
    Auth[Authenticated caller]
    API[Heimdall API]
    DB[(PostgreSQL)]
    Google[Google identity]
    Ops[Operator / environment]

    Anon -->|TB-1| API
    Auth -->|TB-2| API
    API -->|TB-3| DB
    API -->|TB-4| Google
    Ops -->|TB-5| API
BoundaryCrossingWhat is on the other side
TB-1Anonymous internet → the authentication endpointsCredentials, tokens, and every account in the system
TB-2An authenticated caller → data belonging to a tenantEvery other tenant’s persons, applications and permissions
TB-3The API → its databasePassword hashes, TOTP secrets, reset tokens, the key ring
TB-4The API → GoogleWhoever a Google ID token claims to be
TB-5An operator → configuration, logs and images; a service holding the signing key → the APIThe 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.

IDThreatControlVerificationResidual
TH-01Password guessing against a known addressPer-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 minuteTestLow
TH-02Enumerating which addresses and scopes exist, from the response or its timingOne 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-03Exhausting the API’s memory through login — or, by keeping PasswordHashGate full, denying password sign-in to everyonePasswordHashGate 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 doesTest + 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-04Guessing a 6-digit second-factor codePer 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)TestLow: 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-05Guessing a password-reset or verification token48 characters from a CSPRNG, single-use, expiringInspectionLow
TH-06Using an MFA-pending challenge token as a full token, at Heimdall or at a consumerDistinct 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)TestLow: a consumer that does not validate the audience still accepts it
TH-25Telling 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)TestLow
TH-26Registering 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 nothingTestLow
TH-27Flooding the holder of a taken address with registration-attempt noticesAt 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 withheldTestLow: three unwanted notices a day per address
TH-28Using registration to send verification mail to addresses of third partiesAt 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)TestLow: 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-38Flooding 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 limitTestMedium: 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-39Following 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 replacedWhen 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 passwordTestLow
TH-29Filling the registration queue so that later requests are lostThe 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 comesInspectionMedium
TH-30A person following a link they never requested, and ending with an account whose password someone else choseClosed 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 nothingTestLow
TH-31A leaked registration link (a forwarded email, a proxy log) used to guess the sign-up passwordThe 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 itTestLow
TH-32A 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-AddressThe 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)TestLow
TH-33A 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 applicationTestMedium
TH-34A 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 releaseTestLow
TH-35Token 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 tokenA 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 itTestLow

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.

IDThreatControlVerificationResidual
TH-07A Scope Admin reaching a scope they do not ownScopeOwnershipChecker re-reads ownership from the database on every attempt, and excludes a deleted personTestLow
TH-08A demoted person keeping the authority they held when their token was issuedActorLivenessFilter compares the role claim against the stored role on every request, and refuses the token when they disagreeTestLow
TH-09A deleted or removed identity continuing to act on an unexpired tokenActorLivenessFilter re-reads both identity tables per request (FR-AU-05, FR-GO-12)TestLow
TH-10Inferring another tenant’s row counts from sequential identifiersOnly PublicId GUIDs cross the boundary (NFR-15)TestLow
TH-11Granting oneself or another person the System Admin roleUpdatePersonCommandHandler refuses any role change unless the acting role is System Admin, and supports no transition other than to System Admin (UC-08)TestLow — the claim it reads is now verified against the row by TH-08’s fix
TH-12Acting on a scope after being removed as its ownerScopeOwnershipChecker re-reads, so removal takes effect immediatelyTestLow
TH-41A Scope Admin learning which other tenants’ scopes a co-owner owns, by reading that co-owner through a scope the two shareEvery 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 ownershipTestLow: adding a co-owner needs no acceptance by the person added (UC-21); FR-PE-12 lists Scope Admins by design
TH-36A 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 changedAn 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 itTestMedium: 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-37An 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 thereThe 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 limitTestMedium: 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.

IDThreatControlVerificationResidual
TH-13Recovering passwords from stolen rowsArgon2id with a per-person salt (NFR-02)TestLow
TH-14Taking over an account using a stolen password-reset or email-verification tokenBoth are stored as a SHA-256 digest and never in the form a caller presents (SingleUseTokenHash)TestLow
TH-15Recovering TOTP secrets from stolen rowsEncrypted at rest with ASP.NET Data Protection (NFR-16)TestMedium
TH-16Replaying a stolen second-factor recovery code, or recovering one from a stolen row80 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 formatTestLow
TH-17Injecting SQL through a caller-supplied valueEF Core parameterises every query; no string-concatenated SQLInspectionLow
TH-18Denying an action that was takenEvery 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 canTest (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

IDThreatControlVerificationResidual
TH-19Presenting a forged or replayed Google ID tokenSignature, 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-upTest (audience, nonce) + inspection (Google’s own checks)Low
TH-20Presenting a token minted for a different OAuth clientThe configured client IDs are the trusted audience, and a test requires that constraint to be presentTestLow
TH-21Reaching an existing password account by signing in with the same address through GoogleThe 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 unconditionallyTestLow
TH-40Taking 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 currentTestLow

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

IDThreatControlVerificationResidual
TH-22Minting a valid token for any identity, using the signing secretThe 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 + inspectionLow
TH-43Creating a token that Heimdall accepts, by a service that holds the signing keyEvery 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)TestMedium: a service holding the signing key can create tokens; asymmetric signing is planned
TH-23Reading a delivered verification, reset or 2FA code — or a recipient address — out of the logsProduction 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 + testMedium
TH-24Using the seeded master accountCredentials are supplied by environment and required at startupInspectionMedium
TH-42Reading 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 LocalOn 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 startTest + inspectionLow: 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.

ThreatWhat it wasWhat closed it
TH-14Password-reset and email-verification tokens were stored in plaintext, while the weaker recovery codes beside them were hashedSingleUseTokenHash: 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-08A demoted account kept its authority until its token expired, and could spend that window promoting itself back permanentlyActorLivenessFilter compares the role claim against the row it was already reading, and refuses a token whose role is out of date
TH-21Nobody had established what a Google sign-in does with an address that already belongs to a password accountTraced: 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-03The 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 policyPasswordHashGate: four concurrent derivations process-wide, every derivation on a request path, 503 rather than an unbounded queue. Measured at no latency cost
TH-18The audit log was append-only by convention; the API’s own credentials could rewrite the trailTriggers 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-22The signing secret could not be replaced without invalidating every token in flight, so it was never rotatedHEIMDALL_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-35Finding: Heimdall’s middleware validates signature and lifetime only, not the audience (ArturRios.Jwt IsTokenValidAsync), so an application token, signed with the person tokens’ keys, would pass itResponse: 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-36A 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 tokenThe 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:

OrderStatusSet when
1TodoThe use case has not been started (default).
2In ProgressA feature branch has been created and implementation has begun.
3TestingImplementation is finished; unit and functional tests are being written, run, and fixed until green.
4DoneThe pull request has been reviewed and merged to develop; the issue is then closed.

An issue only ever moves forward during normal flow. If review requests changes, work continues on the same branch (still linked to the same issue) until tests pass again and the PR is re-reviewed.

4. Step-by-step

Step 1 — Branch from develop

Every use case is implemented on its own branch, created from an up-to-date develop — the integration branch. main only ever receives releases (see §5 ):

git switch develop
git pull
git switch -c feature/uc-01-create-scope

Branch naming pattern:

feature/uc-##-use-case-name
  • ## — the zero-padded use case number (01, 02, … 29).
  • use-case-name — the use case name in lower-case kebab-case.

A change that is not a use case — a defect, a dependency, documentation — goes on a fix/<name> or feature/<name> branch in the same way. Those two prefixes are the only ones the Branch Policy check accepts into develop (besides Dependabot’s), and it also refuses a branch that was cut from main instead of develop.

Use caseBranch
UC-01: Create Scopefeature/uc-01-create-scope
UC-11: Login (Authenticate)feature/uc-11-login
UC-25: Sign Up / Sign In via Googlefeature/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:

  1. 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.
  2. 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.
  3. 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:

WhenWhat
Every quarterRestore 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 quarterConfirm encryption at rest is still in force for the database volume and its backups, and that the database connection still requires TLS
Every six monthsRe-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 processingRevisit the DPIA , the record of processing, and the retention schedule

The cadence matters less than the fact that each item has one. An unscheduled review is one that happens after an incident.

Step 5 — Test until green

Following the Testing Specification Document :

  1. Write the unit tests for every Command/Query handler and any new Domain behavior (main flow + each applicable AF-xx alternative flow).
  2. Write the functional tests for each endpoint (main flow + every AF-xx, including the authorization flows), end-to-end against Testcontainers PostgreSQL.
  3. Run the tests (dotnet test, and/or filtered by Category=Unit / Category=Functional).
  4. Fix any failures — in the implementation or the tests.
  5. 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).
  • The feature branch was deleted.
  • The issue is in Done and closed.

7. References

10.14 - Operations & Infrastructure Document

Hosting, the four environments, the technical foundation and the health-check feature.

Operations & Infrastructure Document — Heimdall API

0. Hosting region

Personal data is stored in Brazil, and that is a constraint rather than an incidental fact: it determines which transfer rules apply to everything leaving the deployment. Moving the region means redoing the assessment in §7 of the Data Protection Document , because the outbound flows to Mailgun and Google are measured from wherever the data sits.

Note also that Brazil holds no EU adequacy decision. If any tenant scope serves data subjects in the EEA, hosting here is itself a restricted transfer under GDPR Chapter V — see §7.2 of that document, which records this as a question the controller must answer rather than an assumption.

0.1 Encryption, backups, and restoring without undoing an erasure

NFR-25. This section states what the deployment must provide and what the API does about it. Two of the three requirements are properties of the hosting rather than of this codebase, and are marked accordingly: the controller must confirm them — they cannot be verified from the repository.

Encryption at rest

Database volume✅ Confirmed by the controller, 10 September 2026 — the data is encrypted at rest on the server
Backups✅ Confirmed, 10 September 2026 — encrypted client-side before upload, on a storage service that holds no key able to decrypt them
Application-level✅ Argon2id password hashes, encrypted TOTP secrets (NFR-16), hashed tokens and recovery codes

The two rows were confirmed separately, and deliberately so: “encrypted on the server” establishes the volume, and a backup written elsewhere is a different artefact with its own key management. A regime that encrypts the live database while shipping plaintext dumps to object storage is common enough to be worth ruling out rather than assuming away. Both are now confirmed.

The service was identified as MEGA on 10 September 2026, which settles the jurisdiction question this section previously left open. It is answered below under Where the backups actually are, and the transfer ground is recorded in §7.3 of the Data Protection Document .

The application-level protections are real and tested, and they are not a substitute. The threat model’s TB-3 covers what an attacker recovers from stolen rows, which presupposes the rows were read; it says nothing about a disk or a snapshot leaving the building. Everything not listed in the third row above — every name, every address, every audit entry — is stored in clear text within the database and is protected only by whatever the volume beneath it provides.

Encryption in transit

UseHttpsRedirection covers the inbound edge. The database connection is the other half, and the API warns at start-up when the connection string does not require TLS.

Npgsql’s default SSL Mode is Prefer, which silently falls back to an unencrypted connection when the server does not offer TLS — so a deployment can be sending credentials and addresses in clear text while looking correctly configured. Set SSL Mode=Require, VerifyCA or VerifyFull in HEIMDALL_DATA_CONNECTIONSTRING.

It warns by default rather than refusing to start, and that is a departure from how this codebase treats other security configuration — TH-23’s email control fails start-up in Production. Failing by default would take down a working deployment on upgrade over a setting the operator may not control directly.

Set HEIMDALL_DATA_REQUIRE_TLS=true to turn the warning into a refusal. That is the right setting once TLS is confirmed in place: from that point a connection string that does not ask for encryption is a misconfiguration rather than a known state, and starting anyway would be starting in the one condition the check exists to prevent. It is opt-in rather than the default only so that enabling it is a decision somebody makes, not one that happens to them during an upgrade.

The backup regime

ServiceMEGA — Mega Cloud Services Limited, Auckland, New Zealand✅ Confirmed 10 September 2026
ScheduleDaily full backup✅ Confirmed 10 September 2026
EncryptionClient-side, zero-knowledge — MEGA holds no key that decrypts the contents✅ Confirmed 10 September 2026
Retention35 days✅ Confirmed 10 September 2026
LocationEurope, 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 testingQuarterly, 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.

  1. 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.
  2. 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.
  3. 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:

  1. Collect the anonymised identifiers from the logs, covering the period from the backup’s timestamp to now.
  2. POST /api/auth/erasure-reconciliation with that list, as a System Admin.
  3. 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.
  4. 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.

EnvironmentWhereDeployed byASPNETCORE_ENVIRONMENTPublic hostsDatabase
localThe developer’s Windows machine, Docker DesktopBy hand: docker compose --env-file docker/local.env up -d --buildDevelopmenthttp://localhost:8080heimdall_local, PostgreSQL on Windows
developmentThe 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 onDevelopmentheimdall-api-dev.example.com; /api/ under heimdall-dev.example.comheimdall_development, PostgreSQL on the VPS
homologationThe VPS, on demandJenkins, on every push of a release/x.y.z branchStagingheimdall-api-hml.example.com; /api/ under heimdall-hml.example.comheimdall_homologation, PostgreSQL on the VPS
productionThe VPS, always onJenkins, on a green release/x.y.z → main pull request, which it then merges and tagsProductionheimdall-api.example.com; /api/ under heimdall.example.comheimdall, PostgreSQL on the VPS

Each environment has its own database and its own database login, its own token signing secret and its own master user, so nothing issued or stored in one is accepted or readable in another. The env files of the three VPS environments are kept on the VPS (/etc/yggdrasil/<environment>/), filled in from the repository’s docker/<environment>.env.example templates.

Development and homologation hold no real personal data. Development and homologation run without Mailgun, so the API logs verification tokens, reset tokens and 2FA codes instead of sending them, and EF Core logs parameter values outside Production — which is what TH-23 in the Threat Model Document accepts for non-production deployments, on the condition that no real person’s data is in them. They are tested with made-up people, and production data is never copied into them. Because both serve public hosts, the API runs there with EF Core’s parameter logging and the developer exception page off (NFR-28), unless the env file sets HEIMDALL_ALLOW_SENSITIVE_DIAGNOSTICS=true; the templates leave it empty.

The supported database setup gives each environment an owner role of its own for the migrations (HEIMDALL_MIGRATIONS_CONNECTIONSTRING) and an API login that owns nothing and has only the row privileges it needs (NFR-29, Threat Model TH-18). The operations guide, “Database roles”, has the grants, the checks and the steps for a new environment and for switching one over; the operator applies them.

The step-by-step setup of each environment is on the documentation site, under Environments and deployment .

1. Introduction

1.1 Purpose

This document captures cross-cutting platform concerns for the Heimdall API that fall outside the identity domain modeled in the Vision Document , System Requirements Document , and Use Case Specification Document .

It covers two areas:

  • The technical foundation — the project scaffolding, solution architecture, and initial data infrastructure the domain features are built on.
  • Health & monitoring — the operational endpoints used to observe that the API and its dependencies are up, and the Prometheus metrics that show how it is performing.

These are functional/operational capabilities of the platform rather than the identity domain, so they are documented here to keep the domain documents focused while still tracking the work formally. The specific technologies and versions this platform is built on are defined once in the Technology Stack Document and referenced from here rather than duplicated.

ItemGitHub IssueStatus
Project scaffolding & initial infrastructure#31✅ Implemented
Health Check feature#32✅ Implemented

2. Technical Foundation (Project Scaffolding & Initial Infrastructure)

Corresponds to issue #31 . Status: Implemented (delivered via PR #1 — feat/data-infrastructure — and preceding commits).

2.1 Overview

The solution is a layered (DDD-style) .NET Web API. The foundational scaffolding establishes the project structure, the Entity Framework Core data layer with the initial migration, startup seeding, and the functional test harness that the identity use cases (UC-01 … UC-29) are implemented on top of.

2.2 Solution Architecture

graph TD
    subgraph Presentation
        WebApi[ArturRios.Heimdall.WebApi<br/>ASP.NET Core Web API host]
    end
    subgraph Application
        Command[ArturRios.Heimdall.Command]
        Query[ArturRios.Heimdall.Query]
        Shared[ArturRios.Heimdall.Shared]
    end
    subgraph Domain
        DomainLayer[ArturRios.Heimdall.Domain<br/>Entities & data model]
    end
    subgraph Infrastructure
        Data[ArturRios.Heimdall.Data<br/>EF Core DbContext & migrations]
        DB[(Database)]
    end

    WebApi --> Command
    WebApi --> Query
    Command --> Domain
    Query --> Domain
    Command --> Shared
    Query --> Shared
    Command --> Data
    Query --> Data
    Data --> DomainLayer
    Data --> DB

2.3 Delivered Capabilities

AreaRequirementStatus
IR-01The solution shall be organized into Domain, Application (Command / Query / Shared — CQRS split), Infrastructure/Data, and Presentation/WebApi layers✅
IR-02The data layer shall use Entity Framework Core with a design-time factory and an initial migration✅
IR-03Database tables shall use snake_case, singular naming, with EF diagnostics gated✅
IR-04Role IDs shall be pinned to the Roles enum values✅
IR-05On startup, the system shall seed the roles and the master System Admin✅
IR-06A migration menu script shall be provided under scripts/✅
IR-07Environment configuration files shall be copied to the build output✅
IR-08A functional test container shall apply migrations and assert the resulting schema✅
IR-09The 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:

  1. A basic liveness (“hello world”) endpoint — a lightweight, public check that the API is up.
  2. 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

IDRequirementPriority
FR-HC-01The system shall expose a public liveness endpoint (GET /api/healthcheck) that confirms the API process is running and responding, requiring no authenticationHigh
FR-HC-02The system shall expose a detailed health check endpoint (GET /api/healthcheck/detailed) accessible only to System AdminsHigh
FR-HC-03The detailed health check shall verify the database connectionHigh
FR-HC-04The detailed health check shall report the status of each verified service individuallyHigh
FR-HC-05The 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 downHigh
FR-HC-06The health check design shall be extensible, allowing new service verifications to be added without changing the response contractMedium
FR-HC-07The 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

MethodEndpointDescriptionAuth Required
GET/api/healthcheckBasic liveness check — confirms the API is on (“hello world”)No (Public)
GET/api/healthcheck/detailedDetailed health check — reports per-service status and an aggregate Healthy / Unhealthy general statusSystemAdmin

3.4 Detailed Health Check — Response Contract

The detailed response reports a status (the aggregate general status) and a services array with one entry per verified service.

All services up:

{
  "status": "Healthy",
  "services": [
    { "name": "Database", "status": "Healthy" }
  ]
}

Database connection down:

{
  "status": "Unhealthy",
  "services": [
    { "name": "Database", "status": "Unhealthy" }
  ]
}

The aggregate status is Healthy only when every entry in services is healthy; if any service is unhealthy, the aggregate is Unhealthy (FR-HC-05). Adding a new verification (FR-HC-06) simply appends another entry to services and participates in the same aggregation rule.

3.5 Use Case — UC-30: Check API Health

FieldValue
IDUC-30
NameCheck API Health
ActorsAnonymous / Monitoring System (liveness), System Admin (detailed)
DescriptionObserve whether the API is running (liveness) and whether its dependencies are healthy (detailed)
PreconditionsFor the detailed check, the actor is authenticated with the SystemAdmin role. The liveness check has no preconditions
PostconditionsHealth information is returned; no system state is modified

Main Flow (liveness):

  1. A caller (monitor, load balancer, or anonymous user) sends GET /api/healthcheck.
  2. 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[] }
  1. A System Admin sends GET /api/healthcheck/detailed.
  2. The system authorizes the request; only System Admins may proceed.
  3. The system verifies each registered service (currently: the database connection).
  4. The system computes the aggregate general status (Healthy if all up, Unhealthy otherwise).
  5. The system returns the per-service statuses and the aggregate status.

Alternative Flows:

IDConditionOutcome
AF-30aDetailed check requested by a caller who is not a System Admin403 Forbidden
AF-30bDetailed check requested with no/invalid authentication401 Unauthorized
AF-30cOne or more verified services are down200 OK (or 503, per FR-HC-07) with status = Unhealthy and the failing service(s) marked

3.6 Authorization

ActionSystemAdminScopeAdminUserAnonymous
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 ).

PropertyValue
EndpointGET /metrics, Prometheus text format
Port9464 — HEIMDALL_METRICS_PORT; blank means the default, 0 disables the exporter entirely
AuthenticationNone — the port is the access control, see below
CollectedASP.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
Resourceservice.name = heimdall-api

Access control is the port, not the host name. The API runs behind Traefik, which forwards the client’s own Host header, so any check on it is a check on an attacker-chosen value. The scrape endpoint is instead answered only when the connection arrived on the metrics port (HttpContext.Connection.LocalPort), which the socket decides. Traefik forwards to 8080; Prometheus scrapes 9464 directly over a private Docker network; Compose never publishes 9464. A request for /metrics on 8080 falls through to the API, which has no such route.

The endpoint is the first branch in the request pipeline, so authentication, authorization, rate limiting and CORS never apply to it, and it is not part of the OpenAPI document.


4. Traceability

CapabilityRequirementsUse CaseIssue
Project scaffolding & initial infrastructureIR-01 … IR-09—#31
Health & monitoringFR-HC-01 … FR-HC-07UC-30#32

11 - Changelog

Notable changes in each release.

All notable changes to the Heimdall API are recorded in this file.

The format is based on Keep a Changelog , and this project adheres to Semantic Versioning .

Unreleased

1.6.0 - 2026-10-16

Security

  • 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/confirm before 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.

1.5.0 - 2026-10-15

Added

  • 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.

1.4.0 - 2026-10-13

Added

  • 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.

1.3.0 - 2026-10-11

Changed

  • 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).

1.2.0 - 2026-10-09

Changed

  • 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.

1.1.1 - 2026-10-09

Fixed

  • 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.

1.1.0 - 2026-10-09

Added

  • 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.

1.0.0 - 2026-09-28

First release.

Added

  • 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:

python scripts/flake_hunt.py --runs 25

See the Testing Specification Document for the full testing standard.

Coverage

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.

Branching model

feature/<name> ─┐
fix/<name> ─────┴─▶ develop ──▶ release/x.y.z ──▶ main  (tag vx.y.z)
BranchCut fromMerges intoHow
feature/<name>, fix/<name>developdevelopPull request, squash or merge. The branch is deleted on merge.
release/x.y.zdevelopmainPull 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.

EventDeploys to
Push to develop (every merged pull request)development — left stopped if it was stopped
Push of a release/x.y.z branchhomologation — likewise
Pull request release/x.y.z → main with every check greenproduction, 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.

  1. 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.
  2. Open a pull request release/1.4.0 → main.
  3. 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.
  4. 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.