diff --git a/.env.sample b/.env.sample index 96c7705..6080580 100644 --- a/.env.sample +++ b/.env.sample @@ -17,3 +17,16 @@ NOBLE_VELERO_S3_BUCKET= NOBLE_VELERO_S3_URL= NOBLE_VELERO_AWS_ACCESS_KEY_ID= NOBLE_VELERO_AWS_SECRET_ACCESS_KEY= + +# Authentik + OIDC — when **noble_authentik_install=true**, Ansible installs Authentik and reconfigures Argo CD, Grafana, Headlamp (native OIDC) and Prometheus/Alertmanager/Longhorn via oauth2-proxy (OIDC to Authentik + Traefik ForwardAuth). See **ansible/roles/noble_authentik/README.md**. +NOBLE_AUTHENTIK_SECRET_KEY= +NOBLE_AUTHENTIK_POSTGRES_PASSWORD= +NOBLE_AUTHENTIK_BOOTSTRAP_TOKEN= +NOBLE_AUTHENTIK_BOOTSTRAP_EMAIL= +NOBLE_AUTHENTIK_BOOTSTRAP_PASSWORD= +NOBLE_AUTHENTIK_CLIENT_SECRET_ARGOCD= +NOBLE_AUTHENTIK_CLIENT_SECRET_GRAFANA= +NOBLE_AUTHENTIK_CLIENT_SECRET_HEADLAMP= +NOBLE_AUTHENTIK_CLIENT_SECRET_OAUTH2_PROXY= +# Random secret for oauth2-proxy session cookie (see oauth2-proxy Helm chart docs; e.g. openssl rand -base64 32 | head -c 32 | base64) +NOBLE_AUTHENTIK_OAUTH2_PROXY_COOKIE_SECRET= diff --git a/ansible/README.md b/ansible/README.md index 3b093c3..c0a19b6 100644 --- a/ansible/README.md +++ b/ansible/README.md @@ -76,11 +76,12 @@ ansible-playbook playbooks/noble.yml --tags cilium,metallb ansible-playbook playbooks/noble.yml --tags trivy ansible-playbook playbooks/noble.yml --skip-tags newt ansible-playbook playbooks/noble.yml --tags velero -e noble_velero_install=true -e noble_velero_s3_bucket=... -e noble_velero_s3_url=... +ansible-playbook playbooks/noble.yml --tags authentik -e noble_authentik_install=true ``` ### Variables — `group_vars/all.yml` and role defaults -- **`group_vars/all.yml`:** **`noble_newt_install`**, **`noble_velero_install`**, **`noble_cert_manager_require_cloudflare_secret`**, **`noble_argocd_apply_root_application`**, **`noble_argocd_apply_bootstrap_root_application`**, **`noble_k8s_api_server_override`**, **`noble_k8s_api_server_auto_fallback`**, **`noble_k8s_api_server_fallback`**, **`noble_skip_k8s_health_check`** +- **`group_vars/all.yml`:** **`noble_newt_install`**, **`noble_velero_install`**, **`noble_authentik_install`**, **`noble_cert_manager_require_cloudflare_secret`**, **`noble_argocd_apply_root_application`**, **`noble_argocd_apply_bootstrap_root_application`**, **`noble_k8s_api_server_override`**, **`noble_k8s_api_server_auto_fallback`**, **`noble_k8s_api_server_fallback`**, **`noble_skip_k8s_health_check`** - **`roles/noble_platform/defaults/main.yml`:** **`noble_apply_sops_secrets`**, **`noble_sops_age_key_file`** (SOPS secrets under **`clusters/noble/secrets/`**) ## Roles @@ -89,7 +90,7 @@ ansible-playbook playbooks/noble.yml --tags velero -e noble_velero_install=true |------|----------| | `talos_phase_a` | Talos genconfig, apply-config, bootstrap, kubeconfig | | `helm_repos` | `helm repo add` / `update` | -| `noble_*` | Cilium, CSI Volume Snapshot CRDs + controller, metrics-server, Longhorn, MetalLB (20m Helm wait), kube-vip, Traefik, cert-manager, Newt, Argo CD, Kyverno, platform stack, **Trivy Operator**, Velero (optional) | +| `noble_*` | Cilium, CSI Volume Snapshot CRDs + controller, metrics-server, Longhorn, MetalLB (20m Helm wait), kube-vip, Traefik, cert-manager, Newt, Argo CD, Kyverno, platform stack, **Authentik** (optional OIDC), **Trivy Operator**, Velero (optional) | | `noble_landing_urls` | Writes **`ansible/output/noble-lab-ui-urls.md`** — URLs, service names, and (optional) Argo/Grafana passwords from Secrets | | `noble_post_deploy` | Post-install reminders | | `talos_bootstrap` | Genconfig-only (used by older playbook) | diff --git a/ansible/group_vars/all.yml b/ansible/group_vars/all.yml index bf214ad..afbacc6 100644 --- a/ansible/group_vars/all.yml +++ b/ansible/group_vars/all.yml @@ -26,3 +26,6 @@ noble_velero_install: false noble_argocd_apply_root_application: true # Bootstrap kustomize in Argo (**noble-bootstrap-root** → **clusters/noble/bootstrap**). Applied with manual sync; enable automation after **noble.yml** (see **clusters/noble/bootstrap/argocd/README.md** §5). noble_argocd_apply_bootstrap_root_application: true + +# Authentik (OIDC IdP) + oauth2-proxy ForwardAuth — set **true** after **.env** has NOBLE_AUTHENTIK_* (see ansible/roles/noble_authentik/README.md). +noble_authentik_install: false diff --git a/ansible/playbooks/noble.yml b/ansible/playbooks/noble.yml index 34410e0..6e67e73 100644 --- a/ansible/playbooks/noble.yml +++ b/ansible/playbooks/noble.yml @@ -4,7 +4,7 @@ # Run from repo **ansible/** directory: ansible-playbook playbooks/noble.yml # # Tags: repos, cilium, csi_snapshot, metrics, longhorn, metallb, kube_vip, traefik, cert_manager, newt, -# argocd, kyverno, kyverno_policies, platform, trivy, velero, all (default) +# argocd, kyverno, kyverno_policies, platform, authentik, trivy, velero, all (default) - name: Noble cluster — platform stack (Ansible-managed) hosts: localhost connection: local @@ -228,6 +228,8 @@ tags: [argocd, gitops] - role: noble_platform tags: [platform, observability, apps] + - role: noble_authentik + tags: [authentik, sso, oauth, oidc] - role: noble_trivy tags: [trivy, security, scanning] - role: noble_velero diff --git a/ansible/roles/helm_repos/defaults/main.yml b/ansible/roles/helm_repos/defaults/main.yml index e97da7f..1e57ce4 100644 --- a/ansible/roles/helm_repos/defaults/main.yml +++ b/ansible/roles/helm_repos/defaults/main.yml @@ -15,3 +15,5 @@ noble_helm_repos: - { name: kyverno, url: "https://kyverno.github.io/kyverno/" } - { name: vmware-tanzu, url: "https://vmware-tanzu.github.io/helm-charts" } - { name: aqua, url: "https://aquasecurity.github.io/helm-charts/" } + - { name: goauthentik, url: "https://charts.goauthentik.io" } + - { name: oauth2-proxy, url: "https://oauth2-proxy.github.io/manifests" } diff --git a/ansible/roles/noble_authentik/README.md b/ansible/roles/noble_authentik/README.md new file mode 100644 index 0000000..064a9f9 --- /dev/null +++ b/ansible/roles/noble_authentik/README.md @@ -0,0 +1,29 @@ +# noble_authentik — Authentik + OIDC for the noble stack + +Installs **Authentik** (Helm `goauthentik/authentik`) as the cluster IdP, **oauth2-proxy** as an **OIDC** client to Authentik for Traefik **ForwardAuth** (Prometheus, Alertmanager, Longhorn UI), and re-applies Helm values so **Argo CD**, **Grafana**, and **Headlamp** use **native OIDC** to Authentik (not HTTP BasicAuth). + +## Enable + +1. Copy repository **`.env.sample`** to **`.env`** and set every **`NOBLE_AUTHENTIK_*`** variable (see comments there). +2. Set **`noble_authentik_install: true`** in **`ansible/group_vars/all.yml`** (or pass **`-e noble_authentik_install=true`**). +3. Run **`ansible-playbook playbooks/noble.yml --tags authentik`** (or a full **`noble.yml`**) from **`ansible/`** with a working **`KUBECONFIG`**. + +`noble_authentik` runs **after** **`noble_platform`** so Grafana / Headlamp / Prometheus exist before SSO Helm upgrades. + +## Variables + +See **`defaults/main.yml`**. Hostnames default to **`auth.apps.noble.lab.pcenicni.dev`** and **`oauth2.apps.noble.lab.pcenicni.dev`**. + +## IdP configuration + +When **`noble_authentik_configure_idp`** is true, Ansible runs **`files/configure_authentik.py`** (Python 3, stdlib only) with the bootstrap token to create/update OAuth2 providers and applications for **argocd**, **grafana**, **headlamp**, and **oauth2-proxy**, create **`noble-admins`** / **`noble-editors`**, and add the bootstrap user (by email) to those groups. + +## RBAC notes + +- **Argo CD:** `noble-admins` group → `role:admin` (see **`clusters/noble/bootstrap/argocd/values-authentik-oidc.yaml`**). +- **Grafana:** `noble-admins` → Admin, `noble-editors` → Editor (see **`values-authentik-oidc.yaml`**). + +## Troubleshooting + +- Re-run **`configure_authentik.py`** only by executing **`noble.yml`** with **`--tags authentik`** after fixing `.env`. +- If Authentik API calls fail, check flows exist (slug **`default-provider-authorization-implicit-consent`**) and TLS reaches **`AUTHENTIK_API_BASE`**. diff --git a/ansible/roles/noble_authentik/defaults/main.yml b/ansible/roles/noble_authentik/defaults/main.yml new file mode 100644 index 0000000..7ee1e2d --- /dev/null +++ b/ansible/roles/noble_authentik/defaults/main.yml @@ -0,0 +1,42 @@ +--- +# Set **noble_authentik_install: true** after filling **.env** (see role README and repository **.env.sample**). +noble_authentik_install: false +# When true, run **configure_authentik.py** against the Authentik API (requires bootstrap token + client secrets). +noble_authentik_configure_idp: true + +noble_authentik_chart_version: "2026.2.3" +noble_authentik_oauth2_proxy_chart_version: "10.4.3" + +noble_authentik_host: auth.apps.noble.lab.pcenicni.dev +noble_authentik_public_url: "https://{{ noble_authentik_host }}" +noble_authentik_api_base: "{{ noble_authentik_public_url }}/api/v3" + +noble_authentik_oauth2_proxy_host: oauth2.apps.noble.lab.pcenicni.dev + +# OIDC client ids (must match Authentik providers created by configure script) +noble_authentik_client_id_argocd: argocd +noble_authentik_client_id_grafana: grafana +noble_authentik_client_id_headlamp: headlamp +noble_authentik_client_id_oauth2_proxy: oauth2-proxy + +# Secrets / bootstrap — prefer **lookup('env', ...)** set via repository **.env** (see from_env.yml). +noble_authentik_secret_key: "" +noble_authentik_postgresql_password: "" +noble_authentik_bootstrap_token: "" +noble_authentik_bootstrap_email: "" +noble_authentik_bootstrap_password: "" + +noble_authentik_client_secret_argocd: "" +noble_authentik_client_secret_grafana: "" +noble_authentik_client_secret_headlamp: "" +noble_authentik_client_secret_oauth2_proxy: "" +noble_authentik_oauth2_proxy_cookie_secret: "" + +noble_authentik_helm_wait_timeout: 25m + +# Re-apply the same chart versions as the rest of noble.yml when flipping SSO on. +noble_authentik_argocd_chart_version: "9.4.17" +noble_authentik_kube_prometheus_chart_version: "82.15.1" +noble_authentik_headlamp_chart_version: "0.40.1" +noble_authentik_longhorn_chart_version: "1.11.2" +noble_authentik_kube_prometheus_helm_wait_timeout: 60m diff --git a/ansible/roles/noble_authentik/files/__pycache__/configure_authentik.cpython-314.pyc b/ansible/roles/noble_authentik/files/__pycache__/configure_authentik.cpython-314.pyc new file mode 100644 index 0000000..f309206 Binary files /dev/null and b/ansible/roles/noble_authentik/files/__pycache__/configure_authentik.cpython-314.pyc differ diff --git a/ansible/roles/noble_authentik/files/configure_authentik.py b/ansible/roles/noble_authentik/files/configure_authentik.py new file mode 100644 index 0000000..ccf7469 --- /dev/null +++ b/ansible/roles/noble_authentik/files/configure_authentik.py @@ -0,0 +1,259 @@ +#!/usr/bin/env python3 +""" +Create / update Authentik OAuth2/OpenID providers + applications + groups (stdlib only). +Environment: + AUTHENTIK_API_BASE e.g. https://auth.apps.example.com/api/v3 + AUTHENTIK_TOKEN bootstrap token (Bearer) + BOOTSTRAP_EMAIL initial admin email (added to noble-admins) + CLIENT_JSON path to JSON file: { "argocd": {"client_id","client_secret","redirect_uri"}, ... } +""" +from __future__ import annotations + +import json +import os +import sys +import urllib.error +import urllib.parse +import urllib.request + + +def req(method: str, url: str, body: dict | None = None) -> tuple[int, dict | list]: + data = None + headers = {"Authorization": f"Bearer {os.environ['AUTHENTIK_TOKEN']}", "Accept": "application/json"} + if body is not None: + data = json.dumps(body).encode("utf-8") + headers["Content-Type"] = "application/json" + r = urllib.request.Request(url, data=data, headers=headers, method=method) + try: + with urllib.request.urlopen(r, timeout=120) as resp: + raw = resp.read().decode("utf-8") + code = resp.getcode() + if not raw: + return code, {} + return code, json.loads(raw) + except urllib.error.HTTPError as e: + err = e.read().decode("utf-8", errors="replace") + raise RuntimeError(f"{method} {url} -> HTTP {e.code}: {err}") from e + + +def collect(base: str, path: str) -> list: + out: list = [] + url = base + path + origin = base.split("/api/v3", 1)[0] if "/api/v3" in base else base + while url: + code, payload = req("GET", url) + if code != 200: + raise RuntimeError(f"GET {url} unexpected {code}") + if isinstance(payload, dict) and "results" in payload: + out.extend(payload["results"]) + nxt = payload.get("next") or "" + if not nxt: + url = "" + elif nxt.startswith("http"): + url = nxt + else: + url = origin + nxt + else: + break + return out + + +def find_flow_pk(base: str, slug: str) -> str: + code, payload = req("GET", f"{base}/flows/instances/?slug={urllib.parse.quote(slug)}") + if code != 200 or not isinstance(payload, dict): + raise RuntimeError(f"flow lookup failed for {slug}") + results = payload.get("results") or [] + if not results: + raise RuntimeError(f"flow slug not found: {slug}") + return str(results[0]["pk"]) + + +def find_flow_pk_first(base: str, slugs: list[str]) -> str: + last_err: Exception | None = None + for s in slugs: + try: + return find_flow_pk(base, s) + except RuntimeError as e: + last_err = e + raise RuntimeError(f"no flow matched {slugs}: {last_err}") + + +def signing_key_pk(base: str) -> int: + code, payload = req("GET", f"{base}/crypto/certificatekeypairs/?ordering=pk&page_size=1") + if code != 200 or not isinstance(payload, dict) or not payload.get("results"): + raise RuntimeError("no signing certificate keypairs returned") + return int(payload["results"][0]["pk"]) + + +def scope_mapping_pks(base: str) -> list[int]: + mappings = collect(base, "/propertymappings/oauthscope/?page_size=500") + want_scopes = {"openid", "email", "profile", "offline_access", "groups"} + pks: list[int] = [] + for m in mappings: + sn = (m.get("scope_name") or "").strip() + if sn in want_scopes: + pks.append(int(m["pk"])) + # de-dupe preserve order + seen: set[int] = set() + uniq: list[int] = [] + for p in pks: + if p not in seen: + seen.add(p) + uniq.append(p) + if len(uniq) >= 3: + return uniq + # Fallback: include managed OpenID mappings by name heuristics + for m in mappings: + name = (m.get("name") or "").lower() + if "openid" in name and any(x in name for x in ("openid", "email", "profile", "groups", "offline")): + pk = int(m["pk"]) + if pk not in seen: + seen.add(pk) + uniq.append(pk) + if len(uniq) < 3: + raise RuntimeError(f"too few oauth scope mappings resolved ({uniq}); inspect /propertymappings/oauthscope/") + return uniq + + +def upsert_oauth_provider( + base: str, + slug: str, + name: str, + client_id: str, + client_secret: str, + redirect_uri: str, + auth_flow: str, + invalidation_flow: str, + signing_key: int, + property_mappings: list[int], +) -> int: + code, payload = req("GET", f"{base}/providers/oauth2/?slug={urllib.parse.quote(slug)}") + if code != 200 or not isinstance(payload, dict): + raise RuntimeError("provider list failed") + results = payload.get("results") or [] + body = { + "name": name, + "slug": slug, + "authorization_flow": auth_flow, + "invalidation_flow": invalidation_flow, + "property_mappings": property_mappings, + "client_type": "confidential", + "client_id": client_id, + "client_secret": client_secret, + "redirect_uris": redirect_uri.strip() + "\n", + "signing_key": signing_key, + } + if results: + pk = int(results[0]["pk"]) + code2, _ = req("PATCH", f"{base}/providers/oauth2/{pk}/", body) + if code2 not in (200, 204): + raise RuntimeError(f"PATCH provider {slug} -> {code2}") + return pk + code3, created = req("POST", f"{base}/providers/oauth2/", body) + if code3 not in (200, 201) or not isinstance(created, dict): + raise RuntimeError(f"POST provider {slug} -> {code3} {created}") + return int(created["pk"]) + + +def upsert_application(base: str, slug: str, name: str, provider_pk: int) -> None: + code, payload = req("GET", f"{base}/core/applications/?slug={urllib.parse.quote(slug)}") + if code != 200 or not isinstance(payload, dict): + raise RuntimeError("application list failed") + results = payload.get("results") or [] + body = {"name": name, "slug": slug, "provider": provider_pk} + if results: + pk = int(results[0]["pk"]) + code2, _ = req("PATCH", f"{base}/core/applications/{pk}/", body) + if code2 not in (200, 204): + raise RuntimeError(f"PATCH application {slug} -> {code2}") + return + code3, _ = req("POST", f"{base}/core/applications/", body) + if code3 not in (200, 201): + raise RuntimeError(f"POST application {slug} -> {code3}") + + +def ensure_group(base: str, name: str) -> int: + code, payload = req("GET", f"{base}/core/groups/?name={urllib.parse.quote(name)}") + if code != 200 or not isinstance(payload, dict): + raise RuntimeError("group list failed") + results = payload.get("results") or [] + if results: + return int(results[0]["pk"]) + code2, created = req("POST", f"{base}/core/groups/", {"name": name}) + if code2 not in (200, 201) or not isinstance(created, dict): + raise RuntimeError(f"POST group {name} -> {code2}") + return int(created["pk"]) + + +def add_user_to_groups(base: str, email: str, group_pks: list[int]) -> None: + code, payload = req("GET", f"{base}/core/users/?email={urllib.parse.quote(email)}") + if code != 200 or not isinstance(payload, dict): + raise RuntimeError("user list failed") + results = payload.get("results") or [] + if not results: + print(f"WARN: no user with email {email}; skip group membership", file=sys.stderr) + return + user = results[0] + upk = int(user["pk"]) + existing = [int(g["pk"]) for g in user.get("groups", []) if isinstance(g, dict) and "pk" in g] + merged = sorted(set(existing + group_pks)) + if merged == sorted(set(existing)): + return + code2, _ = req("PATCH", f"{base}/core/users/{upk}/", {"groups": merged}) + if code2 not in (200, 204): + raise RuntimeError(f"PATCH user groups -> {code2}") + + +def main() -> int: + base = os.environ.get("AUTHENTIK_API_BASE", "").rstrip("/") + tok = os.environ.get("AUTHENTIK_TOKEN", "") + cfg_path = os.environ.get("CLIENT_JSON", "") + email = os.environ.get("BOOTSTRAP_EMAIL", "") + if not base or not tok or not cfg_path: + print("AUTHENTIK_API_BASE, AUTHENTIK_TOKEN, CLIENT_JSON required", file=sys.stderr) + return 2 + with open(cfg_path, encoding="utf-8") as f: + clients = json.load(f) + if not isinstance(clients, dict): + print("CLIENT_JSON must be an object keyed by provider slug", file=sys.stderr) + return 2 + + auth_flow = find_flow_pk(base, "default-provider-authorization-implicit-consent") + invalidation_flow = find_flow_pk_first( + base, + ["default-invalidation-flow", "default-provider-invalidation-flow"], + ) + + signing_key = signing_key_pk(base) + pmap = scope_mapping_pks(base) + + admins_pk = ensure_group(base, "noble-admins") + editors_pk = ensure_group(base, "noble-editors") + + for slug, meta in clients.items(): + name = meta.get("name") or slug + cid = meta["client_id"] + csec = meta["client_secret"] + redir = meta["redirect_uri"] + ppk = upsert_oauth_provider( + base, + slug, + name, + cid, + csec, + redir, + auth_flow, + invalidation_flow, + signing_key, + pmap, + ) + upsert_application(base, slug, name, ppk) + + if email: + add_user_to_groups(base, email, [admins_pk, editors_pk]) + print("authentik: providers + applications configured", flush=True) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/ansible/roles/noble_authentik/tasks/from_env.yml b/ansible/roles/noble_authentik/tasks/from_env.yml new file mode 100644 index 0000000..d20398e --- /dev/null +++ b/ansible/roles/noble_authentik/tasks/from_env.yml @@ -0,0 +1,217 @@ +--- +# **.env** is shell `KEY=value` syntax (not YAML). Source it like **noble_velero** does. +- name: Stat repository .env for Authentik + ansible.builtin.stat: + path: "{{ noble_repo_root }}/.env" + register: noble_authentik_dotenv_stat + changed_when: false + +- name: Load NOBLE_AUTHENTIK_SECRET_KEY from .env when unset + ansible.builtin.shell: | + set -a + . "{{ noble_repo_root }}/.env" + set +a + printf '%s' "${NOBLE_AUTHENTIK_SECRET_KEY:-}" + register: noble_authentik_secret_key_from_env + when: + - noble_authentik_dotenv_stat.stat.exists | default(false) + - noble_authentik_secret_key | default('') | length == 0 + changed_when: false + no_log: true + +- name: Apply NOBLE_AUTHENTIK_SECRET_KEY from .env + ansible.builtin.set_fact: + noble_authentik_secret_key: "{{ noble_authentik_secret_key_from_env.stdout | trim }}" + when: + - noble_authentik_secret_key_from_env is defined + - (noble_authentik_secret_key_from_env.stdout | default('') | trim | length) > 0 + no_log: true + +- name: Load NOBLE_AUTHENTIK_POSTGRES_PASSWORD from .env when unset + ansible.builtin.shell: | + set -a + . "{{ noble_repo_root }}/.env" + set +a + printf '%s' "${NOBLE_AUTHENTIK_POSTGRES_PASSWORD:-}" + register: noble_authentik_pg_from_env + when: + - noble_authentik_dotenv_stat.stat.exists | default(false) + - noble_authentik_postgresql_password | default('') | length == 0 + changed_when: false + no_log: true + +- name: Apply NOBLE_AUTHENTIK_POSTGRES_PASSWORD from .env + ansible.builtin.set_fact: + noble_authentik_postgresql_password: "{{ noble_authentik_pg_from_env.stdout | trim }}" + when: + - noble_authentik_pg_from_env is defined + - (noble_authentik_pg_from_env.stdout | default('') | trim | length) > 0 + no_log: true + +- name: Load NOBLE_AUTHENTIK_BOOTSTRAP_TOKEN from .env when unset + ansible.builtin.shell: | + set -a + . "{{ noble_repo_root }}/.env" + set +a + printf '%s' "${NOBLE_AUTHENTIK_BOOTSTRAP_TOKEN:-}" + register: noble_authentik_bt_from_env + when: + - noble_authentik_dotenv_stat.stat.exists | default(false) + - noble_authentik_bootstrap_token | default('') | length == 0 + changed_when: false + no_log: true + +- name: Apply NOBLE_AUTHENTIK_BOOTSTRAP_TOKEN from .env + ansible.builtin.set_fact: + noble_authentik_bootstrap_token: "{{ noble_authentik_bt_from_env.stdout | trim }}" + when: + - noble_authentik_bt_from_env is defined + - (noble_authentik_bt_from_env.stdout | default('') | trim | length) > 0 + no_log: true + +- name: Load NOBLE_AUTHENTIK_BOOTSTRAP_EMAIL from .env when unset + ansible.builtin.shell: | + set -a + . "{{ noble_repo_root }}/.env" + set +a + printf '%s' "${NOBLE_AUTHENTIK_BOOTSTRAP_EMAIL:-}" + register: noble_authentik_be_from_env + when: + - noble_authentik_dotenv_stat.stat.exists | default(false) + - noble_authentik_bootstrap_email | default('') | length == 0 + changed_when: false + no_log: true + +- name: Apply NOBLE_AUTHENTIK_BOOTSTRAP_EMAIL from .env + ansible.builtin.set_fact: + noble_authentik_bootstrap_email: "{{ noble_authentik_be_from_env.stdout | trim }}" + when: + - noble_authentik_be_from_env is defined + - (noble_authentik_be_from_env.stdout | default('') | trim | length) > 0 + no_log: true + +- name: Load NOBLE_AUTHENTIK_BOOTSTRAP_PASSWORD from .env when unset + ansible.builtin.shell: | + set -a + . "{{ noble_repo_root }}/.env" + set +a + printf '%s' "${NOBLE_AUTHENTIK_BOOTSTRAP_PASSWORD:-}" + register: noble_authentik_bp_from_env + when: + - noble_authentik_dotenv_stat.stat.exists | default(false) + - noble_authentik_bootstrap_password | default('') | length == 0 + changed_when: false + no_log: true + +- name: Apply NOBLE_AUTHENTIK_BOOTSTRAP_PASSWORD from .env + ansible.builtin.set_fact: + noble_authentik_bootstrap_password: "{{ noble_authentik_bp_from_env.stdout | trim }}" + when: + - noble_authentik_bp_from_env is defined + - (noble_authentik_bp_from_env.stdout | default('') | trim | length) > 0 + no_log: true + +- name: Load NOBLE_AUTHENTIK_CLIENT_SECRET_ARGOCD from .env when unset + ansible.builtin.shell: | + set -a + . "{{ noble_repo_root }}/.env" + set +a + printf '%s' "${NOBLE_AUTHENTIK_CLIENT_SECRET_ARGOCD:-}" + register: noble_authentik_cs_argo_from_env + when: + - noble_authentik_dotenv_stat.stat.exists | default(false) + - noble_authentik_client_secret_argocd | default('') | length == 0 + changed_when: false + no_log: true + +- name: Apply NOBLE_AUTHENTIK_CLIENT_SECRET_ARGOCD from .env + ansible.builtin.set_fact: + noble_authentik_client_secret_argocd: "{{ noble_authentik_cs_argo_from_env.stdout | trim }}" + when: + - noble_authentik_cs_argo_from_env is defined + - (noble_authentik_cs_argo_from_env.stdout | default('') | trim | length) > 0 + no_log: true + +- name: Load NOBLE_AUTHENTIK_CLIENT_SECRET_GRAFANA from .env when unset + ansible.builtin.shell: | + set -a + . "{{ noble_repo_root }}/.env" + set +a + printf '%s' "${NOBLE_AUTHENTIK_CLIENT_SECRET_GRAFANA:-}" + register: noble_authentik_cs_graf_from_env + when: + - noble_authentik_dotenv_stat.stat.exists | default(false) + - noble_authentik_client_secret_grafana | default('') | length == 0 + changed_when: false + no_log: true + +- name: Apply NOBLE_AUTHENTIK_CLIENT_SECRET_GRAFANA from .env + ansible.builtin.set_fact: + noble_authentik_client_secret_grafana: "{{ noble_authentik_cs_graf_from_env.stdout | trim }}" + when: + - noble_authentik_cs_graf_from_env is defined + - (noble_authentik_cs_graf_from_env.stdout | default('') | trim | length) > 0 + no_log: true + +- name: Load NOBLE_AUTHENTIK_CLIENT_SECRET_HEADLAMP from .env when unset + ansible.builtin.shell: | + set -a + . "{{ noble_repo_root }}/.env" + set +a + printf '%s' "${NOBLE_AUTHENTIK_CLIENT_SECRET_HEADLAMP:-}" + register: noble_authentik_cs_hl_from_env + when: + - noble_authentik_dotenv_stat.stat.exists | default(false) + - noble_authentik_client_secret_headlamp | default('') | length == 0 + changed_when: false + no_log: true + +- name: Apply NOBLE_AUTHENTIK_CLIENT_SECRET_HEADLAMP from .env + ansible.builtin.set_fact: + noble_authentik_client_secret_headlamp: "{{ noble_authentik_cs_hl_from_env.stdout | trim }}" + when: + - noble_authentik_cs_hl_from_env is defined + - (noble_authentik_cs_hl_from_env.stdout | default('') | trim | length) > 0 + no_log: true + +- name: Load NOBLE_AUTHENTIK_CLIENT_SECRET_OAUTH2_PROXY from .env when unset + ansible.builtin.shell: | + set -a + . "{{ noble_repo_root }}/.env" + set +a + printf '%s' "${NOBLE_AUTHENTIK_CLIENT_SECRET_OAUTH2_PROXY:-}" + register: noble_authentik_cs_o2_from_env + when: + - noble_authentik_dotenv_stat.stat.exists | default(false) + - noble_authentik_client_secret_oauth2_proxy | default('') | length == 0 + changed_when: false + no_log: true + +- name: Apply NOBLE_AUTHENTIK_CLIENT_SECRET_OAUTH2_PROXY from .env + ansible.builtin.set_fact: + noble_authentik_client_secret_oauth2_proxy: "{{ noble_authentik_cs_o2_from_env.stdout | trim }}" + when: + - noble_authentik_cs_o2_from_env is defined + - (noble_authentik_cs_o2_from_env.stdout | default('') | trim | length) > 0 + no_log: true + +- name: Load NOBLE_AUTHENTIK_OAUTH2_PROXY_COOKIE_SECRET from .env when unset + ansible.builtin.shell: | + set -a + . "{{ noble_repo_root }}/.env" + set +a + printf '%s' "${NOBLE_AUTHENTIK_OAUTH2_PROXY_COOKIE_SECRET:-}" + register: noble_authentik_cs_cookie_from_env + when: + - noble_authentik_dotenv_stat.stat.exists | default(false) + - noble_authentik_oauth2_proxy_cookie_secret | default('') | length == 0 + changed_when: false + no_log: true + +- name: Apply NOBLE_AUTHENTIK_OAUTH2_PROXY_COOKIE_SECRET from .env + ansible.builtin.set_fact: + noble_authentik_oauth2_proxy_cookie_secret: "{{ noble_authentik_cs_cookie_from_env.stdout | trim }}" + when: + - noble_authentik_cs_cookie_from_env is defined + - (noble_authentik_cs_cookie_from_env.stdout | default('') | trim | length) > 0 + no_log: true diff --git a/ansible/roles/noble_authentik/tasks/main.yml b/ansible/roles/noble_authentik/tasks/main.yml new file mode 100644 index 0000000..94a9418 --- /dev/null +++ b/ansible/roles/noble_authentik/tasks/main.yml @@ -0,0 +1,302 @@ +--- +- name: Authentik disabled (set noble_authentik_install=true and .env — see role README) + ansible.builtin.debug: + msg: "Skipping noble_authentik (noble_authentik_install is false)." + when: not (noble_authentik_install | default(false) | bool) + +- name: Authentik + OIDC stack + when: noble_authentik_install | default(false) | bool + block: + - name: Include Authentik secrets from .env + ansible.builtin.include_tasks: from_env.yml + + - name: Require Authentik secrets and bootstrap settings + ansible.builtin.assert: + that: + - noble_authentik_secret_key | default('') | length > 0 + - noble_authentik_postgresql_password | default('') | length > 0 + - noble_authentik_bootstrap_token | default('') | length > 0 + - noble_authentik_bootstrap_email | default('') | length > 0 + - noble_authentik_bootstrap_password | default('') | length > 0 + - noble_authentik_client_secret_argocd | default('') | length > 0 + - noble_authentik_client_secret_grafana | default('') | length > 0 + - noble_authentik_client_secret_headlamp | default('') | length > 0 + - noble_authentik_client_secret_oauth2_proxy | default('') | length > 0 + - noble_authentik_oauth2_proxy_cookie_secret | default('') | length > 0 + fail_msg: >- + Authentik requires secrets in .env (see ansible/roles/noble_authentik/README.md) or matching -e extra-vars. + + - name: Ensure Ansible temp dir for rendered Helm values + ansible.builtin.file: + path: "{{ noble_repo_root }}/ansible/.ansible-tmp" + state: directory + mode: "0700" + + - name: Render Authentik Helm extra values (secrets) + ansible.builtin.template: + src: authentik-extra-values.yaml.j2 + dest: "{{ noble_repo_root }}/ansible/.ansible-tmp/authentik-extra-values.yaml" + mode: "0600" + no_log: true + + - name: Apply Authentik and oauth2-proxy namespaces + ansible.builtin.command: + argv: + - kubectl + - apply + - -f + - "{{ noble_repo_root }}/clusters/noble/bootstrap/authentik/namespace.yaml" + - -f + - "{{ noble_repo_root }}/clusters/noble/bootstrap/oauth2-proxy/namespace.yaml" + environment: + KUBECONFIG: "{{ noble_kubeconfig }}" + changed_when: true + + - name: Install Authentik (Helm) + ansible.builtin.command: + argv: + - helm + - upgrade + - --install + - authentik + - goauthentik/authentik + - --namespace + - authentik + - --version + - "{{ noble_authentik_chart_version }}" + - -f + - "{{ noble_repo_root }}/clusters/noble/bootstrap/authentik/values.yaml" + - -f + - "{{ noble_repo_root }}/ansible/.ansible-tmp/authentik-extra-values.yaml" + - --force-conflicts + - --wait + - --timeout + - "{{ noble_authentik_helm_wait_timeout }}" + environment: + KUBECONFIG: "{{ noble_kubeconfig }}" + changed_when: true + + - name: Wait for authentik server rollout + ansible.builtin.command: + argv: + - kubectl + - rollout + - status + - deployment/authentik-server + - -n + - authentik + - --timeout=15m + environment: + KUBECONFIG: "{{ noble_kubeconfig }}" + changed_when: false + + - name: Render Authentik API client descriptor (JSON) + ansible.builtin.template: + src: authentik-clients.json.j2 + dest: "{{ noble_repo_root }}/ansible/.ansible-tmp/authentik-clients.json" + mode: "0600" + no_log: true + + - name: Configure Authentik OAuth2/OIDC providers (API) + ansible.builtin.command: + argv: + - python3 + - "{{ role_path }}/files/configure_authentik.py" + environment: + AUTHENTIK_API_BASE: "{{ noble_authentik_api_base }}" + AUTHENTIK_TOKEN: "{{ noble_authentik_bootstrap_token }}" + BOOTSTRAP_EMAIL: "{{ noble_authentik_bootstrap_email }}" + CLIENT_JSON: "{{ noble_repo_root }}/ansible/.ansible-tmp/authentik-clients.json" + when: noble_authentik_configure_idp | default(true) | bool + changed_when: true + no_log: true + + - name: Create argocd namespace Secret for OIDC client (Argo CD $authentik-oidc:clientSecret) + ansible.builtin.shell: | + set -euo pipefail + kubectl -n argocd create secret generic authentik-oidc \ + --from-literal=clientSecret="${ARGOCD_OIDC_SECRET}" \ + --dry-run=client -o yaml | kubectl apply -f - + kubectl -n argocd label secret authentik-oidc app.kubernetes.io/part-of=argocd --overwrite + environment: + KUBECONFIG: "{{ noble_kubeconfig }}" + ARGOCD_OIDC_SECRET: "{{ noble_authentik_client_secret_argocd }}" + no_log: true + changed_when: true + + - name: Create Grafana OIDC client secret (GF_AUTH_GENERIC_OAUTH_CLIENT_SECRET) + ansible.builtin.shell: | + set -euo pipefail + kubectl -n monitoring create secret generic authentik-grafana-oauth \ + --from-literal=GF_AUTH_GENERIC_OAUTH_CLIENT_SECRET="${GRAFANA_OIDC_SECRET}" \ + --dry-run=client -o yaml | kubectl apply -f - + environment: + KUBECONFIG: "{{ noble_kubeconfig }}" + GRAFANA_OIDC_SECRET: "{{ noble_authentik_client_secret_grafana }}" + no_log: true + changed_when: true + + - name: Create Headlamp OIDC env secret (OIDC_* env vars) + ansible.builtin.shell: | + set -euo pipefail + kubectl -n headlamp create secret generic headlamp-oidc \ + --from-literal=OIDC_CLIENT_ID="{{ noble_authentik_client_id_headlamp }}" \ + --from-literal=OIDC_CLIENT_SECRET="${HEADLAMP_OIDC_SECRET}" \ + --from-literal=OIDC_ISSUER_URL="{{ noble_authentik_public_url }}/application/o/headlamp/" \ + --from-literal=OIDC_SCOPES="openid profile email groups offline_access" \ + --from-literal=OIDC_CALLBACK_URL="https://headlamp.apps.noble.lab.pcenicni.dev/oidc-callback" \ + --from-literal=OIDC_USE_PKCE="true" \ + --dry-run=client -o yaml | kubectl apply -f - + environment: + KUBECONFIG: "{{ noble_kubeconfig }}" + HEADLAMP_OIDC_SECRET: "{{ noble_authentik_client_secret_headlamp }}" + no_log: true + changed_when: true + + - name: Create oauth2-proxy credentials Secret (OIDC to Authentik; not BasicAuth) + ansible.builtin.shell: | + set -euo pipefail + kubectl -n oauth2-proxy create secret generic oauth2-proxy-credentials \ + --from-literal=client-id="{{ noble_authentik_client_id_oauth2_proxy }}" \ + --from-literal=client-secret="${O2_CLIENT_SECRET}" \ + --from-literal=cookie-secret="${O2_COOKIE_SECRET}" \ + --dry-run=client -o yaml | kubectl apply -f - + environment: + KUBECONFIG: "{{ noble_kubeconfig }}" + O2_CLIENT_SECRET: "{{ noble_authentik_client_secret_oauth2_proxy }}" + O2_COOKIE_SECRET: "{{ noble_authentik_oauth2_proxy_cookie_secret }}" + no_log: true + changed_when: true + + - name: Install oauth2-proxy (Helm) — OIDC provider Authentik + ansible.builtin.command: + argv: + - helm + - upgrade + - --install + - oauth2-proxy + - oauth2-proxy/oauth2-proxy + - --namespace + - oauth2-proxy + - --version + - "{{ noble_authentik_oauth2_proxy_chart_version }}" + - -f + - "{{ noble_repo_root }}/clusters/noble/bootstrap/oauth2-proxy/values.yaml" + - --force-conflicts + - --wait + - --timeout + - 10m + environment: + KUBECONFIG: "{{ noble_kubeconfig }}" + changed_when: true + + - name: Apply Traefik ForwardAuth Middleware (references oauth2-proxy OIDC session) + ansible.builtin.command: + argv: + - kubectl + - apply + - -f + - "{{ noble_repo_root }}/clusters/noble/bootstrap/oauth2-proxy/middleware-forwardauth.yaml" + environment: + KUBECONFIG: "{{ noble_kubeconfig }}" + changed_when: true + + - name: Helm upgrade Argo CD with Authentik OIDC values + ansible.builtin.command: + argv: + - helm + - upgrade + - --install + - argocd + - argo/argo-cd + - --namespace + - argocd + - --version + - "{{ noble_authentik_argocd_chart_version }}" + - -f + - "{{ noble_repo_root }}/clusters/noble/bootstrap/argocd/values.yaml" + - -f + - "{{ noble_repo_root }}/clusters/noble/bootstrap/argocd/values-authentik-oidc.yaml" + - --force-conflicts + - --wait + - --timeout + - 15m + environment: + KUBECONFIG: "{{ noble_kubeconfig }}" + changed_when: true + + - name: Helm upgrade kube-prometheus-stack (Grafana OIDC + ForwardAuth on Prom/AM) + ansible.builtin.command: + argv: + - helm + - upgrade + - --install + - kube-prometheus + - prometheus-community/kube-prometheus-stack + - -n + - monitoring + - --version + - "{{ noble_authentik_kube_prometheus_chart_version }}" + - -f + - "{{ noble_repo_root }}/clusters/noble/bootstrap/kube-prometheus-stack/values.yaml" + - -f + - "{{ noble_repo_root }}/clusters/noble/bootstrap/kube-prometheus-stack/values-authentik-oidc.yaml" + - --force-conflicts + - --wait + - --timeout + - "{{ noble_authentik_kube_prometheus_helm_wait_timeout }}" + environment: + KUBECONFIG: "{{ noble_kubeconfig }}" + changed_when: true + + - name: Helm upgrade Headlamp with Authentik OIDC values + ansible.builtin.command: + argv: + - helm + - upgrade + - --install + - headlamp + - headlamp/headlamp + - --version + - "{{ noble_authentik_headlamp_chart_version }}" + - -n + - headlamp + - -f + - "{{ noble_repo_root }}/clusters/noble/bootstrap/headlamp/values.yaml" + - -f + - "{{ noble_repo_root }}/clusters/noble/bootstrap/headlamp/values-authentik-oidc.yaml" + - --force-conflicts + - --wait + - --timeout + - 10m + environment: + KUBECONFIG: "{{ noble_kubeconfig }}" + changed_when: true + + - name: Helm upgrade Longhorn with ForwardAuth (oauth2-proxy OIDC) + ansible.builtin.command: + argv: + - helm + - upgrade + - --install + - longhorn + - longhorn/longhorn + - -n + - longhorn-system + - --version + - "{{ noble_authentik_longhorn_chart_version }}" + - -f + - "{{ noble_repo_root }}/clusters/noble/bootstrap/longhorn/values.yaml" + - -f + - "{{ noble_repo_root }}/clusters/noble/bootstrap/longhorn/values-authentik-forwardauth.yaml" + - --force-conflicts + - --wait + - --timeout + - "{{ noble_helm_longhorn_wait_timeout | default('20m') }}" + environment: + KUBECONFIG: "{{ noble_kubeconfig }}" + register: noble_authentik_longhorn_helm + retries: "{{ noble_helm_longhorn_retries | default(8) | int }}" + delay: "{{ noble_helm_longhorn_retry_delay | default(25) | int }}" + until: noble_authentik_longhorn_helm.rc == 0 + changed_when: true diff --git a/ansible/roles/noble_authentik/templates/authentik-clients.json.j2 b/ansible/roles/noble_authentik/templates/authentik-clients.json.j2 new file mode 100644 index 0000000..84594a2 --- /dev/null +++ b/ansible/roles/noble_authentik/templates/authentik-clients.json.j2 @@ -0,0 +1,26 @@ +{ + "argocd": { + "name": "Argo CD", + "client_id": {{ noble_authentik_client_id_argocd | to_json }}, + "client_secret": {{ noble_authentik_client_secret_argocd | to_json }}, + "redirect_uri": "https://argo.apps.noble.lab.pcenicni.dev/auth/callback" + }, + "grafana": { + "name": "Grafana", + "client_id": {{ noble_authentik_client_id_grafana | to_json }}, + "client_secret": {{ noble_authentik_client_secret_grafana | to_json }}, + "redirect_uri": "https://grafana.apps.noble.lab.pcenicni.dev/login/generic_oauth" + }, + "headlamp": { + "name": "Headlamp", + "client_id": {{ noble_authentik_client_id_headlamp | to_json }}, + "client_secret": {{ noble_authentik_client_secret_headlamp | to_json }}, + "redirect_uri": "https://headlamp.apps.noble.lab.pcenicni.dev/oidc-callback" + }, + "oauth2-proxy": { + "name": "oauth2-proxy (ForwardAuth)", + "client_id": {{ noble_authentik_client_id_oauth2_proxy | to_json }}, + "client_secret": {{ noble_authentik_client_secret_oauth2_proxy | to_json }}, + "redirect_uri": "https://{{ noble_authentik_oauth2_proxy_host }}/oauth2/callback" + } +} diff --git a/ansible/roles/noble_authentik/templates/authentik-extra-values.yaml.j2 b/ansible/roles/noble_authentik/templates/authentik-extra-values.yaml.j2 new file mode 100644 index 0000000..9318791 --- /dev/null +++ b/ansible/roles/noble_authentik/templates/authentik-extra-values.yaml.j2 @@ -0,0 +1,16 @@ +--- +authentik: + secret_key: "{{ noble_authentik_secret_key }}" + postgresql: + password: "{{ noble_authentik_postgresql_password }}" +global: + env: + - name: AUTHENTIK_BOOTSTRAP_TOKEN + value: "{{ noble_authentik_bootstrap_token }}" + - name: AUTHENTIK_BOOTSTRAP_EMAIL + value: "{{ noble_authentik_bootstrap_email }}" + - name: AUTHENTIK_BOOTSTRAP_PASSWORD + value: "{{ noble_authentik_bootstrap_password }}" +postgresql: + auth: + password: "{{ noble_authentik_postgresql_password }}" diff --git a/ansible/roles/noble_landing_urls/defaults/main.yml b/ansible/roles/noble_landing_urls/defaults/main.yml index 1da1332..d146b6e 100644 --- a/ansible/roles/noble_landing_urls/defaults/main.yml +++ b/ansible/roles/noble_landing_urls/defaults/main.yml @@ -9,6 +9,16 @@ noble_landing_urls_fetch_credentials: true noble_landing_urls_headlamp_token_duration: 48h noble_lab_ui_entries: + - name: Authentik + description: OIDC IdP (admin UI, OAuth2/OIDC for cluster apps) + namespace: authentik + service: authentik-server + url: https://auth.apps.noble.lab.pcenicni.dev + - name: oauth2-proxy + description: OIDC to Authentik + Traefik ForwardAuth (Prometheus, Alertmanager, Longhorn) + namespace: oauth2-proxy + service: oauth2-proxy + url: https://oauth2.apps.noble.lab.pcenicni.dev - name: Argo CD description: GitOps UI (sync, apps, repos) namespace: argocd diff --git a/ansible/roles/noble_landing_urls/templates/noble-lab-ui-urls.md.j2 b/ansible/roles/noble_landing_urls/templates/noble-lab-ui-urls.md.j2 index 777b95a..66c4bdc 100644 --- a/ansible/roles/noble_landing_urls/templates/noble-lab-ui-urls.md.j2 +++ b/ansible/roles/noble_landing_urls/templates/noble-lab-ui-urls.md.j2 @@ -21,9 +21,9 @@ This file is **generated** by Ansible (`noble_landing_urls` role). Use it as a t | **Argo CD** | `admin` | {% if (noble_fetch_argocd_pw_b64 is defined) and (noble_fetch_argocd_pw_b64.rc | default(1) == 0) and (noble_fetch_argocd_pw_b64.stdout | default('') | length > 0) %}`{{ noble_fetch_argocd_pw_b64.stdout | b64decode }}`{% else %}*(not fetched — use commands below)*{% endif %} | | **Grafana** | {% if (noble_fetch_grafana_user_b64 is defined) and (noble_fetch_grafana_user_b64.rc | default(1) == 0) and (noble_fetch_grafana_user_b64.stdout | default('') | length > 0) %}`{{ noble_fetch_grafana_user_b64.stdout | b64decode }}`{% else %}*(from Secret — use commands below)*{% endif %} | {% if (noble_fetch_grafana_pw_b64 is defined) and (noble_fetch_grafana_pw_b64.rc | default(1) == 0) and (noble_fetch_grafana_pw_b64.stdout | default('') | length > 0) %}`{{ noble_fetch_grafana_pw_b64.stdout | b64decode }}`{% else %}*(not fetched — use commands below)*{% endif %} | | **Headlamp** | ServiceAccount **`headlamp`** | {% if (noble_fetch_headlamp_token is defined) and (noble_fetch_headlamp_token.rc | default(1) == 0) and (noble_fetch_headlamp_token.stdout | default('') | trim | length > 0) %}Token ({{ noble_landing_urls_headlamp_token_duration | default('48h') }}): `{{ noble_fetch_headlamp_token.stdout | trim }}`{% else %}*(not generated — use command below)*{% endif %} | -| **Prometheus** | — | No auth in default install (lab). | -| **Alertmanager** | — | No auth in default install (lab). | -| **Longhorn** | — | No default login unless you enable access control in the UI settings. | +| **Prometheus** | — | Browser login via **oauth2-proxy** → **Authentik** (OIDC). | +| **Alertmanager** | — | Browser login via **oauth2-proxy** → **Authentik** (OIDC). | +| **Longhorn** | — | Browser login via **oauth2-proxy** → **Authentik** (OIDC). | ### Commands to retrieve passwords (if not filled above) @@ -45,7 +45,8 @@ To generate this file **without** calling kubectl, run Ansible with **`-e noble_ - **Argo CD** `argocd-initial-admin-secret` disappears after you change the admin password. - **Grafana** password is random unless you set `grafana.adminPassword` in chart values. -- **Prometheus / Alertmanager** UIs are unauthenticated by default — restrict when hardening (`talos/CLUSTER-BUILD.md` Phase G). +- **Argo CD / Grafana / Headlamp** use **native OIDC** to **Authentik** when **`noble_authentik_install`** ran with **`ansible/roles/noble_authentik`** (see **`clusters/noble/bootstrap/**/values-authentik*.yaml`**). +- **Prometheus / Alertmanager / Longhorn** UIs use **oauth2-proxy** as an **OIDC RP** to Authentik (Traefik ForwardAuth), not HTTP BasicAuth. - **SOPS:** cluster secrets in git under **`clusters/noble/secrets/`** are encrypted; decrypt with **`age-key.txt`** (not in git). See **`clusters/noble/secrets/README.md`**. - **Headlamp** token above expires after the configured duration; re-run Ansible or `kubectl create token` to refresh. - **Velero** has **no web UI** — use **`velero`** CLI or **`kubectl -n velero get backup,schedule,backupstoragelocation`**. Metrics: **`velero`** Service in **`velero`** (Prometheus scrape). See `clusters/noble/bootstrap/velero/README.md`. diff --git a/clusters/noble/bootstrap/argocd/values-authentik-oidc.yaml b/clusters/noble/bootstrap/argocd/values-authentik-oidc.yaml new file mode 100644 index 0000000..2724bbb --- /dev/null +++ b/clusters/noble/bootstrap/argocd/values-authentik-oidc.yaml @@ -0,0 +1,20 @@ +# OIDC with Authentik (merged on `helm upgrade` after **noble_authentik** provisions providers + Secret **authentik-oidc**). +# Issuer path uses provider slug **argocd** (see noble_authentik/configure_authentik.py). + +configs: + cm: + oidc.config: | + name: Authentik + issuer: https://auth.apps.noble.lab.pcenicni.dev/application/o/argocd/ + clientID: argocd + clientSecret: $authentik-oidc:clientSecret + requestedScopes: + - openid + - profile + - email + - groups + rbac: + policy.default: role:readonly + policy.csv: | + g, admin, role:admin + g, noble-admins, role:admin diff --git a/clusters/noble/bootstrap/authentik/namespace.yaml b/clusters/noble/bootstrap/authentik/namespace.yaml new file mode 100644 index 0000000..71b1516 --- /dev/null +++ b/clusters/noble/bootstrap/authentik/namespace.yaml @@ -0,0 +1,8 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: authentik + labels: + pod-security.kubernetes.io/enforce: privileged + pod-security.kubernetes.io/audit: privileged + pod-security.kubernetes.io/warn: privileged diff --git a/clusters/noble/bootstrap/authentik/values.yaml b/clusters/noble/bootstrap/authentik/values.yaml new file mode 100644 index 0000000..8ac813a --- /dev/null +++ b/clusters/noble/bootstrap/authentik/values.yaml @@ -0,0 +1,51 @@ +# Authentik — noble lab (Helm: goauthentik/authentik) +# +# Secrets (secret_key, postgres password, bootstrap) are supplied at install time by Ansible +# (-f authentik-extra-values.yaml from noble_authentik role). Do not commit real secrets here. +# +# DNS: auth.apps.noble.lab.pcenicni.dev → Traefik LB (see traefik/values.yaml). +# +# helm repo add goauthentik https://charts.goauthentik.io && helm repo update +# kubectl apply -f clusters/noble/bootstrap/authentik/namespace.yaml +# helm upgrade --install authentik goauthentik/authentik -n authentik --create-namespace \ +# --version 2026.2.3 -f clusters/noble/bootstrap/authentik/values.yaml -f /path/to/extra.yaml --wait + +postgresql: + enabled: true + auth: + username: authentik + database: authentik + password: "" + primary: + persistence: + enabled: true + storageClassName: longhorn + size: 10Gi + +authentik: + secret_key: "" + postgresql: + name: authentik + user: authentik + password: "" + port: 5432 + +server: + replicas: 1 + ingress: + enabled: true + ingressClassName: traefik + annotations: + cert-manager.io/cluster-issuer: letsencrypt-prod + hosts: + - host: auth.apps.noble.lab.pcenicni.dev + paths: + - path: / + pathType: Prefix + tls: + - secretName: authentik-apps-noble-tls + hosts: + - auth.apps.noble.lab.pcenicni.dev + +worker: + replicas: 1 diff --git a/clusters/noble/bootstrap/headlamp/values-authentik-oidc.yaml b/clusters/noble/bootstrap/headlamp/values-authentik-oidc.yaml new file mode 100644 index 0000000..c4d8c6e --- /dev/null +++ b/clusters/noble/bootstrap/headlamp/values-authentik-oidc.yaml @@ -0,0 +1,9 @@ +# OIDC with Authentik — credentials live in Secret **headlamp-oidc** (envFrom), created by **noble_authentik**. + +config: + oidc: + secret: + create: false + externalSecret: + enabled: true + name: headlamp-oidc diff --git a/clusters/noble/bootstrap/kube-prometheus-stack/values-authentik-oidc.yaml b/clusters/noble/bootstrap/kube-prometheus-stack/values-authentik-oidc.yaml new file mode 100644 index 0000000..573d523 --- /dev/null +++ b/clusters/noble/bootstrap/kube-prometheus-stack/values-authentik-oidc.yaml @@ -0,0 +1,33 @@ +# Authentik OIDC for Grafana; ForwardAuth to **oauth2-proxy** (OIDC to Authentik) for Prometheus / Alertmanager UIs. + +prometheus: + ingress: + annotations: + traefik.ingress.kubernetes.io/router.middlewares: oauth2-proxy-forward-auth@kubernetescrd + +alertmanager: + ingress: + annotations: + traefik.ingress.kubernetes.io/router.middlewares: oauth2-proxy-forward-auth@kubernetescrd + +grafana: + env: + GF_AUTH_GENERIC_OAUTH_CLIENT_SECRET: + valueFrom: + secretKeyRef: + name: authentik-grafana-oauth + key: GF_AUTH_GENERIC_OAUTH_CLIENT_SECRET + grafana.ini: + auth: + disable_login_form: "false" + auth.generic_oauth: + enabled: true + name: Authentik + allow_sign_up: true + client_id: grafana + scopes: openid profile email groups + use_pkce: true + auth_url: https://auth.apps.noble.lab.pcenicni.dev/application/o/grafana/oauth2/authorize/ + token_url: https://auth.apps.noble.lab.pcenicni.dev/application/o/grafana/oauth2/token/ + api_url: https://auth.apps.noble.lab.pcenicni.dev/application/o/grafana/userinfo/ + role_attribute_path: "contains(groups[*], 'noble-admins') && 'Admin' || contains(groups[*], 'noble-editors') && 'Editor' || 'Viewer'" diff --git a/clusters/noble/bootstrap/kustomization.yaml b/clusters/noble/bootstrap/kustomization.yaml index db2bc0a..3048667 100644 --- a/clusters/noble/bootstrap/kustomization.yaml +++ b/clusters/noble/bootstrap/kustomization.yaml @@ -9,6 +9,8 @@ apiVersion: kustomize.config.k8s.io/v1beta1 kind: Kustomization resources: + - authentik/namespace.yaml + - oauth2-proxy/namespace.yaml - kube-prometheus-stack/namespace.yaml - loki/namespace.yaml - fluent-bit/namespace.yaml diff --git a/clusters/noble/bootstrap/longhorn/values-authentik-forwardauth.yaml b/clusters/noble/bootstrap/longhorn/values-authentik-forwardauth.yaml new file mode 100644 index 0000000..48f450b --- /dev/null +++ b/clusters/noble/bootstrap/longhorn/values-authentik-forwardauth.yaml @@ -0,0 +1,5 @@ +# ForwardAuth to **oauth2-proxy** (OIDC with Authentik) for the Longhorn UI Ingress. + +ingress: + annotations: + traefik.ingress.kubernetes.io/router.middlewares: oauth2-proxy-forward-auth@kubernetescrd diff --git a/clusters/noble/bootstrap/oauth2-proxy/middleware-forwardauth.yaml b/clusters/noble/bootstrap/oauth2-proxy/middleware-forwardauth.yaml new file mode 100644 index 0000000..cea782b --- /dev/null +++ b/clusters/noble/bootstrap/oauth2-proxy/middleware-forwardauth.yaml @@ -0,0 +1,16 @@ +# Traefik ForwardAuth → oauth2-proxy (OIDC with Authentik). Reference from Ingress: +# traefik.ingress.kubernetes.io/router.middlewares: oauth2-proxy-forward-auth@kubernetescrd +apiVersion: traefik.io/v1alpha1 +kind: Middleware +metadata: + name: forward-auth + namespace: oauth2-proxy +spec: + forwardAuth: + address: http://oauth2-proxy.oauth2-proxy.svc.cluster.local:4180/oauth2/auth + trustForwardHeader: true + authResponseHeaders: + - X-Forwarded-User + - X-Forwarded-Email + - X-Forwarded-Preferred-Username + - X-Forwarded-Groups diff --git a/clusters/noble/bootstrap/oauth2-proxy/namespace.yaml b/clusters/noble/bootstrap/oauth2-proxy/namespace.yaml new file mode 100644 index 0000000..ded8ae8 --- /dev/null +++ b/clusters/noble/bootstrap/oauth2-proxy/namespace.yaml @@ -0,0 +1,4 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: oauth2-proxy diff --git a/clusters/noble/bootstrap/oauth2-proxy/values.yaml b/clusters/noble/bootstrap/oauth2-proxy/values.yaml new file mode 100644 index 0000000..d5e3242 --- /dev/null +++ b/clusters/noble/bootstrap/oauth2-proxy/values.yaml @@ -0,0 +1,47 @@ +# oauth2-proxy — OIDC client to **Authentik** (not BasicAuth). Used with Traefik ForwardAuth +# so apps without native OIDC (Prometheus, Alertmanager, Longhorn UI) still get a full OAuth code flow. +# +# Client id/secret/cookie-secret are created by Ansible (Kubernetes Secret + Helm values). +# +# helm repo add oauth2-proxy https://oauth2-proxy.github.io/manifests && helm repo update +# kubectl apply -f clusters/noble/bootstrap/oauth2-proxy/namespace.yaml +# helm upgrade --install oauth2-proxy oauth2-proxy/oauth2-proxy -n oauth2-proxy \ +# --version 10.4.3 -f clusters/noble/bootstrap/oauth2-proxy/values.yaml -f /path/to/extra.yaml --wait + +config: + # Populated by Ansible: Secret **oauth2-proxy-credentials** (keys client-id, client-secret, cookie-secret). + existingSecret: oauth2-proxy-credentials + clientID: oauth2-proxy + clientSecret: "" + cookieSecret: "" + cookieName: _oauth2_proxy + emailDomains: ["*"] + +ingress: + enabled: true + className: traefik + path: / + hosts: + - oauth2.apps.noble.lab.pcenicni.dev + annotations: + cert-manager.io/cluster-issuer: letsencrypt-prod + tls: + - secretName: oauth2-apps-noble-tls + hosts: + - oauth2.apps.noble.lab.pcenicni.dev + +extraArgs: + provider: oidc + skip-provider-button: "true" + oidc-issuer-url: "https://auth.apps.noble.lab.pcenicni.dev/application/o/oauth2-proxy/" + redirect-url: "https://oauth2.apps.noble.lab.pcenicni.dev/oauth2/callback" + scope: "openid profile email groups" + cookie-domain: ".apps.noble.lab.pcenicni.dev" + whitelist-domain: ".apps.noble.lab.pcenicni.dev" + set-authorization-header: "true" + pass-access-token: "false" + reverse-proxy: "true" + upstream: static://200 + +service: + portNumber: 4180