Skip to content

Authentication & Identity Flow

Identity Stack Overview

The homelab runs two independent Keycloak deployments with completely separate scopes:

Deployment Hostname Namespace Scope User base
MDAPI Keycloak idp.mdapi.ch keycloak MDAPI organisation SSO — guards every *.mdapi.ch app via oauth2-proxy tillo, family and friends
Envuassu Keycloak login.envuassu.ch envuassu "En Vuassu" neighbourhood community — guards Nextcloud + Zammad for the families sharing the joint property The villas in the En Vuassu block

They share no realms, users, or trust. Each has its own Postgres; TLS for both comes from the cluster-wide letsencrypt-prod issuer.

flowchart TD
    subgraph ldap_stack["OpenLDAP — 192.168.1.52"]
        ldap["OpenLDAP\ndc=mdapi,dc=ch"]
        lam["LAM admin UI\nlam.mdapi.ch"]
    end

    subgraph kc_mdapi["Keycloak — idp.mdapi.ch (MDAPI)"]
        kc1["keycloak ns"]
        kc1_pg["Postgres 5Gi"]
    end

    subgraph kc_env["Keycloak — login.envuassu.ch (En Vuassu)"]
        kc2["envuassu ns"]
        kc2_pg["Postgres"]
    end

    mail_auth["docker-mailserver\nPostfix + Dovecot\nLDAP auth"]
    oauth2_proxy["oauth2-proxy\nauth.mdapi.ch"]
    apps_mdapi["MDAPI apps\n*.mdapi.ch"]
    apps_env["Envuassu apps\nNextcloud, Zammad"]

    ldap --> mail_auth
    lam --> ldap
    kc1 --> kc1_pg
    kc2 --> kc2_pg
    ldap <-->|"two-way sync\n(users, groups,\neverything LDAP-supported)"| kc1
    kc1 -->|"OIDC + LDAP-derived\nroles/groups claims"| oauth2_proxy --> apps_mdapi
    kc2 -->|"OIDC"| apps_env

MDAPI Keycloak — idp.mdapi.ch

Keycloak is the OIDC/SSO provider for the MDAPI organisation. It runs in the keycloak namespace with a dedicated Postgres backend.

Realms:

Realm Purpose
master Admin console
tilloch Main user realm (tillo.ch domain)

The tilloch realm uses default-roles-tilloch to assign baseline permissions automatically: view-applications, view-consent, manage-account-links. These are required for the account console tabs to appear.

