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"]
    sssd_vpn["sssd on mbptillo\nOpenVPN PAM auth"]
    oauth2_proxy["oauth2-proxy\nauth.mdapi.ch"]
    apps_mdapi["MDAPI apps\n*.mdapi.ch"]
    apps_env["Envuassu apps\nNextcloud, Zammad"]

    ldap --> mail_auth & sssd_vpn
    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, sssd/OpenVPN)"]
    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=Groups,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 — there is no federation or trust between the two Keycloaks. Manifests at https://gitlab.mdapi.ch/mdapi/fleet/-/tree/main/envuassu/keycloak (public mirror).

OpenLDAP

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

  • Mail stack — Postfix and Dovecot authenticate via LDAP. Users are objectClass=room entries under ou=People.
  • OpenVPN — sssd on mbptillo authenticates VPN users via PAM → LDAP, with cache_credentials=true for resilience when the cluster is unreachable.

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.