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_HOSTNAMEis pinned explicitly per instance. A Keycloak left to infer its hostname generateslocalhostlinks 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/inetOrgPersonentries underou=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.