The image is a custom build off registry.access.redhat.com/ubi9/ubi9-micro (Keycloak's own distroless-style distribution), not a wrapped upstream tag — see Supply Chain Security → Covered images.

Two operational invariants apply to every Keycloak instance here:

  • Admin automation runs as apibot — a dedicated service account, not the human admin user — so scripted realm and client changes are auditable and survive admin-password rotations.
  • KC_HOSTNAME is pinned explicitly per instance. A Keycloak left to infer its hostname generates localhost links in action emails (password reset, verify-email), so the pin is part of standing up any new instance.

LDAP federation — two-way sync with OpenLDAP

The tilloch realm is federated against the on-cluster OpenLDAP at 192.168.1.52 (dc=mdapi,dc=ch). Sync is bidirectional for everything LDAP can represent — username, full name, email, phone, postal address, group membership — so user maintenance can happen on either side and converges within the next sync window.

flowchart LR
    ldap["OpenLDAP\ndc=mdapi,dc=ch"]
    kc["Keycloak — tilloch realm\n(idp.mdapi.ch)"]
    apps_ldap["LDAP-bound apps\n(Postfix, Dovecot)"]
    apps_oidc["OIDC-bound apps\n(everything behind oauth2-proxy)"]

    ldap <-->|"two-way sync\n(user attrs + groups)"| kc
    ldap --> apps_ldap
    kc --> apps_oidc

What does not round-trip:

  • Preferred language — no clean LDAP attribute, kept Keycloak-only.
  • MFA / WebAuthn factors, recovery codes, session policies — Keycloak-native concepts; LDAP has no equivalent.

These attributes are written and read only on the Keycloak side and don't get clobbered by sync.

Group → role mapping

Keycloak's role/group claims for the tilloch realm are derived from LDAP groups (ou=group,dc=mdapi,dc=ch via the role-ldap-mapper). This means adding a user to an LDAP group is enough — they pick up the matching realm role on the next login, and any OIDC client that asserts roles / groups claims sees it. Authorisation policy stays in LDAP; Keycloak just publishes it as OIDC claims.

Envuassu Keycloak — login.envuassu.ch

A second Keycloak instance lives in the envuassu namespace alongside the neighbourhood's Nextcloud AIO and Zammad. It is the IdP for those community apps only.

The point worth documenting is the absence of a relationship: the two Keycloaks share no realms, no users, no trust and no federation, and each has its own Postgres. Compromising or losing one has no authorisation effect on the other, and the platform cannot authenticate as the community's users. Both draw TLS from the same cluster-wide letsencrypt-prod issuer, which is the only thing they have in common. Manifests at https://gitlab.mdapi.ch/mdapi/fleet/-/tree/main/envuassu/keycloak.

OpenLDAP

OpenLDAP is the authoritative directory for dc=mdapi,dc=ch. Two primary consumers:

  • Mail stack — Postfix and Dovecot authenticate via LDAP. Users are posixAccount/inetOrgPerson entries under ou=People.
  • OpenVPN — deliberately not an LDAP consumer. Access is client-certificate only; see Remote Access → Authentication for why the directory dependency was dropped from the door you use to reach a broken estate.

LAM provides a web UI at lam.mdapi.ch.

Secret Management

flowchart LR
    akeyless["Akeyless SaaS"]
    cm_vm["CipherTrust Manager\ncm.mdapi.ch\ncustomer fragment\n(on-premise)"]
    eso["External Secrets Operator\ncm-akeyless ClusterSecretStore"]
    k8s_sec["K8s Secret"]
    pod["Pod"]

    akeyless <-->|"customer fragment\nnever leaves LAN"| cm_vm
    eso -->|"fetch /mdapi/* secrets"| cm_vm
    eso --> k8s_sec --> pod

All /mdapi/ paths use with_customer_fragment: true. The customer fragment is stored exclusively on the CipherTrust Manager VM — Akeyless SaaS cannot decrypt these secrets without it.

If CipherTrust Manager is unreachable, ExternalSecret refreshes fail cluster-wide. The designed first response is the OpenBao failover plane: an openbao ClusterSecretStore runs live alongside cm-akeyless (and Windmill's f/secrets/get_secret falls back to it automatically), so consumers can be repointed while the VM is down. Restoring the VM from its Longhorn snapshot is the deep-recovery path — see Secret Management → Failover.

TLS — cert-manager + Let's Encrypt

flowchart LR
    annotation["Ingress annotation\ncert-manager.io/issuer:\nletsencrypt-prod"]
    cm_op["cert-manager"]
    bind9["BIND9\n31.3.128.59:53\n(ns.mdapi.ch external)"]
    le["Let's Encrypt ACME v2"]
    tls_secret["TLS Secret"]

    annotation --> cm_op
    cm_op -->|"nsupdate TSIG\nkey: mdapi"| bind9
    bind9 --> le -->|"certificate"| cm_op --> tls_secret

DNS-01 is used for the public mdapi.ch / tillo.ch domains — it supports wildcards and works for services not reachable from the internet; names under the internal-only home.tillo.ch zone validate via HTTP-01 instead. The TSIG key never leaves the cluster — it lives in a K8s Secret referenced by cert-manager.

cert-manager targets 31.3.128.59:53 (external IP of ns.mdapi.ch). On the router, UDP/53 is a plain DNAT to the in-cluster BIND at 192.168.1.53; public v4 TCP/53 is fronted by the router's haproxy, which injects a PROXYv2 header (send-proxy-v2) into BIND's PROXY listener so BIND still sees real client addresses. A single letsencrypt-prod ClusterIssuer is configured with this nameserver and reconciled by Fleet — manifests at https://gitlab.mdapi.ch/mdapi/fleet/-/tree/main/letsencrypt-clusterissuer (public mirror). It covers the whole estate, GitLab included.