Skip to content

About

A centralized identity management API built with ASP.NET Core (.NET 10)

Resources

Contributing

Stars

0 stars

Watchers

0 watching

Forks

Latest commit

 

History

773 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Heimdall API

Docs License: Attribution

📖 Read the documentation website — overview, run and test instructions, architecture and class diagrams, sequence diagrams for the main flows, the API reference, and the full requirements specification.

A centralized identity management API built with ASP.NET Core (.NET 10). It provides person management, application (non-human identity) management, authentication and authorization, password recovery, email verification, and Google Sign-In for multiple client systems through scope-based multi-tenancy — each client system operates within its own isolated scope.

Overview

  • Multi-tenant by scope. Every User belongs to exactly one scope; a ScopeAdmin owns one or more scopes; a SystemAdmin governs the whole system and belongs to no scope.
  • Persons & applications. Manages human identities (persons) and non-human identities (applications, each owned by a Scope Admin who owns the application's scope).
  • Scope-specific permissions. Each scope can define its own permissions, and a permission flagged IncludeAsJwtClaim is folded into the JWT issued to identities acting within that scope.
  • Authentication. Password-based login (JWT), password recovery, email verification, optional Google Sign-In per scope, and optional two-factor authentication (authenticator app and/or email, with single-use recovery codes).
  • Deletion strategies. Both logical (soft) and hard deletion, with well-defined cascade rules.
  • Layered (DDD) architecture. Domain, Application (CQRS: Command/Query/Shared), Infrastructure (EF Core data layer), and Presentation (Web API).

For the full picture — vision, requirements, use cases, the technology stack, and testing standards — see Documentation.

Documentation

The full documentation is published as a website: https://docs.artur-rios.tech/heimdall-docs/docs/

Section What it covers
Overview The domain vocabulary — scopes, persons, roles, applications, permissions — and the rules that bind them
Getting started Prerequisites, configuration, migrations, and running the API
Architecture Layers, CQRS, the auditing decorator, and the request pipeline — with class diagrams
Domain model Entities, relationships, and deletion cascades — with a class diagram
API reference Every endpoint, who may call it, and the use case it implements
API explorer Swagger UI over the generated OpenAPI document — parameters, schemas, and responses
Flows Sequence diagrams for login, two-factor, Google Sign-In, onboarding, and audit logging
Operations Migrations, health checks, logging, rate limiting, and the integrations
Environments and deployment Local on Docker Desktop, and development, homologation and production on the VPS — env files and each PostgreSQL database
Changelog Every release, and what is unreleased
Contributing Building, testing, branching, commits and releasing

The site lives in heimdall-docs, which shows this repository's requirements, OpenAPI document and coverage report as copies; how to change it is in CONTRIBUTING.md.

Requirements documents

These are the specification, and they remain plain Markdown under docs/requirements — the website shows copies of those files, and this is where they are authoritative:

Use case status

Delivery tracker for the use cases in the Use Case Specification Document, plus the platform and compliance work that is not itself a use case. Each one ships on its own branch, issue, and pull request — see the Development Workflow Document.

Every item below is also on the project board, which is the live view; this table is the one you can read without leaving the repository.

Legend: ✅ done and merged  ·  🚧 in progress  ·  ⬜ not started

Milestones

Milestone State Covers
MVP ✅ 18 / 18 Scopes, persons, roles, authentication, password recovery, email verification
Google Sign-In ✅ 6 / 6 Google-authenticated identities and their administration
LGPD & GDPR Compliance 🚧 18 / 20 Data subject rights, retention, records, breach response

The compliance milestone is not complete, and the three items outstanding are marked controller-action: they need a decision or a contract from the controller and cannot be closed by writing code. Marking it complete while they stand would misrepresent the system's position — see Data protection & compliance below.

Scope Management

Use case Status Issue
UC-01: Create Scope ✅ #2
UC-02: View Scope ✅ #3
UC-03: Update Scope ✅ #4
UC-04: Logical Delete Scope ✅ #5
UC-05: Hard Delete Scope ✅ #6
UC-21: Add Scope Owner ✅ #22
UC-22: Remove Scope Owner ✅ #23
UC-23: Promote User to Scope Owner ✅ #24
UC-58: Withdraw a Scope Owner Invitation ✅ —
UC-59: List My Scope Invitations ✅ —
UC-60: Accept a Scope Owner Invitation ✅ —
UC-61: Decline a Scope Owner Invitation ✅ —

Person Management

Use case Status Issue
UC-06: Create Person ✅ #7
UC-07: View Person ✅ #8
UC-08: Update Person ✅ #9
UC-09: Logical Delete Person ✅ #10
UC-10: Hard Delete Person ✅ #11

Authentication & Security

Use case Status Issue
UC-11: Login (Authenticate) ✅ #12
UC-12: Request Password Recovery ✅ #13
UC-13: Reset Password ✅ #14
UC-14: Verify Email ✅ #15
UC-15: Resend Verification Email ✅ #16
UC-51: Send a Verification Email on a Person's Behalf ✅ #177
UC-52: Send a Password Reset on a Person's Behalf ✅ #177
UC-36: Enable Two-Factor Authentication ✅ #68
UC-37: Confirm Two-Factor Authentication Setup ✅ #69
UC-38: Verify Second Factor ✅ #70
UC-39: Disable Two-Factor Authentication ✅ #71
UC-40: Regenerate Recovery Codes ✅ #72
UC-46: Resend Second-Factor Email Code ✅ #133
UC-47: Register a Password User ✅ #169
UC-49: List Pending Registrations ✅ #175
UC-50: Discard a Pending Registration ✅ #175

Application Management

Use case Status Issue
UC-16: Create Application ✅ #17
UC-17: View Application ✅ #18
UC-18: Update Application ✅ #19
UC-19: Logical Delete Application ✅ #20
UC-20: Hard Delete Application ✅ #21

Application Authentication

Use case Status Issue
UC-53: Issue an Application Credential ✅ #178
UC-54: List an Application's Credentials ✅ #178
UC-55: Revoke an Application Credential ✅ #178
UC-56: Obtain an Application Token ✅ #178
UC-57: Check an Application Token ✅ #178

Scope Permission Management

Use case Status Issue
UC-31: Create Scope Permission ✅ —
UC-32: View Scope Permission ✅ —
UC-33: Update Scope Permission ✅ —
UC-34: Logical Delete Scope Permission ✅ —
UC-35: Hard Delete Scope Permission ✅ —

These five were delivered together on one branch rather than one branch, issue, and pull request each, so they carry no issue link. See the Development Workflow Document for the standard the rest of the tracker follows.

Google Sign-In

Use case Status Issue
UC-24: Enable/Disable Google Sign-In ✅ #25
UC-25: Sign Up / Sign In via Google ✅ #26
UC-26: Sign Out via Google ✅ #27
UC-27: View Google User ✅ #28
UC-28: Logical Delete Google User ✅ #29
UC-29: Hard Delete Google User ✅ #30

Data Subject Rights

The rights LGPD Art. 18 and GDPR Chapter III give the people whose data this holds. All self-service: none requires an administrator's cooperation.

Use case Status Issue
UC-41: Export Own Personal Data ✅ #90
UC-42: Request Erasure of Own Identity ✅ #91
UC-43: List Outstanding Erasure Requests ✅ #91
UC-44: Restrict Processing of Own Identity ✅ #93
UC-45: Lift a Restriction on Processing ✅ #93

UC-43 shipped with UC-42 and UC-45 with UC-44 — each pair is one right and its administrative counterpart, and splitting them would have left a request path with nowhere to be resolved.

Data protection & compliance

The LGPD & GDPR Compliance milestone. Where an item added a requirement, the NFR is named; where it produced a document, the document is linked.

Item Status Issue
Data retention schedule (NFR-19) ✅ #95
Purge expired single-use tokens ✅ #96
Erasure that actually erases — anonymisation (NFR-20) ✅ #92
Audit attributions cleared once due (NFR-21) ✅ #97
Log retention and address redaction (NFR-22) ✅ #98
Lawful basis and notice version per identity (NFR-23) ✅ #94
Restriction of processing (NFR-24) ✅ #93
Encryption at rest, backups, restore reconciliation (NFR-25) ✅ #106
Security signal detection (NFR-26) ✅ #105
Data minimisation review ✅ #99
Record of processing activities ✅ #100
Privacy Notice and Encarregado ✅ #101
Data Processing Agreement and sub-processors ✅ #102
International transfer assessment ✅ #103
Data Protection Impact Assessment ✅ #104
Incident Response and breach register ✅ #105
Data subject export (UC-41) ✅ #90

Controller actions — all closed

None of these three could be closed by code. Each needed a decision, a contract, or knowledge of infrastructure the repository cannot see, and each is now answered.

Item Status Issue
Determine whether any scope serves EEA data subjects ✅ #124
Confirm encryption at rest, backup regime, and alert routing ✅ #125
Execute contractual clauses with Mailgun and Google ✅ Not needed #123

#124 was answered yes — nothing restricts access by geography, so GDPR applies under Art. 3(2). That turned out to be narrower than it sounds: a person in the EEA signing up directly is not a Chapter V transfer, because there is no exporter (EDPB Guidelines 05/2021). Only an EEA tenant creates one, and the DPA now incorporates the EU SCCs Module Two, which is the tenant's own document to sign rather than an agreement to negotiate.

#125 was answered in three parts. The database volume and the backups are encrypted; backups are daily, kept 35 days, and held by MEGA — encrypted client-side, so MEGA holds no key that decrypts them. That last fact is what grounds the transfer, since a backup is a full copy of everything: LGPD Art. 33 IX with Art. 7 V, GDPR Art. 45 adequacy, and EDPB Recommendations 01/2020 Use Case 1 as the supplementary measure that survives an adequacy decision being withdrawn. The alert-routing half is closed by scripts/security_signals.py, which collects SECURITY_SIGNAL lines on a schedule and mails what is new — see Security signals. Until it existed the detection built for NFR-26 wrote warnings nobody read, which for Art. 33's purpose is not detection at all: both notification clocks run from awareness.

#123 — executing contractual clauses with Mailgun and Google — is closed as not planned, and the transfer it covered is lawful anyway. Two things resolved it: the Google transfer turned out not to exist, because the ID token is validated offline against cached public certificates and is never sent to Google; and the email transfer rests on LGPD Art. 33 IX with Art. 7 V, a statutory ground needing no counterparty agreement. Nothing was signed. See §6.1 and §7.1 of the Data Protection Document.

Audit Trail

Use case Status Issue
UC-48: List the Audit Trail ✅ #174

Platform

Not use cases, tracked separately.

Item Status Issue
Project scaffolding & initial infrastructure ✅ #31
Health check (liveness + detailed dependency check, UC-30) ✅ #32
Audit logging for write operations (NFR-09) ✅ #78
Real email delivery (Mailgun, via ArturRios.Messaging) ✅ —

Email delivery closed with UC-12: both the verification and password reset emails now go through Mailgun (see Email delivery). The logging senders survive as the fallback for environments without credentials, which is what keeps the functional suite off the network.

Prerequisites

  • .NET 10 SDK

  • PostgreSQL (the API's relational database; functional tests spin up their own via Testcontainers)

  • Python 3 (only to run the migration menu script)

  • Docker (only to deploy with Compose — see Deploy with Docker)

  • The pinned EF Core CLI tool — restore it once after cloning:

    dotnet tool restore

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

Then fill it in. These are required — the API fails to start without them:

Variable Value
HEIMDALL_DATA_CONNECTIONSTRING A PostgreSQL connection string
HEIMDALL_DATA_DATABASETYPE PostgreSql
HEIMDALL_AUTH_SIGNING_KEY The ES256 private key every token is signed with: the one-line base64 of a P-256 PKCS#8 PEM (openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256 -out signing.pem, then base64 -w0 signing.pem). Startup throws if it is unset or unusable, naming the variable and never the value
HEIMDALL_APPLICATION_TOKEN_AUDIENCE The aud of application tokens (UC-56). Startup throws if it is unset, or equal to HEIMDALL_AUTH_TOKEN_AUDIENCE: a distinct audience is what stops a consumer taking an application token for a person's. docker-compose.yml defaults it to heimdall-applications and the docker/*.env.example templates preset that value; the Web API's Environments/.env holds only a placeholder
HEIMDALL_MASTER_USER_NAME / _EMAIL / _PASSWORD Credentials for the first system administrator. Only consulted when the database holds no system admin yet, but the seeder refuses to start without them in that case

The rest are optional and fall back to a default:

Variable Default
HEIMDALL_AUTH_TOKEN_ISSUER / _AUDIENCE Empty
HEIMDALL_AUTH_TOKEN_EXPIRATION_IN_SECONDS 3600 (1 hour)
HEIMDALL_AUTH_SIGNING_KEY_PREVIOUS Empty (set only while rotating the signing key, to the key being retired, in the same format: its public half stays published and its tokens stay valid, but it never signs)
HEIMDALL_APPLICATION_TOKEN_EXPIRATION_IN_SECONDS 600 (10 minutes)
HEIMDALL_AUTH_MAX_CONCURRENT_PASSWORD_HASHES 4 (~2.4 GB of Argon2id working set)
HEIMDALL_INSECURE_TEST_PASSWORD_HASHING Unset: the production Argon2id cost (600 MB, 16 lanes). true selects the cheapest cost Argon2id accepts, for the test suite only (tests/default.runsettings sets it). Startup refuses it in Production and Staging, and no hash stored under it verifies once it is removed, so never set it in a deployment
HEIMDALL_EMAIL_VERIFICATION_TOKEN_EXPIRATION_IN_SECONDS 86400 (24 hours)
HEIMDALL_PASSWORD_RESET_TOKEN_EXPIRATION_IN_SECONDS 3600 (1 hour)
HEIMDALL_ADMIN_EMAIL_COOLDOWN_IN_SECONDS 300 (5 minutes) — the per-person cooldown on account email: administrator-sent links, anonymous password recovery, and a person's own resends, setup codes and email-change notices (which are also capped at five per day-long window); 0 turns the cooldown off
HEIMDALL_SCOPE_INVITATION_LIFETIME_IN_SECONDS 604800 (7 days) — how long a Scope Admin's invitation to co-own a scope can be accepted; 3600 to 2592000, anything else falls back to the default
HEIMDALL_REGISTRATION_MAX_PENDING_PER_SCOPE 1000 — the most live pending self-registrations one scope may hold; a registration beyond it stores and mails nothing, is answered as any other, and is logged with REGISTRATION_CAP_REACHED. Any positive integer; anything else falls back to the default
HEIMDALL_LOG_DIRECTORY logs
HEIMDALL_METRICS_PORT 9464 — the Prometheus scrape port; 0 switches metrics off (see Metrics)
HEIMDALL_CORS_ALLOWED_ORIGINS Empty — every cross-origin request is refused. Set it to the front end's origin (comma separated for several), or a browser client cannot call the API
HEIMDALL_TRUSTED_PROXIES Empty — X-Forwarded-For and X-Forwarded-Proto are ignored, and start-up warns. Behind a reverse proxy, set it to the proxy's addresses or CIDR networks (comma separated) so the rate limiter and the request log see the real caller
HEIMDALL_TRUSTED_CALLER_KEYS Empty — no client system may name its users' addresses to the anonymous-auth rate limiter. Comma-separated secrets of at least 32 characters (shorter ones stop start-up); a request with one in X-Heimdall-Caller-Key is counted under the address in X-Heimdall-Client-Address (see Operations)

The data retention, security monitoring and database TLS settings are listed with their defaults under Environment variables at a glance on the documentation site.

Email delivery

Verification (UC-06) and password reset (UC-12) emails go out through Mailgun, using ArturRios.Messaging. Delivery is enabled only when both credentials are present:

Variable Value
MAILGUN_API_KEY Your Mailgun private API key
MAILGUN_DOMAIN The Mailgun sending domain
MAILGUN_API_VERSION Optional; defaults to v3
MAILGUN_FROM Optional; the sender, as Name <address> or a bare address, e.g. Heimdall <no-reply@example.com>. Unset, mail goes out from a bare postmaster@<MAILGUN_DOMAIN>

Leave them unset — as local runs and the functional test suite do — and the API logs each token instead of emailing it, warning once at start-up. That is a supported mode, not a broken one: it keeps the API runnable without credentials and keeps the test suite off the network.

Both emails carry a link built from these, with the token appended as a token query parameter. If a link is not configured the email carries the bare token instead, which still works — the link is only a convenience wrapper around it.

Variable Value
HEIMDALL_EMAIL_VERIFICATION_URL Front-end page that verifies an email address
HEIMDALL_PASSWORD_RESET_URL Front-end page that sets a new password

Each page finishes the job by posting its token back: the verification page to POST /api/auth/verify-email (UC-14), the reset page to POST /api/auth/password-reset (UC-13), along with the new password. Both endpoints are anonymous, since someone arriving from a link in their mail client holds no token of any other kind.

Spending a token of either kind retires every other live token the person holds of that kind. A second "forgot password" click cannot be replayed after the first has already changed the password, and a verification link left in an inbox stops working once the address is verified.

A verification link that expired, was lost, or never arrived is replaced through POST /api/auth/resend-verification (UC-15). That one 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 — and it retires the outstanding ones before mailing a new one, so only the newest link works. An address that is already verified is refused: a link mailed to it could do nothing when clicked.

A Mailgun failure is logged, never surfaced. POST /api/auth/password-recovery must answer identically whether or not the address belongs to anyone, so an outage cannot be allowed to turn into a 500 that tells an anonymous caller their guess was a real account.

Google Sign-In

POST /api/auth/google (UC-25) signs a Google account up or in against a scope: the caller sends the ID token they obtained from Google plus the scope's PublicId, and gets back the same kind of bearer token a password login yields. The first call for a given Google account in a given scope creates the Google User; every later one authenticates it.

Two things must be true before it works. The scope must have Google Sign-In switched on through PUT /api/scopes/{id}/google-signin (UC-24) — it is off by default — and the API must know which OAuth client the tokens were minted for:

Variable Value
HEIMDALL_GOOGLE_CLIENT_IDS Comma-separated Google OAuth client IDs accepted as an ID token's audience

Leave it unset and Google sign-in refuses every token with a 401, warning once at start-up. Unlike the email fallback, this is not a convenience mode: 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; a scope that never enabled the feature is unaffected either way.

Google sign-in only ever produces a User-equivalent identity (FR-GO-04). It cannot create or authenticate a Scope Admin or System Admin, whatever the Google account is.

POST /api/auth/google/sign-out (UC-26) ends a Google session. It revokes nothing — tokens here are stateless signed JWTs with an expiry, the strategy UC-11 established — so the endpoint confirms the caller still holds a live Google session and 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 once it is deleted, and one naming a deleted or unknown Google User is refused.

The Google Users of a scope are administered through four endpoints, all nested under the scope they belong to (FR-GO-06):

Endpoint Use case Who
GET /api/scopes/{scopeId}/google-users UC-27 System Admin, or an owner of the scope
GET /api/scopes/{scopeId}/google-users/{id} UC-27 The above, or the Google User themselves
DELETE /api/scopes/{scopeId}/google-users/{id} UC-28 System Admin, or an owner of the scope
DELETE /api/scopes/{scopeId}/google-users/{id}/hard UC-29 System Admin only

The by-id read is the one that admits a Google User, and it is why that endpoint carries no role attribute: a Google User's token is User-role, so anything strong enough to keep other Users out would lock out the actor the use case grants. The listing admits none of them.

Both deletions accept a scope's PublicId in the path and refuse a Google User reached through the wrong one. The logical delete is idempotent — repeating it answers 200 with alreadyDeleted: true and writes nothing — and is honoured everywhere it matters: a deleted account cannot sign in (UC-25), 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.

Migrations

The schema is managed with EF Core migrations, applied explicitly — the API process never migrates (the container entrypoint does by default unless HEIMDALL_RUN_MIGRATIONS is set to something other than true), and refuses to start when migrations are pending. Apply them before the first run, and after every update, with the interactive migration menu:

python scripts/migrations.py

It asks which environment file to load (for the connection string), then offers the migration actions. Requires dotnet tool restore (above) to have been run once. The Docker image applies them itself — see What the container does at start-up.

Run

dotnet run --project src/Presentation/ArturRios.Heimdall.WebApi

Serves on http://localhost:5177 (the http launch profile); the https profile adds https://localhost:7235.

To call it, api-client/ holds ready-to-send requests for every operation in docs/openapi/heimdall.json as both .http files and a Bruno collection, chained so no identifier has to be copied by hand — see api-client/README.md.

Swagger UI is at http://localhost:5177/swagger, with JWT wired in — press Authorize, paste a token, and call any endpoint. It is built from the same SwaggerConfiguration that python scripts/openapi.py writes to docs/openapi/heimdall.json, so the API explorer publishes byte-for-byte what a running instance serves.

Deploy with Docker

docker-compose.yml runs the API in a container against a PostgreSQL instance that is already installed on the host — the same instance other services and environments share, each owning a database of its own. Postgres is deliberately not a service in the file.

Four environments, one Compose file, one env file each:

Environment Where Deployed by ASPNETCORE_ENVIRONMENT Public hosts Env file template Database
local The developer's Windows machine, Docker Desktop Hand: docker compose --env-file docker/local.env up -d --build Development http://localhost:8080 docker/local.env.example heimdall_local, PostgreSQL on Windows
development The VPS, on demand Jenkins, on every push to develop Development heimdall-api-dev.example.com, and /api/ under heimdall-dev.example.com docker/development.env.example heimdall_development, PostgreSQL on the VPS
homologation The VPS, on demand Jenkins, on every push of a release/x.y.z branch Staging heimdall-api-hml.example.com, and /api/ under heimdall-hml.example.com docker/homologation.env.example heimdall_homologation, PostgreSQL on the VPS
production The VPS, always on Jenkins, on a green release/x.y.z → main pull request, which it then merges and tags Production heimdall-api.example.com, and /api/ under heimdall.example.com docker/production.env.example heimdall, PostgreSQL on the VPS

example.com stands for the real domain. Staging keeps homologation off Mailgun — verification and reset e-mails are logged, as in Development — without serving Swagger or the developer exception page.

Local is deployed by hand. Copy the template and fill it in — it documents what each variable expects:

cp docker/local.env.example docker/local.env

Then, from the repository root:

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

The real env files are gitignored: they hold the database password, the token signing key and the master user's credentials.

Development, homologation and production share one Ubuntu VPS and are deployed by yggdrasil: Jenkins applies yggdrasil's stacks/heimdall-api.proxy.yml on top of this Compose file — no host port, Traefik in front with a wildcard certificate, the project named heimdall-api-<environment> — and reads the env file from /etc/yggdrasil/<environment>/heimdall-api.env on the VPS, filled in from the matching template. Each environment has its own database, login, signing key and master user. Development and homologation run only while they are used: a deploy leaves a stopped environment stopped, and scripts/ygg.sh env start <environment> on the VPS turns one on (env stop turns it off again).

For the full walkthrough — every command, what each host's Postgres has to allow, and the errors each misconfiguration produces — see Environments and deployment.

What the container does at start-up

The image carries an EF Core migrations bundle built from src/Infrastructure/ArturRios.Heimdall.Data/Migrations. The entrypoint applies pending migrations and only then starts the API, which is what lets a container be pointed at an empty database — the API itself never migrates, and refuses to start while migrations are pending. Set HEIMDALL_RUN_MIGRATIONS=false (any value other than true skips it) to apply them out of band instead (python scripts/migrations.py), which is required if the environment ever runs more than one replica.

The bundle migrates with HEIMDALL_MIGRATIONS_CONNECTIONSTRING when it is set and with the API's own connection string otherwise. Set it to the schema owner's, and the API can run as a database role that owns nothing, which is what makes the audit trail's triggers hold against the API's own credentials (Threat Model TH-18); scripts/sql sets the roles up, and the operations guide's "Database roles" gives the order.

Metrics

The container serves Prometheus metrics at /metrics on port 9464, and only there — on the API's port 8080 the path is not served. Prometheus is meant to scrape the container directly over a Docker network it shares with it (heimdall-api.<environment>:9464 under yggdrasil), so Compose deliberately does not publish 9464: the endpoint is told apart by the port a connection arrives on rather than by the Host header, which Traefik forwards from the client and an attacker therefore controls. Publishing the port would undo that.

HEIMDALL_METRICS_PORT moves the endpoint (blank = 9464) or, set to 0, switches metrics off. A port other than 9464 must also be added to ASPNETCORE_HTTP_PORTS (the image sets 8080;9464). What is collected, and the rules for keeping the port private behind Traefik, are in Operations → Metrics.

What the host's Postgres needs

The container reaches Postgres over the bridge network, not over the loopback interface the host's own processes use, so an installation that only listens on localhost is unreachable from it:

  • listen_addresses must include the address the container connects to (* binds all interfaces — pair it with a firewall that does not expose 5432 publicly).

  • pg_hba.conf needs a line for the Docker bridge range, e.g. host heimdall_development heimdall_development_svc 172.16.0.0/12 scram-sha-256.

  • The database and its two roles must exist — an owner the migrations run as, and a login the API runs as, which owns nothing; each service and each environment on the shared instance gets its own, so that development and homologation cannot reach production's data. On the VPS, from a copy of scripts/sql:

    sudo -u postgres psql -X -v owner=heimdall_owner -v runtime=heimdall_svc -v database=heimdall -f new-deployment.sql
    sudo -u postgres psql -X -v owner=heimdall_development_owner -v runtime=heimdall_development_svc -v database=heimdall_development -f new-deployment.sql
    sudo -u postgres psql -X -v owner=heimdall_homologation_owner -v runtime=heimdall_homologation_svc -v database=heimdall_homologation -f new-deployment.sql

    then each login's password with \password, and one pg_hba.conf line per login, so each reaches its own database and no other. A database that belongs to the API's own login, as this README used to create it, is moved over with separate-owner.sql; the operations guide's "Database roles" has both procedures, in order.

Docker Desktop on Windows is the exception: it forwards the connection through its own VM, so the Windows Postgres sees it arrive from 127.0.0.1 and the stock pg_hba.conf already covers it — neither an extra rule nor a firewall opening is needed there. The VPS's plain engine connects over the bridge for real, and needs both of the above.

Check reachability before the first deploy:

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'

Naming the login

Compose pins Search Path=public in the connection string, and the reason is worth knowing before anyone changes it. EF names the migrations history table unqualified, so Postgres resolves it through the search path — whose default, "$user", public, makes the login's name a schema lookup. The entities live in a schema called heimdall, so a login also named heimdall sends the second run looking for its history in that schema, where the first run wrote none: it concludes nothing was ever applied and dies on relation "role" already exists. The pin makes every run read the table the first one wrote, whatever the login is called.

The same trap applies when running from source — Environments/.env.local and scripts/migrations.py share the connection string, not this file — so either keep Search Path=public in those connection strings too, or do not name the login heimdall.

Changelog

Notable changes in each release are recorded in CHANGELOG.md.

Contributing

Building from source, running the tests, authoring migrations, regenerating the OpenAPI document, the branching model and the release process are described in CONTRIBUTING.md.

The GitHub repository is a read-only mirror for the real, open-source code of heimdall-api. Its home is Forgejo, at https://git.techcraftsmen.net/artur-rios/heimdall-api. If you want to contribute, e-mail me at arturdev@duck.com.

Legal

This project is licensed under the Artur Rios Attribution License. It is free and open source: you may view, use, fork, modify, and extend it, provided you credit the author, Artur Rios, with a link to artur-rios.tech or github.com/artur-rios.

Copyright © 2026 Artur Rios.

About

A centralized identity management API built with ASP.NET Core (.NET 10)

Resources

Contributing

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages