From 5e5c6ef671ea0e5afb653d2b953cbd6801439e07 Mon Sep 17 00:00:00 2001 From: Nikholas Pcenicni <82239765+nikpcenicni@users.noreply.github.com> Date: Thu, 14 May 2026 14:26:43 -0400 Subject: [PATCH] Enhance Authentik role in noble cluster setup by adding support for resolving OAuth2 flow, signing key, and scope mapping UUIDs from the worker database, improving API access under 2026+ RBAC. Update README with troubleshooting steps for common OAuth2 provider issues and adjust default variables for better configuration management. Ensure seamless integration with oauth2-proxy by allowing unverified email handling in development environments. --- ansible/roles/noble_authentik/README.md | 21 +- .../roles/noble_authentik/defaults/main.yml | 22 +- .../configure_authentik.cpython-314.pyc | Bin 25496 -> 31114 bytes .../resolve_noble_group_pks.cpython-314.pyc | Bin 0 -> 881 bytes ...ve_oauth_scope_mapping_pks.cpython-314.pyc | Bin 0 -> 1377 bytes ..._add_bootstrap_user_groups.cpython-314.pyc | Bin 0 -> 3380 bytes ...ure_authentik_admin_access.cpython-314.pyc | Bin 0 -> 3791 bytes .../worker_upsert_oauth_oidc.cpython-314.pyc | Bin 0 -> 6241 bytes .../files/configure_authentik.py | 213 ++++++++++----- .../files/resolve_noble_group_pks.py | 15 ++ .../files/resolve_oauth_flow_pks.py | 23 ++ .../files/resolve_oauth_scope_mapping_pks.py | 22 ++ .../files/resolve_oauth_signing_key_pk.py | 13 + .../files/worker_add_bootstrap_user_groups.py | 55 ++++ .../worker_ensure_authentik_admin_access.py | 73 +++++ .../files/worker_upsert_oauth_oidc.py | 110 ++++++++ ansible/roles/noble_authentik/tasks/main.yml | 249 ++++++++++++++++++ ...authentik-worker-admin-access-spec.json.j2 | 3 + .../authentik-worker-oidc-spec.json.j2 | 32 +++ .../authentik-worker-user-groups-spec.json.j2 | 7 + .../tasks/apply_talos_node_config.yml | 69 +++++ ansible/roles/talos_phase_a/tasks/main.yml | 34 +-- .../noble/bootstrap/oauth2-proxy/values.yaml | 4 + talos/README.md | 2 + 24 files changed, 868 insertions(+), 99 deletions(-) create mode 100644 ansible/roles/noble_authentik/files/__pycache__/resolve_noble_group_pks.cpython-314.pyc create mode 100644 ansible/roles/noble_authentik/files/__pycache__/resolve_oauth_scope_mapping_pks.cpython-314.pyc create mode 100644 ansible/roles/noble_authentik/files/__pycache__/worker_add_bootstrap_user_groups.cpython-314.pyc create mode 100644 ansible/roles/noble_authentik/files/__pycache__/worker_ensure_authentik_admin_access.cpython-314.pyc create mode 100644 ansible/roles/noble_authentik/files/__pycache__/worker_upsert_oauth_oidc.cpython-314.pyc create mode 100644 ansible/roles/noble_authentik/files/resolve_noble_group_pks.py create mode 100644 ansible/roles/noble_authentik/files/resolve_oauth_flow_pks.py create mode 100644 ansible/roles/noble_authentik/files/resolve_oauth_scope_mapping_pks.py create mode 100644 ansible/roles/noble_authentik/files/resolve_oauth_signing_key_pk.py create mode 100644 ansible/roles/noble_authentik/files/worker_add_bootstrap_user_groups.py create mode 100644 ansible/roles/noble_authentik/files/worker_ensure_authentik_admin_access.py create mode 100644 ansible/roles/noble_authentik/files/worker_upsert_oauth_oidc.py create mode 100644 ansible/roles/noble_authentik/templates/authentik-worker-admin-access-spec.json.j2 create mode 100644 ansible/roles/noble_authentik/templates/authentik-worker-oidc-spec.json.j2 create mode 100644 ansible/roles/noble_authentik/templates/authentik-worker-user-groups-spec.json.j2 create mode 100644 ansible/roles/talos_phase_a/tasks/apply_talos_node_config.yml diff --git a/ansible/roles/noble_authentik/README.md b/ansible/roles/noble_authentik/README.md index 8d6dbae..b62df15 100644 --- a/ansible/roles/noble_authentik/README.md +++ b/ansible/roles/noble_authentik/README.md @@ -12,11 +12,11 @@ Installs **Authentik** (Helm `goauthentik/authentik`) as the cluster IdP, **oaut ## Variables -See **`defaults/main.yml`**. Hostnames default to **`auth.apps.noble.lab.pcenicni.dev`** and **`oauth2.apps.noble.lab.pcenicni.dev`**. +See **`defaults/main.yml`**. Hostnames default to **`auth.apps.noble.lab.pcenicni.dev`** and **`oauth2.apps.noble.lab.pcenicni.dev`**. **`noble_authentik_ensure_admin_ui_access`** (default **true**) re-applies **authentik Admins** superuser membership via the worker on each **`--tags authentik`** run so the admin UI keeps working under **2026+** RBAC. ## 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. +When **`noble_authentik_configure_idp`** is true, Ansible creates/updates OAuth2 providers and applications for **argocd**, **grafana**, **headlamp**, and **oauth2-proxy** using either the **worker ORM path** (default **`noble_authentik_oidc_provision_via: worker`**: **`kubectl exec`** + **`ak shell`** + **`files/worker_upsert_oauth_oidc.py`**, which avoids **2026+** REST **403** on **`GET …/providers/oauth2/**`) or the **REST-only path** (**`noble_authentik_oidc_provision_via: rest`**: **`files/configure_authentik.py`** needs a token that can list/patch OAuth2 providers). With the worker path and a bootstrap email, it also runs **`files/worker_add_bootstrap_user_groups.py`** so **`User.groups.add`** does not depend on **`GET …/core/users/**`. It then runs **`configure_authentik.py`** with **`AUTHENTIK_SKIP_OIDC_REST`** / **`AUTHENTIK_SKIP_USER_GROUP_REST`** when those worker steps ran, so the script only calls **`ensure_group`** over the API (skipped when **`AUTHENTIK_NOBLE_*_GROUP_PK`** are set). ## RBAC notes @@ -25,17 +25,24 @@ When **`noble_authentik_configure_idp`** is true, Ansible runs **`files/configur ## Troubleshooting +- **oauth2-proxy shows 500** on **`oauth2.apps…/oauth2/callback`** (logs: `email in id_token (...) isn't verified`): Authentik’s id_token often lacks **`email_verified: true`** for bootstrap users. **`clusters/noble/bootstrap/oauth2-proxy/values.yaml`** sets **`insecure-oidc-allow-unverified-email`** for the lab; otherwise verify the user’s email in Authentik, then **`helm upgrade oauth2-proxy`** (or **`--tags authentik`**). - 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`**. - **`GET …/flows/instances/…` → HTTP 403** with **`Token invalid/expired`**: the bootstrap API token is not accepted yet (common right after install: worker still creating it) or **`NOBLE_AUTHENTIK_BOOTSTRAP_TOKEN`** in `.env` does not match the value Helm applied. Re-run **`--tags authentik`** (the role waits for **`GET …/core/applications/`** to return **200** with your token). If you rotated the token in `.env` only, run the play again so Helm picks up the new value, or mint a new API token for **`akadmin`** in the admin UI. -- **`GET …/flows/instances/…` → HTTP 403** with **permission** errors (Authentik **2026+** RBAC): the bearer token’s user must be able to **view flows**. The Helm bootstrap token belongs to **`akadmin`**, which must be in the **`authentik Admins`** group. Add **`akadmin`** to **Directory → Groups → authentik Admins**, or create a new **API token** for `akadmin` after fixing groups, and put that token in **`NOBLE_AUTHENTIK_BOOTSTRAP_TOKEN`**. As a workaround, set **`noble_authentik_oauth_authorization_flow_pk`** and **`noble_authentik_oauth_invalidation_flow_pk`** (both required) to the flows’ **UUID** primary keys from **Admin → Flows** (or `-e` / `group_vars`); the configure script then skips flow list API calls. -- **`/if/admin/` redirects to `/if/user/`** (even as **`akadmin`**): the admin UI only loads when **`canAccessAdmin`** is true. That comes from **`user.isSuperuser`** on **`GET /api/v3/core/users/me/`**, which is **not** the Django username — in Authentik **2026.x** it is derived from **membership in a group with the superuser flag** (bootstrap blueprint: **`authentik Admins`**). If **`isSuperuser`** is false in **`/me`**, `akadmin` is missing that membership or the group’s flag is off. Fix in **Directory → Groups** when you can, or run the worker shell below, then **log out** and sign in again. +- **`GET …/flows/instances/…` → HTTP 403** with **permission** errors (Authentik **2026+** RBAC): the bootstrap API token often cannot **view flows**. The role reads flow UUIDs from the **worker** database (`kubectl exec` + **`ak shell`**) when **`noble_authentik_oauth_authorization_flow_pk`** / **`noble_authentik_oauth_invalidation_flow_pk`** are unset. The same pattern applies to **`/crypto/certificatekeypairs/`**, **`/propertymappings/…`**, **`/core/groups/`**, and the matching **`noble_authentik_*`** inventory variables. If a lookup fails, fix **`akadmin`** / **authentik Admins** / token, or set the UUID variables manually (see below). +- **`GET …/crypto/certificatekeypairs/…` → HTTP 403** (permission): same RBAC issue as flows. When **`noble_authentik_oauth_signing_key_pk`** is unset, the role resolves the first **CertificateKeyPair** UUID from the **worker** DB. You can also set **`noble_authentik_oauth_signing_key_pk`** manually (Admin → **System** → **Certificates**). +- **`GET …/propertymappings/…` → HTTP 403** (permission): when **`noble_authentik_oauth_scope_mapping_pks`** is unset, the role resolves **ScopeMapping** UUIDs from the **worker** DB: **openid**, **email**, **profile**, **offline_access**, and **groups** only if a separate **`groups`** mapping exists (Authentik **2026.x** defaults put **groups** inside **profile** only). +- **`GET …/core/groups/…` → HTTP 403** (permission): when **`noble_authentik_group_pk_noble_admins`** and **`noble_authentik_group_pk_noble_editors`** are unset, the role runs **`resolve_noble_group_pks.py`** in the worker (**`get_or_create`** for **noble-admins** / **noble-editors**), then passes **`AUTHENTIK_NOBLE_*_GROUP_PK`** into **`configure_authentik.py`** so it skips group list/create via REST. +- **`GET …/providers/oauth2/…` → HTTP 403** (permission): bootstrap tokens often cannot list OAuth2 providers. With the default **`noble_authentik_oidc_provision_via: worker`**, the role upserts providers and applications in **`authentik-worker`** via Django ORM (**`worker_upsert_oauth_oidc.py`**) instead of **`configure_authentik.py`** REST. Set **`noble_authentik_oidc_provision_via: rest`** only if your API token has **view_oauth2provider** / provider edit permissions (e.g. a full **akadmin** token from the UI). +- **`GET …/core/users/…` → HTTP 403** when adding the bootstrap user to **noble-admins** / **noble-editors**: with **`noble_authentik_oidc_provision_via: worker`** and a non-empty bootstrap email, the role runs **`worker_add_bootstrap_user_groups.py`** in the worker (ORM **`User.groups.add`**) and sets **`AUTHENTIK_SKIP_USER_GROUP_REST`** so **`configure_authentik.py`** does not call the users API for membership. +- **Manual flow / signing / scope / group UUIDs (optional):** set **`noble_authentik_oauth_authorization_flow_pk`** and **`noble_authentik_oauth_invalidation_flow_pk`** (both together), optionally **`noble_authentik_oauth_signing_key_pk`**, **`noble_authentik_oauth_scope_mapping_pks`**, **`noble_authentik_group_pk_noble_admins`**, and **`noble_authentik_group_pk_noble_editors`**, from the admin UI or `-e` / `group_vars`; **`configure_authentik.py`** then skips the matching REST discovery calls. +- **`/if/admin/` redirects to `/if/user/`** (lost admin panel): in **2026.x**, **`canAccessAdmin`** follows **`isSuperuser`**, which is true only when the user belongs to a group with the **superuser** flag (**`authentik Admins`** by default). **`noble_authentik_ensure_admin_ui_access`** (default **true**) makes **`--tags authentik`** run **`files/worker_ensure_authentik_admin_access.py`** in **authentik-worker** (adds **akadmin** or the **bootstrap email** user to **authentik Admins** and forces **`is_superuser`** on that group). **Log out** of Authentik (private window is fine) and sign in again. Set **`noble_authentik_ensure_admin_ui_access: false`** to skip. Without Ansible, you can fix it in **Directory → Groups → authentik Admins** (superuser flag + membership) or run **`ak shell`** with the same logic as that script. -### Fix `akadmin` superuser / admin redirect (worker shell) +### Fix admin access manually (worker shell, no Ansible) ```bash -kubectl exec -it deploy/authentik-worker -n authentik -- ak shell -c "from authentik.core.models import User, Group; u=User.objects.get(username='akadmin'); adm,_=Group.objects.get_or_create(name='authentik Admins', defaults={'is_superuser': True}); adm.is_superuser=True; adm.save(update_fields=['is_superuser']); adm.users.add(u); u=User.objects.get(pk=u.pk); print('all_groups', list(u.all_groups().values_list('name', flat=True))); print('is_superuser', u.is_superuser)" +kubectl exec -it deploy/authentik-worker -n authentik -- ak shell -c "from authentik.core.models import User, Group; u=User.objects.get(username='akadmin'); adm,_=Group.objects.get_or_create(name='authentik Admins', defaults={'is_superuser': True}); adm.is_superuser=True; adm.save(update_fields=['is_superuser']); u.groups.add(adm); u=User.objects.get(pk=u.pk); print('is_superuser', u.is_superuser)" ``` -Then **log out** of Authentik (or use a private window) and sign in again as **`akadmin`**. +Then **log out** of Authentik and sign in again. - **Grafana / Headlamp / ForwardAuth “Unauthorized” or Authentik “Not found”** (Authentik **2026.x**): OAuth endpoints are no longer under **`/application/o//oauth2/...`**. Use **issuer discovery** (Grafana **`server_url`** at **`…/application/o//`**; oauth2-proxy **`oidc-issuer-url`**; Headlamp **`-oidc-idp-issuer-url`**). Re-apply **Traefik** (**`allowCrossNamespace`** so Ingresses can use Middleware in **`oauth2-proxy`**), **kube-prometheus-stack**, and **Headlamp** after updating values (e.g. **`ansible-playbook playbooks/noble.yml --tags authentik`**). diff --git a/ansible/roles/noble_authentik/defaults/main.yml b/ansible/roles/noble_authentik/defaults/main.yml index 08ee577..35b040a 100644 --- a/ansible/roles/noble_authentik/defaults/main.yml +++ b/ansible/roles/noble_authentik/defaults/main.yml @@ -3,8 +3,16 @@ noble_authentik_install: false # When true, run **configure_authentik.py** against the Authentik API (requires bootstrap token + client secrets). noble_authentik_configure_idp: true +# **worker** — upsert OAuth2 providers + applications via **ak shell** + Django ORM (avoids **GET …/providers/oauth2/** 403 +# for bootstrap tokens). **rest** — use the Authentik API only (needs a token that can list/patch OAuth2 providers). +# When true (default), run **worker_ensure_authentik_admin_access.py** so **akadmin** / bootstrap email is in +# **authentik Admins** with **is_superuser** on the group (fixes **/if/admin/** redirecting to user UI in 2026+). +noble_authentik_ensure_admin_ui_access: true noble_authentik_chart_version: "2026.2.3" +noble_authentik_namespace: authentik +# Helm release name (deployments: **{release}-server**, **{release}-worker**). +noble_authentik_release_name: authentik noble_authentik_oauth2_proxy_chart_version: "10.4.3" noble_authentik_host: auth.apps.noble.lab.pcenicni.dev @@ -33,9 +41,21 @@ noble_authentik_client_secret_oauth2_proxy: "" noble_authentik_oauth2_proxy_cookie_secret: "" # Optional: OAuth2 provider flow PKs (UUID strings). When **both** are set, **configure_authentik.py** -# skips **GET /flows/instances/** (avoids 403 if the API token user is not a superuser). See role README. +# skips **GET /flows/instances/** (avoids 403 if the API token cannot view flows). If unset, the role +# tries **kubectl exec** into **authentik-worker** + **ak shell** to read the same slugs from the DB. noble_authentik_oauth_authorization_flow_pk: "" noble_authentik_oauth_invalidation_flow_pk: "" +# Optional: OAuth2 signing key (**CertificateKeyPair** UUID). When set, **configure_authentik.py** skips +# **GET /crypto/certificatekeypairs/** (often 403 for bootstrap tokens). If unset, the role resolves it +# from the worker DB when possible (see **resolve_oauth_signing_key_pk.py**). +noble_authentik_oauth_signing_key_pk: "" +# Optional: comma-separated **ScopeMapping** UUIDs (openid, email, profile, offline_access; optional **groups** +# if you created a separate mapping — 2026.x defaults embed groups in **profile** only). +# When set, **configure_authentik.py** skips **GET /propertymappings/...** (often 403 for bootstrap tokens). +noble_authentik_oauth_scope_mapping_pks: "" +# Optional: **Group** UUIDs for **noble-admins** / **noble-editors** (skip **GET /core/groups/** when set). +noble_authentik_group_pk_noble_admins: "" +noble_authentik_group_pk_noble_editors: "" noble_authentik_helm_wait_timeout: 25m 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 index ffaa034550b394c598fa22cb83196df78bc89ff0..453ff00a4c8d898e9bdaa821d369b1ce0b13ba27 100644 GIT binary patch delta 10918 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 LzSlrbU$FfzpwJru diff --git a/ansible/roles/noble_authentik/files/__pycache__/resolve_noble_group_pks.cpython-314.pyc b/ansible/roles/noble_authentik/files/__pycache__/resolve_noble_group_pks.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..f79632f9193eeee9af4160f9a80ec65cb94ec059 GIT binary patch literal 881 zcmYLHUuzRV5TCufTvJV&__tMyO|=gu(w+|meee&0)`}S7N#jfGaZR?(wU@hNcdufh zkOz?#1@*~qAo%7-@FP?Ng{7e2Q{R+OeCUg_*IXRfncvRL4l}>m%Om+CfYNp|s|N(& zm%5~)^+E3nf-WqAP42@y5o7}FjL>Xt6l{GI{G!mG>XR_rPsgZn5z6MCx|fN}++$%J zHL*IVqeNBK@$|+~b)gQ6+O-z|E%c4UBhn@|u{Dce6@5?TN|R9$v!MD>6*QMa)^P<_ z)hD<5$5IqB50&UL(Lcj2cR}IfCEqm8<$}rNmu)CpP^;P8>+KJ8b?!9C$(IP z|J&LADpETE59ZZi=W(S-yCHKPcX*6c#>nDaS(8TKcBss@=&~F8f>+B!(sZh~AKrb; z)O2zvTw0|~!DW7h3MXVvlToBhW+LVVLh4*FsYPpLosFn;vLcDY)yq<;#8ltX=(t|M z#tc6`JlQt3$Zj_Oy7_kU z{nWduSKhbmST6&|Mw#NETGlpl$yTS`R>uqYM*i?dTb>9R$5BrWKEq5iWOSwzwy4jO zsaO$9BrP$uhNR+Qv)SvI7W?!jJBtCuCO?Pjw@wJz&jPvhbGXtq_f7RZazHXK&E)Yv DI}gBA literal 0 HcmV?d00001 diff --git a/ansible/roles/noble_authentik/files/__pycache__/resolve_oauth_scope_mapping_pks.cpython-314.pyc b/ansible/roles/noble_authentik/files/__pycache__/resolve_oauth_scope_mapping_pks.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..dfc7636acc0e2837328190bd1cd19840e945568c GIT binary patch literal 1377 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 literal 0 HcmV?d00001 diff --git a/ansible/roles/noble_authentik/files/__pycache__/worker_ensure_authentik_admin_access.cpython-314.pyc b/ansible/roles/noble_authentik/files/__pycache__/worker_ensure_authentik_admin_access.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..d41a06b8f56ed072c6ec9439347b453ead717e09 GIT binary patch literal 3791 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 QUx?BlPdyWexR0IkUuJRppa1{> literal 0 HcmV?d00001 diff --git a/ansible/roles/noble_authentik/files/__pycache__/worker_upsert_oauth_oidc.cpython-314.pyc b/ansible/roles/noble_authentik/files/__pycache__/worker_upsert_oauth_oidc.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..b79b9801465ed89b9097548ccac517deec52c348 GIT binary patch literal 6241 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 str: def signing_key_pk(base: str): - 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"): + raw = (os.environ.get("AUTHENTIK_OAUTH_SIGNING_KEY_PK") or "").strip() + if raw: + return primary_key(raw) + code, payload = req_any("GET", f"{base}/crypto/certificatekeypairs/?ordering=pk&page_size=1") + if code != 200: + raise RuntimeError( + f"GET certificatekeypairs -> HTTP {code}: {payload!r}. " + "Authentik 2026+ RBAC: set AUTHENTIK_OAUTH_SIGNING_KEY_PK (CertificateKeyPair UUID) or let " + "Ansible resolve it from the worker DB (noble_authentik_oauth_signing_key_pk)." + ) + if not isinstance(payload, dict) or not payload.get("results"): raise RuntimeError("no signing certificate keypairs returned") - return payload["results"][0]["pk"] + return primary_key(payload["results"][0]["pk"]) def _collect_scope_mappings(base: str) -> list: @@ -187,7 +204,7 @@ def _collect_scope_mappings(base: str) -> list: return collect(base, path) except RuntimeError as exc: err = str(exc) - if " 404" in err or "404:" in err: + if " 404" in err or "404:" in err or " 403" in err or "403:" in err: last_err = exc continue raise @@ -195,6 +212,17 @@ def _collect_scope_mappings(base: str) -> list: def scope_mapping_pks(base: str) -> list[int | str]: + raw = (os.environ.get("AUTHENTIK_OAUTH_SCOPE_MAPPING_PKS") or "").strip() + if raw: + parts = [p.strip() for p in raw.split(",") if p.strip()] + uniq = dedupe_pks_preserve_order([primary_key(p) for p in parts]) + if len(uniq) < 3: + raise RuntimeError( + f"AUTHENTIK_OAUTH_SCOPE_MAPPING_PKS must list at least 3 UUIDs (got {len(uniq)}); " + "use comma-separated ScopeMapping primary keys in order " + "openid,email,profile,offline_access,groups from the worker helper or Admin UI." + ) + return uniq mappings = _collect_scope_mappings(base) want_scopes = {"openid", "email", "profile", "offline_access", "groups"} pks: list[int | str] = [] @@ -225,7 +253,11 @@ def scope_mapping_pks(base: str) -> list[int | str]: hints = ", ".join( f"GET {base}{p.split('?', 1)[0]}/" for p in _OAUTH_SCOPE_MAPPING_LIST_PATHS ) - raise RuntimeError(f"too few oauth scope mappings resolved ({uniq}); inspect one of: {hints}") + raise RuntimeError( + f"too few oauth scope mappings resolved ({uniq}); inspect one of: {hints}. " + "Authentik 2026+ RBAC: set AUTHENTIK_OAUTH_SCOPE_MAPPING_PKS (comma-separated UUIDs) or " + "let Ansible resolve them from the worker DB." + ) return uniq @@ -234,9 +266,13 @@ def find_oauth2_provider_by_client_id(base: str, client_id: str) -> dict | None: url = f"{base}/providers/oauth2/?page_size=100" origin = base.split("/api/v3", 1)[0] if "/api/v3" in base else base while url: - code, payload = req("GET", url) + code, payload = req_any("GET", url) if code != 200 or not isinstance(payload, dict): - raise RuntimeError(f"provider list failed: GET {url} -> {code}") + raise RuntimeError( + f"provider list failed: GET {url} -> {code}: {payload!r}. " + "Authentik 2026+ RBAC: the token may not list OAuth2 providers; use an **akadmin** API token " + "with provider permissions, or grant **view_oauth2provider** (see Admin → Roles)." + ) for row in payload.get("results") or []: if row.get("client_id") == client_id: return row @@ -319,22 +355,52 @@ def upsert_application(base: str, slug: str, name: str, provider_pk: int | str) raise RuntimeError(f"GET application {slug} -> {code}: {payload!r}") +def _group_pk_from_env(name: str) -> str | None: + env_keys = { + "noble-admins": "AUTHENTIK_NOBLE_ADMINS_GROUP_PK", + "noble-editors": "AUTHENTIK_NOBLE_EDITORS_GROUP_PK", + } + key = env_keys.get(name) + if not key: + return None + raw = (os.environ.get(key) or "").strip() + return raw or None + + def ensure_group(base: str, name: str) -> int | str: - code, payload = req("GET", f"{base}/core/groups/?name={urllib.parse.quote(name)}") - if code != 200 or not isinstance(payload, dict): + env_pk = _group_pk_from_env(name) + if env_pk: + return primary_key(env_pk) + code, payload = req_any("GET", f"{base}/core/groups/?name={urllib.parse.quote(name)}") + if code != 200: + raise RuntimeError( + f"GET groups name={name!r} -> HTTP {code}: {payload!r}. " + "Authentik 2026+ RBAC: set AUTHENTIK_NOBLE_ADMINS_GROUP_PK and " + "AUTHENTIK_NOBLE_EDITORS_GROUP_PK (Group UUIDs), or let Ansible resolve/create them via the worker DB." + ) + if not isinstance(payload, dict): raise RuntimeError("group list failed") results = payload.get("results") or [] if results: return primary_key(results[0]["pk"]) - code2, created = req("POST", f"{base}/core/groups/", {"name": name}) + code2, created = req_any("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}") + raise RuntimeError( + f"POST group {name} -> {code2}: {created!r}. " + "If the API user cannot create groups, run the noble_authentik worker helper or create the group in Admin." + ) return primary_key(created["pk"]) def add_user_to_groups(base: str, email: str, group_pks: list[int | str]) -> None: - code, payload = req("GET", f"{base}/core/users/?email={urllib.parse.quote(email)}") - if code != 200 or not isinstance(payload, dict): + code, payload = req_any("GET", f"{base}/core/users/?email={urllib.parse.quote(email)}") + if code != 200: + raise RuntimeError( + f"GET users email={email!r} -> HTTP {code}: {payload!r}. " + "Authentik 2026+ RBAC: the token may not list users; add the bootstrap user to **noble-admins** / " + "**noble-editors** in Admin, or use a token with **view_user** permission." + ) + if not isinstance(payload, dict): raise RuntimeError("user list failed") results = payload.get("results") or [] if not results: @@ -350,9 +416,13 @@ def add_user_to_groups(base: str, email: str, group_pks: list[int | str]) -> Non merged = dedupe_pks_preserve_order([*existing, *group_pks]) if {str(x) for x in merged} == {str(x) for x in existing}: return - code2, _ = req("PATCH", f"{base}/core/users/{upk}/", {"groups": merged}) + code2, err = req_any("PATCH", f"{base}/core/users/{upk}/", {"groups": merged}) if code2 not in (200, 204): - raise RuntimeError(f"PATCH user groups -> {code2}") + raise RuntimeError(f"PATCH user groups -> {code2}: {err!r}") + + +def _truthy_env(name: str) -> bool: + return (os.environ.get(name) or "").strip().lower() in ("1", "true", "yes") def main() -> int: @@ -360,62 +430,83 @@ def main() -> int: 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) + skip_oidc_rest = _truthy_env("AUTHENTIK_SKIP_OIDC_REST") + skip_user_group_rest = _truthy_env("AUTHENTIK_SKIP_USER_GROUP_REST") + if not base or not tok: + print("AUTHENTIK_API_BASE and AUTHENTIK_TOKEN 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) + if not skip_oidc_rest and not cfg_path: + print("CLIENT_JSON required unless AUTHENTIK_SKIP_OIDC_REST is set", file=sys.stderr) return 2 - auth_pk = (os.environ.get("AUTHENTIK_OAUTH_AUTHORIZATION_FLOW_PK") or "").strip() - inv_pk = (os.environ.get("AUTHENTIK_OAUTH_INVALIDATION_FLOW_PK") or "").strip() - if auth_pk and inv_pk: - auth_flow, invalidation_flow = auth_pk, inv_pk - elif auth_pk or inv_pk: - print( - "Set both AUTHENTIK_OAUTH_AUTHORIZATION_FLOW_PK and AUTHENTIK_OAUTH_INVALIDATION_FLOW_PK " - "or neither", - file=sys.stderr, - ) - return 2 - else: - 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"], - ) + clients: dict = {} + if not skip_oidc_rest: + 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 - signing_key = signing_key_pk(base) - pmap = scope_mapping_pks(base) + auth_flow: str | None = None + invalidation_flow: str | None = None + signing_key = None + pmap: list[int | str] = [] + + if not skip_oidc_rest: + auth_pk = (os.environ.get("AUTHENTIK_OAUTH_AUTHORIZATION_FLOW_PK") or "").strip() + inv_pk = (os.environ.get("AUTHENTIK_OAUTH_INVALIDATION_FLOW_PK") or "").strip() + if auth_pk and inv_pk: + auth_flow, invalidation_flow = auth_pk, inv_pk + elif auth_pk or inv_pk: + print( + "Set both AUTHENTIK_OAUTH_AUTHORIZATION_FLOW_PK and AUTHENTIK_OAUTH_INVALIDATION_FLOW_PK " + "or neither", + file=sys.stderr, + ) + return 2 + else: + 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") try: - 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 not skip_oidc_rest: + assert auth_flow is not None and invalidation_flow is not None + 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: + if email and not skip_user_group_rest: add_user_to_groups(base, email, [admins_pk, editors_pk]) - print("authentik: providers + applications configured", flush=True) + if skip_oidc_rest and skip_user_group_rest: + print("authentik: noble groups verified; OAuth2 apps + bootstrap group membership via worker", flush=True) + elif skip_oidc_rest: + print("authentik: groups verified; OAuth2 apps via worker (user membership via API)", flush=True) + else: + print("authentik: providers + applications configured", flush=True) return 0 except RuntimeError as exc: print(str(exc), file=sys.stderr) diff --git a/ansible/roles/noble_authentik/files/resolve_noble_group_pks.py b/ansible/roles/noble_authentik/files/resolve_noble_group_pks.py new file mode 100644 index 0000000..1d87878 --- /dev/null +++ b/ansible/roles/noble_authentik/files/resolve_noble_group_pks.py @@ -0,0 +1,15 @@ +# Run inside the Authentik worker image (see noble_authentik Ansible role). +# Ensures **noble-admins** and **noble-editors** exist, then prints their UUIDs (one per line). +# Order: noble-admins, noble-editors — matches **configure_authentik.py** usage. +from authentik.core.models import Group + +_NAMES = ("noble-admins", "noble-editors") + + +def main() -> None: + for name in _NAMES: + g, _ = Group.objects.get_or_create(name=name, defaults={"is_superuser": False}) + print(str(g.pk)) + + +main() diff --git a/ansible/roles/noble_authentik/files/resolve_oauth_flow_pks.py b/ansible/roles/noble_authentik/files/resolve_oauth_flow_pks.py new file mode 100644 index 0000000..2eef434 --- /dev/null +++ b/ansible/roles/noble_authentik/files/resolve_oauth_flow_pks.py @@ -0,0 +1,23 @@ +# Run inside the Authentik worker image: `ak shell -c "exec(open('/tmp/...').read())"`. +# Prints two lines: authorization flow UUID, invalidation flow UUID (for configure_authentik.py). +from authentik.flows.models import Flow + + +def _pk(slug: str) -> str: + return str(Flow.objects.get(slug=slug).pk) + + +def main() -> None: + auth = _pk("default-provider-authorization-implicit-consent") + inv_slug = None + for candidate in ("default-invalidation-flow", "default-provider-invalidation-flow"): + if Flow.objects.filter(slug=candidate).exists(): + inv_slug = candidate + break + if not inv_slug: + raise SystemExit("no default invalidation flow (expected one of: default-invalidation-flow, default-provider-invalidation-flow)") + print(auth) + print(_pk(inv_slug)) + + +main() diff --git a/ansible/roles/noble_authentik/files/resolve_oauth_scope_mapping_pks.py b/ansible/roles/noble_authentik/files/resolve_oauth_scope_mapping_pks.py new file mode 100644 index 0000000..edf0b53 --- /dev/null +++ b/ansible/roles/noble_authentik/files/resolve_oauth_scope_mapping_pks.py @@ -0,0 +1,22 @@ +# Run inside the Authentik worker image (see noble_authentik Ansible role). +# Prints one UUID per line for **ScopeMapping** rows used by configure_authentik.py. +# +# Authentik 2026.x default blueprint puts **groups** inside the **profile** mapping expression; +# there is often no separate scope_name **groups** — so **groups** is optional here. +from authentik.providers.oauth2.models import ScopeMapping + +_REQUIRED = ("openid", "email", "profile", "offline_access") + + +def main() -> None: + for scope in _REQUIRED: + m = ScopeMapping.objects.filter(scope_name=scope).order_by("name").first() + if not m: + raise SystemExit(f"no ScopeMapping for scope_name={scope!r}") + print(str(m.pk)) + groups = ScopeMapping.objects.filter(scope_name="groups").order_by("name").first() + if groups: + print(str(groups.pk)) + + +main() diff --git a/ansible/roles/noble_authentik/files/resolve_oauth_signing_key_pk.py b/ansible/roles/noble_authentik/files/resolve_oauth_signing_key_pk.py new file mode 100644 index 0000000..9fd5d5e --- /dev/null +++ b/ansible/roles/noble_authentik/files/resolve_oauth_signing_key_pk.py @@ -0,0 +1,13 @@ +# Run inside the Authentik worker image (see noble_authentik Ansible role). +# Prints one line: default **CertificateKeyPair** UUID (same ordering as REST: by pk). +from authentik.crypto.models import CertificateKeyPair + + +def main() -> None: + ckp = CertificateKeyPair.objects.order_by("kp_uuid").first() + if not ckp: + raise SystemExit("no CertificateKeyPair in database") + print(str(ckp.pk)) + + +main() diff --git a/ansible/roles/noble_authentik/files/worker_add_bootstrap_user_groups.py b/ansible/roles/noble_authentik/files/worker_add_bootstrap_user_groups.py new file mode 100644 index 0000000..ab40040 --- /dev/null +++ b/ansible/roles/noble_authentik/files/worker_add_bootstrap_user_groups.py @@ -0,0 +1,55 @@ +# Run inside the Authentik worker image (**ak shell**; see noble_authentik Ansible role). +# Adds the bootstrap user to the given **Group** rows by primary key (ORM; bypasses **GET /core/users/** RBAC). +# +# Env: +# AUTHENTIK_WORKER_USER_GROUPS_SPEC absolute path inside the container to JSON: +# {"email": "", "group_pks": ["", ...]} +from __future__ import annotations + +import json +import os +import sys +from typing import Any + +from django.db import transaction + +from authentik.core.models import Group, User + + +def _load_spec() -> dict[str, Any]: + path = (os.environ.get("AUTHENTIK_WORKER_USER_GROUPS_SPEC") or "").strip() + if not path: + print("AUTHENTIK_WORKER_USER_GROUPS_SPEC must point to the JSON spec file", file=sys.stderr) + raise SystemExit(2) + with open(path, encoding="utf-8") as f: + spec = json.load(f) + if not isinstance(spec, dict): + raise SystemExit("spec root must be an object") + return spec + + +def main() -> None: + spec = _load_spec() + email = (spec.get("email") or "").strip() + pks = spec.get("group_pks") + if not email: + print("spec.email is required", file=sys.stderr) + raise SystemExit(2) + if not isinstance(pks, list) or not pks: + print("spec.group_pks must be a non-empty list of UUIDs", file=sys.stderr) + raise SystemExit(2) + + user = User.objects.filter(email__iexact=email).first() + if user is None: + print(f"WARN: no user with email {email!r}; skip group membership (ORM)", file=sys.stderr) + return + + groups = [Group.objects.get(pk=pk) for pk in pks] + with transaction.atomic(): + user.groups.add(*groups) + + print("worker: bootstrap user group membership updated", flush=True) + + +if __name__ == "__main__": + main() diff --git a/ansible/roles/noble_authentik/files/worker_ensure_authentik_admin_access.py b/ansible/roles/noble_authentik/files/worker_ensure_authentik_admin_access.py new file mode 100644 index 0000000..4c6b229 --- /dev/null +++ b/ansible/roles/noble_authentik/files/worker_ensure_authentik_admin_access.py @@ -0,0 +1,73 @@ +# Run inside the Authentik worker image (**ak shell**; see noble_authentik Ansible role). +# Restores **Admin interface** access for the bootstrap account: ensures the **authentik Admins** group +# exists with **is_superuser** set and adds **akadmin** (or the bootstrap user by email) to that group. +# Authentik 2026+ derives **canAccessAdmin** from **User.is_superuser**, which requires membership in a +# group with **is_superuser=True** (see role README). +# +# Env: +# AUTHENTIK_WORKER_ADMIN_ACCESS_SPEC path inside the container to JSON: {"bootstrap_email": "..."} +# (email may be empty — then only **username=akadmin** is considered) +from __future__ import annotations + +import json +import os +import sys +from typing import Any + +from django.db import transaction + +from authentik.core.models import Group, User + +_ADMINS = "authentik Admins" + + +def _load_spec() -> dict[str, Any]: + path = (os.environ.get("AUTHENTIK_WORKER_ADMIN_ACCESS_SPEC") or "").strip() + if not path: + print("AUTHENTIK_WORKER_ADMIN_ACCESS_SPEC must point to the JSON spec file", file=sys.stderr) + raise SystemExit(2) + with open(path, encoding="utf-8") as f: + spec = json.load(f) + if not isinstance(spec, dict): + raise SystemExit("spec root must be an object") + return spec + + +def _resolve_user(spec: dict[str, Any]) -> User | None: + u = User.objects.filter(username="akadmin").first() + if u is not None: + return u + email = (spec.get("bootstrap_email") or "").strip() + if not email: + return None + return User.objects.filter(email__iexact=email).first() + + +def main() -> None: + spec = _load_spec() + user = _resolve_user(spec) + if user is None: + print( + "WARN: no user with username=akadmin and no bootstrap_email match; skip authentik Admins repair", + file=sys.stderr, + ) + return + + with transaction.atomic(): + adm, _ = Group.objects.get_or_create( + name=_ADMINS, + defaults={"is_superuser": True}, + ) + if not adm.is_superuser: + adm.is_superuser = True + adm.save(update_fields=["is_superuser"]) + user.groups.add(adm) + + print( + f"worker: {user.username!r} is in {_ADMINS!r} (superuser group); log out of Authentik and sign in again", + flush=True, + ) + + +if __name__ == "__main__": + main() diff --git a/ansible/roles/noble_authentik/files/worker_upsert_oauth_oidc.py b/ansible/roles/noble_authentik/files/worker_upsert_oauth_oidc.py new file mode 100644 index 0000000..0b0c0c8 --- /dev/null +++ b/ansible/roles/noble_authentik/files/worker_upsert_oauth_oidc.py @@ -0,0 +1,110 @@ +# Run inside the Authentik worker image (**ak shell**; see noble_authentik Ansible role). +# Upserts **OAuth2Provider** + **Application** from a JSON spec (Django ORM; bypasses OAuth2 provider REST RBAC). +# +# Env: +# AUTHENTIK_WORKER_OIDC_SPEC absolute path inside the container to the JSON spec file +# +# Spec JSON: +# { +# "authorization_flow": "", +# "invalidation_flow": "", +# "signing_key": "", +# "property_mappings": ["", ...], +# "clients": { "": {"name","client_id","client_secret","redirect_uri"}, ... } +# } +from __future__ import annotations + +import json +import os +import sys +from typing import Any + +from django.db import transaction + +from authentik.core.models import Application, PropertyMapping +from authentik.flows.models import Flow +from authentik.crypto.models import CertificateKeyPair +from authentik.providers.oauth2.models import ( + ClientTypes, + OAuth2Provider, + RedirectURI, + RedirectURIMatchingMode, +) + + +def _load_spec() -> dict[str, Any]: + path = (os.environ.get("AUTHENTIK_WORKER_OIDC_SPEC") or "").strip() + if not path: + print("AUTHENTIK_WORKER_OIDC_SPEC must point to the JSON spec file", file=sys.stderr) + raise SystemExit(2) + with open(path, encoding="utf-8") as f: + spec = json.load(f) + if not isinstance(spec, dict): + raise SystemExit("spec root must be an object") + return spec + + +def main() -> None: + spec = _load_spec() + auth_id = spec.get("authorization_flow") + inv_id = spec.get("invalidation_flow") + sk_id = spec.get("signing_key") + pmap_ids = spec.get("property_mappings") + clients = spec.get("clients") + if not auth_id or not inv_id or not sk_id: + print("spec requires authorization_flow, invalidation_flow, signing_key", file=sys.stderr) + raise SystemExit(2) + if not isinstance(pmap_ids, list) or len(pmap_ids) < 1: + print("spec.property_mappings must be a non-empty list of UUIDs", file=sys.stderr) + raise SystemExit(2) + if not isinstance(clients, dict) or not clients: + print("spec.clients must be a non-empty object", file=sys.stderr) + raise SystemExit(2) + + auth_flow = Flow.objects.get(pk=auth_id) + inv_flow = Flow.objects.get(pk=inv_id) + signing_key = CertificateKeyPair.objects.get(pk=sk_id) + mappings = list(PropertyMapping.objects.filter(pk__in=pmap_ids)) + if len(mappings) != len(pmap_ids): + found = {str(m.pk) for m in mappings} + missing = [p for p in pmap_ids if str(p) not in found] + print(f"property_mappings: unknown pk(s): {missing}", file=sys.stderr) + raise SystemExit(1) + + with transaction.atomic(): + for slug, meta in clients.items(): + if not isinstance(meta, dict): + print(f"clients[{slug!r}] must be an object", file=sys.stderr) + raise SystemExit(2) + name = (meta.get("name") or slug).strip() + cid = meta["client_id"] + csec = meta["client_secret"] + redir = meta["redirect_uri"].strip() + redirect = [RedirectURI(matching_mode=RedirectURIMatchingMode.STRICT, url=redir)] + + prov = OAuth2Provider.objects.filter(client_id=cid).first() + if prov is None: + prov = OAuth2Provider(client_id=cid) + prov.name = name + prov.client_secret = csec + prov.client_type = ClientTypes.CONFIDENTIAL + prov.authorization_flow = auth_flow + prov.invalidation_flow = inv_flow + prov.signing_key = signing_key + prov.redirect_uris = redirect + prov.save() + prov.property_mappings.set(mappings) + + app = Application.objects.filter(slug=slug).first() + if app is None: + Application.objects.create(name=name, slug=slug, provider=prov) + else: + app.name = name + app.provider = prov + app.save() + + print("worker: OAuth2 providers + applications upserted", flush=True) + + +if __name__ == "__main__": + main() diff --git a/ansible/roles/noble_authentik/tasks/main.yml b/ansible/roles/noble_authentik/tasks/main.yml index 13fb0e3..db98aef 100644 --- a/ansible/roles/noble_authentik/tasks/main.yml +++ b/ansible/roles/noble_authentik/tasks/main.yml @@ -129,6 +129,249 @@ mode: "0600" no_log: true + # Authentik 2026+ RBAC: bootstrap tokens often cannot **GET /flows/instances/** (403). Resolve UUIDs + # from the worker DB via **ak shell** when inventory PKs are unset (same slugs as configure_authentik.py). + - name: Resolve OAuth provider flow UUIDs from authentik-worker (DB; bypasses flows API RBAC) + ansible.builtin.shell: | + set -euo pipefail + NS="{{ noble_authentik_namespace }}" + POD="$(kubectl get pods -n "$NS" \ + -l "app.kubernetes.io/name=authentik,app.kubernetes.io/component=worker" \ + -o jsonpath='{.items[0].metadata.name}')" + REM=/tmp/ansible_resolve_oauth_flow_pks.py + kubectl cp "{{ role_path }}/files/resolve_oauth_flow_pks.py" "${NS}/${POD}:${REM}" + kubectl exec -n "$NS" "$POD" -- ak shell -c "exec(compile(open('${REM}').read(), 'ansible_resolve_oauth_flow_pks.py', 'exec'))" + kubectl exec -n "$NS" "$POD" -- rm -f "$REM" || true + environment: + KUBECONFIG: "{{ noble_kubeconfig }}" + register: noble_authentik_oauth_flow_pk_resolve + changed_when: false + when: + - noble_authentik_configure_idp | default(true) | bool + - (noble_authentik_oauth_authorization_flow_pk | default('') | trim | length) == 0 + - (noble_authentik_oauth_invalidation_flow_pk | default('') | trim | length) == 0 + + - name: Apply OAuth flow PKs from worker resolution for configure_authentik.py + ansible.builtin.set_fact: + noble_authentik_oauth_authorization_flow_pk: "{{ (noble_authentik_oauth_flow_pk_resolve.stdout_lines | select('match', '^[0-9a-fA-F-]{36}$') | list)[0] }}" + noble_authentik_oauth_invalidation_flow_pk: "{{ (noble_authentik_oauth_flow_pk_resolve.stdout_lines | select('match', '^[0-9a-fA-F-]{36}$') | list)[1] }}" + when: + - noble_authentik_oauth_flow_pk_resolve is defined + - not (noble_authentik_oauth_flow_pk_resolve.skipped | default(false)) + - (noble_authentik_oauth_flow_pk_resolve.rc | default(-1)) == 0 + - (noble_authentik_oauth_flow_pk_resolve.stdout_lines | default([]) | select('match', '^[0-9a-fA-F-]{36}$') | list | length) >= 2 + + # Bootstrap tokens often cannot list **/crypto/certificatekeypairs/** (403). + - name: Resolve OAuth signing key UUID from authentik-worker (DB; bypasses crypto API RBAC) + ansible.builtin.shell: | + set -euo pipefail + NS="{{ noble_authentik_namespace }}" + POD="$(kubectl get pods -n "$NS" \ + -l "app.kubernetes.io/name=authentik,app.kubernetes.io/component=worker" \ + -o jsonpath='{.items[0].metadata.name}')" + REM=/tmp/ansible_resolve_oauth_signing_key_pk.py + kubectl cp "{{ role_path }}/files/resolve_oauth_signing_key_pk.py" "${NS}/${POD}:${REM}" + kubectl exec -n "$NS" "$POD" -- ak shell -c "exec(compile(open('${REM}').read(), 'ansible_resolve_oauth_signing_key_pk.py', 'exec'))" + kubectl exec -n "$NS" "$POD" -- rm -f "$REM" || true + environment: + KUBECONFIG: "{{ noble_kubeconfig }}" + register: noble_authentik_oauth_signing_key_resolve + changed_when: false + when: + - noble_authentik_configure_idp | default(true) | bool + - (noble_authentik_oauth_signing_key_pk | default('') | trim | length) == 0 + + - name: Apply OAuth signing key PK from worker resolution for configure_authentik.py + ansible.builtin.set_fact: + noble_authentik_oauth_signing_key_pk: "{{ (noble_authentik_oauth_signing_key_resolve.stdout_lines | select('match', '^[0-9a-fA-F-]{36}$') | list)[0] }}" + when: + - noble_authentik_oauth_signing_key_resolve is defined + - not (noble_authentik_oauth_signing_key_resolve.skipped | default(false)) + - (noble_authentik_oauth_signing_key_resolve.rc | default(-1)) == 0 + - (noble_authentik_oauth_signing_key_resolve.stdout_lines | default([]) | select('match', '^[0-9a-fA-F-]{36}$') | list | length) >= 1 + + # Bootstrap tokens often cannot list **/propertymappings/provider/scope/** (403). + - name: Resolve OAuth scope mapping UUIDs from authentik-worker (DB; bypasses propertymappings API RBAC) + ansible.builtin.shell: | + set -euo pipefail + NS="{{ noble_authentik_namespace }}" + POD="$(kubectl get pods -n "$NS" \ + -l "app.kubernetes.io/name=authentik,app.kubernetes.io/component=worker" \ + -o jsonpath='{.items[0].metadata.name}')" + REM=/tmp/ansible_resolve_oauth_scope_mapping_pks.py + kubectl cp "{{ role_path }}/files/resolve_oauth_scope_mapping_pks.py" "${NS}/${POD}:${REM}" + kubectl exec -n "$NS" "$POD" -- ak shell -c "exec(compile(open('${REM}').read(), 'ansible_resolve_oauth_scope_mapping_pks.py', 'exec'))" + kubectl exec -n "$NS" "$POD" -- rm -f "$REM" || true + environment: + KUBECONFIG: "{{ noble_kubeconfig }}" + register: noble_authentik_oauth_scope_mapping_resolve + changed_when: false + when: + - noble_authentik_configure_idp | default(true) | bool + - (noble_authentik_oauth_scope_mapping_pks | default('') | trim | length) == 0 + + - name: Apply OAuth scope mapping PKs from worker resolution for configure_authentik.py + ansible.builtin.set_fact: + noble_authentik_oauth_scope_mapping_pks: "{{ (noble_authentik_oauth_scope_mapping_resolve.stdout_lines | default([]) | select('match', '^[0-9a-fA-F-]{36}$') | list) | join(',') }}" + when: + - noble_authentik_oauth_scope_mapping_resolve is defined + - not (noble_authentik_oauth_scope_mapping_resolve.skipped | default(false)) + - (noble_authentik_oauth_scope_mapping_resolve.rc | default(-1)) == 0 + - (noble_authentik_oauth_scope_mapping_resolve.stdout_lines | default([]) | select('match', '^[0-9a-fA-F-]{36}$') | list | length) >= 4 + + # Bootstrap tokens often cannot **GET /core/groups/** (403). Worker **get_or_create** ensures groups exist. + - name: Resolve noble-admins / noble-editors group UUIDs from authentik-worker (DB; bypasses groups API RBAC) + ansible.builtin.shell: | + set -euo pipefail + NS="{{ noble_authentik_namespace }}" + POD="$(kubectl get pods -n "$NS" \ + -l "app.kubernetes.io/name=authentik,app.kubernetes.io/component=worker" \ + -o jsonpath='{.items[0].metadata.name}')" + REM=/tmp/ansible_resolve_noble_group_pks.py + kubectl cp "{{ role_path }}/files/resolve_noble_group_pks.py" "${NS}/${POD}:${REM}" + kubectl exec -n "$NS" "$POD" -- ak shell -c "exec(compile(open('${REM}').read(), 'ansible_resolve_noble_group_pks.py', 'exec'))" + kubectl exec -n "$NS" "$POD" -- rm -f "$REM" || true + environment: + KUBECONFIG: "{{ noble_kubeconfig }}" + register: noble_authentik_noble_group_resolve + changed_when: false + when: + - noble_authentik_configure_idp | default(true) | bool + - (noble_authentik_group_pk_noble_admins | default('') | trim | length) == 0 + - (noble_authentik_group_pk_noble_editors | default('') | trim | length) == 0 + + - name: Apply noble group PKs from worker resolution for configure_authentik.py + ansible.builtin.set_fact: + noble_authentik_group_pk_noble_admins: "{{ (noble_authentik_noble_group_resolve.stdout_lines | default([]) | select('match', '^[0-9a-fA-F-]{36}$') | list)[0] }}" + noble_authentik_group_pk_noble_editors: "{{ (noble_authentik_noble_group_resolve.stdout_lines | default([]) | select('match', '^[0-9a-fA-F-]{36}$') | list)[1] }}" + when: + - noble_authentik_noble_group_resolve is defined + - not (noble_authentik_noble_group_resolve.skipped | default(false)) + - (noble_authentik_noble_group_resolve.rc | default(-1)) == 0 + - (noble_authentik_noble_group_resolve.stdout_lines | default([]) | select('match', '^[0-9a-fA-F-]{36}$') | list | length) >= 2 + + - name: Render Authentik worker admin-access spec (JSON for ak shell) + ansible.builtin.template: + src: authentik-worker-admin-access-spec.json.j2 + dest: "{{ noble_repo_root }}/ansible/.ansible-tmp/authentik-worker-admin-access-spec.json" + mode: "0600" + no_log: true + when: + - noble_authentik_configure_idp | default(true) | bool + - noble_authentik_ensure_admin_ui_access | default(true) | bool + + - name: Ensure authentik Admins + superuser group flag (worker ORM; restores admin UI access) + ansible.builtin.shell: | + set -euo pipefail + NS="{{ noble_authentik_namespace }}" + POD="$(kubectl get pods -n "$NS" \ + -l "app.kubernetes.io/name=authentik,app.kubernetes.io/component=worker" \ + -o jsonpath='{.items[0].metadata.name}')" + SPEC=/tmp/ansible_authentik_worker_admin_access_spec.json + REM=/tmp/ansible_worker_ensure_authentik_admin_access.py + kubectl cp "{{ noble_repo_root }}/ansible/.ansible-tmp/authentik-worker-admin-access-spec.json" "${NS}/${POD}:${SPEC}" + kubectl cp "{{ role_path }}/files/worker_ensure_authentik_admin_access.py" "${NS}/${POD}:${REM}" + kubectl exec -n "$NS" "$POD" -- env AUTHENTIK_WORKER_ADMIN_ACCESS_SPEC="${SPEC}" ak shell -c "exec(compile(open('${REM}').read(), 'ansible_worker_ensure_authentik_admin_access.py', 'exec'))" + kubectl exec -n "$NS" "$POD" -- rm -f "$SPEC" "$REM" || true + environment: + KUBECONFIG: "{{ noble_kubeconfig }}" + register: noble_authentik_worker_admin_access + changed_when: true + when: + - noble_authentik_configure_idp | default(true) | bool + - noble_authentik_ensure_admin_ui_access | default(true) | bool + + - name: Require OAuth PKs for worker OIDC upsert (ORM path) + ansible.builtin.assert: + that: + - (noble_authentik_oauth_authorization_flow_pk | default('') | trim | length) > 0 + - (noble_authentik_oauth_invalidation_flow_pk | default('') | trim | length) > 0 + - (noble_authentik_oauth_signing_key_pk | default('') | trim | length) > 0 + - (noble_authentik_oauth_scope_mapping_pks | default('') | trim | length) > 0 + fail_msg: >- + Worker OIDC provisioning needs flow UUIDs, signing key UUID, and comma-separated scope-mapping UUIDs. + Ensure worker resolution tasks ran, or set noble_authentik_oauth_* inventory vars (see role README). + when: + - noble_authentik_configure_idp | default(true) | bool + - (noble_authentik_oidc_provision_via | default('worker') | lower) == 'worker' + + - name: Render Authentik worker OIDC spec (JSON for ak shell upsert) + ansible.builtin.template: + src: authentik-worker-oidc-spec.json.j2 + dest: "{{ noble_repo_root }}/ansible/.ansible-tmp/authentik-worker-oidc-spec.json" + mode: "0600" + no_log: true + when: + - noble_authentik_configure_idp | default(true) | bool + - (noble_authentik_oidc_provision_via | default('worker') | lower) == 'worker' + + - name: Upsert OAuth2 providers + applications in authentik-worker (ORM; bypasses provider REST RBAC) + ansible.builtin.shell: | + set -euo pipefail + NS="{{ noble_authentik_namespace }}" + POD="$(kubectl get pods -n "$NS" \ + -l "app.kubernetes.io/name=authentik,app.kubernetes.io/component=worker" \ + -o jsonpath='{.items[0].metadata.name}')" + SPEC=/tmp/ansible_authentik_worker_oidc_spec.json + REM=/tmp/ansible_worker_upsert_oauth_oidc.py + kubectl cp "{{ noble_repo_root }}/ansible/.ansible-tmp/authentik-worker-oidc-spec.json" "${NS}/${POD}:${SPEC}" + kubectl cp "{{ role_path }}/files/worker_upsert_oauth_oidc.py" "${NS}/${POD}:${REM}" + kubectl exec -n "$NS" "$POD" -- env AUTHENTIK_WORKER_OIDC_SPEC="${SPEC}" ak shell -c "exec(compile(open('${REM}').read(), 'ansible_worker_upsert_oauth_oidc.py', 'exec'))" + kubectl exec -n "$NS" "$POD" -- rm -f "$SPEC" "$REM" || true + environment: + KUBECONFIG: "{{ noble_kubeconfig }}" + register: noble_authentik_worker_oidc_upsert + changed_when: true + when: + - noble_authentik_configure_idp | default(true) | bool + - (noble_authentik_oidc_provision_via | default('worker') | lower) == 'worker' + + - name: Require noble group PKs for worker bootstrap group membership + ansible.builtin.assert: + that: + - (noble_authentik_group_pk_noble_admins | default('') | trim | length) > 0 + - (noble_authentik_group_pk_noble_editors | default('') | trim | length) > 0 + fail_msg: >- + Worker bootstrap group membership needs noble-admins / noble-editors UUIDs (worker DB resolve or inventory). + See noble_authentik_group_pk_noble_* in defaults/README. + when: + - noble_authentik_configure_idp | default(true) | bool + - (noble_authentik_oidc_provision_via | default('worker') | lower) == 'worker' + - (noble_authentik_bootstrap_email | default('') | trim | length) > 0 + + - name: Render Authentik worker user-groups spec (JSON for ak shell) + ansible.builtin.template: + src: authentik-worker-user-groups-spec.json.j2 + dest: "{{ noble_repo_root }}/ansible/.ansible-tmp/authentik-worker-user-groups-spec.json" + mode: "0600" + no_log: true + when: + - noble_authentik_configure_idp | default(true) | bool + - (noble_authentik_oidc_provision_via | default('worker') | lower) == 'worker' + - (noble_authentik_bootstrap_email | default('') | trim | length) > 0 + + - name: Add bootstrap user to noble groups in authentik-worker (ORM; bypasses users API RBAC) + ansible.builtin.shell: | + set -euo pipefail + NS="{{ noble_authentik_namespace }}" + POD="$(kubectl get pods -n "$NS" \ + -l "app.kubernetes.io/name=authentik,app.kubernetes.io/component=worker" \ + -o jsonpath='{.items[0].metadata.name}')" + SPEC=/tmp/ansible_authentik_worker_user_groups_spec.json + REM=/tmp/ansible_worker_add_bootstrap_user_groups.py + kubectl cp "{{ noble_repo_root }}/ansible/.ansible-tmp/authentik-worker-user-groups-spec.json" "${NS}/${POD}:${SPEC}" + kubectl cp "{{ role_path }}/files/worker_add_bootstrap_user_groups.py" "${NS}/${POD}:${REM}" + kubectl exec -n "$NS" "$POD" -- env AUTHENTIK_WORKER_USER_GROUPS_SPEC="${SPEC}" ak shell -c "exec(compile(open('${REM}').read(), 'ansible_worker_add_bootstrap_user_groups.py', 'exec'))" + kubectl exec -n "$NS" "$POD" -- rm -f "$SPEC" "$REM" || true + environment: + KUBECONFIG: "{{ noble_kubeconfig }}" + register: noble_authentik_worker_user_groups + changed_when: true + when: + - noble_authentik_configure_idp | default(true) | bool + - (noble_authentik_oidc_provision_via | default('worker') | lower) == 'worker' + - (noble_authentik_bootstrap_email | default('') | trim | length) > 0 + - name: Configure Authentik OAuth2/OIDC providers (API) ansible.builtin.command: argv: @@ -141,6 +384,12 @@ CLIENT_JSON: "{{ noble_repo_root }}/ansible/.ansible-tmp/authentik-clients.json" AUTHENTIK_OAUTH_AUTHORIZATION_FLOW_PK: "{{ noble_authentik_oauth_authorization_flow_pk | default('') }}" AUTHENTIK_OAUTH_INVALIDATION_FLOW_PK: "{{ noble_authentik_oauth_invalidation_flow_pk | default('') }}" + AUTHENTIK_OAUTH_SIGNING_KEY_PK: "{{ noble_authentik_oauth_signing_key_pk | default('') }}" + AUTHENTIK_OAUTH_SCOPE_MAPPING_PKS: "{{ noble_authentik_oauth_scope_mapping_pks | default('') }}" + AUTHENTIK_NOBLE_ADMINS_GROUP_PK: "{{ noble_authentik_group_pk_noble_admins | default('') }}" + AUTHENTIK_NOBLE_EDITORS_GROUP_PK: "{{ noble_authentik_group_pk_noble_editors | default('') }}" + AUTHENTIK_SKIP_OIDC_REST: "{{ '1' if (noble_authentik_oidc_provision_via | default('worker') | lower) == 'worker' else '' }}" + AUTHENTIK_SKIP_USER_GROUP_REST: "{{ '1' if ((noble_authentik_oidc_provision_via | default('worker') | lower) == 'worker' and (noble_authentik_bootstrap_email | default('') | trim | length) > 0) else '' }}" when: noble_authentik_configure_idp | default(true) | bool changed_when: true diff --git a/ansible/roles/noble_authentik/templates/authentik-worker-admin-access-spec.json.j2 b/ansible/roles/noble_authentik/templates/authentik-worker-admin-access-spec.json.j2 new file mode 100644 index 0000000..198b56c --- /dev/null +++ b/ansible/roles/noble_authentik/templates/authentik-worker-admin-access-spec.json.j2 @@ -0,0 +1,3 @@ +{ + "bootstrap_email": {{ noble_authentik_bootstrap_email | default('') | trim | to_json }} +} diff --git a/ansible/roles/noble_authentik/templates/authentik-worker-oidc-spec.json.j2 b/ansible/roles/noble_authentik/templates/authentik-worker-oidc-spec.json.j2 new file mode 100644 index 0000000..7f295b6 --- /dev/null +++ b/ansible/roles/noble_authentik/templates/authentik-worker-oidc-spec.json.j2 @@ -0,0 +1,32 @@ +{ + "authorization_flow": {{ noble_authentik_oauth_authorization_flow_pk | trim | to_json }}, + "invalidation_flow": {{ noble_authentik_oauth_invalidation_flow_pk | trim | to_json }}, + "signing_key": {{ noble_authentik_oauth_signing_key_pk | trim | to_json }}, + "property_mappings": {{ (noble_authentik_oauth_scope_mapping_pks | default('')).split(',') | map('trim') | reject('equalto', '') | list | to_json }}, + "clients": { + "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-worker-user-groups-spec.json.j2 b/ansible/roles/noble_authentik/templates/authentik-worker-user-groups-spec.json.j2 new file mode 100644 index 0000000..214d48c --- /dev/null +++ b/ansible/roles/noble_authentik/templates/authentik-worker-user-groups-spec.json.j2 @@ -0,0 +1,7 @@ +{ + "email": {{ noble_authentik_bootstrap_email | trim | to_json }}, + "group_pks": [ + {{ noble_authentik_group_pk_noble_admins | trim | to_json }}, + {{ noble_authentik_group_pk_noble_editors | trim | to_json }} + ] +} diff --git a/ansible/roles/talos_phase_a/tasks/apply_talos_node_config.yml b/ansible/roles/talos_phase_a/tasks/apply_talos_node_config.yml new file mode 100644 index 0000000..07dba46 --- /dev/null +++ b/ansible/roles/talos_phase_a/tasks/apply_talos_node_config.yml @@ -0,0 +1,69 @@ +--- +# Included once per **talos_node** (see **main.yml**). When the cluster probe used **joined** mode, +# a straggler may still expose the maintenance API cert (**x509: certificate signed by unknown authority**); +# we retry that node with **--insecure** so apply-config can complete. + +- name: Apply machine config (full cluster maintenance — insecure) + ansible.builtin.command: + argv: + - talosctl + - apply-config + - --insecure + - -n + - "{{ talos_node.ip }}" + - --file + - "{{ noble_talos_dir }}/out/{{ talos_node.machine }}" + when: noble_talos_apply_insecure | bool + changed_when: true + +- name: Apply machine config (joined cluster — TLS with per-node maintenance fallback) + when: not (noble_talos_apply_insecure | bool) + block: + - name: Reset CA mismatch flag for this node + ansible.builtin.set_fact: + noble_talos_apply_node_ca_mismatch: false + + - name: Try TLS (TALOSCONFIG / cluster CA) + ansible.builtin.command: + argv: + - talosctl + - apply-config + - -n + - "{{ talos_node.ip }}" + - --file + - "{{ noble_talos_dir }}/out/{{ talos_node.machine }}" + environment: + TALOSCONFIG: "{{ noble_talos_dir }}/out/talosconfig" + changed_when: true + + rescue: + - name: Detect Talos API still on maintenance / unknown CA (straggler vs first node) + ansible.builtin.set_fact: + noble_talos_apply_node_ca_mismatch: >- + {{ + ('unknown authority' in (ansible_failed_result.stderr | default(''))) + or ('certificate signed by unknown authority' in (ansible_failed_result.stderr | default(''))) + or ('authentication handshake failed' in (ansible_failed_result.stderr | default(''))) + }} + + - name: Apply machine config with --insecure (node not yet trusting cluster CA) + ansible.builtin.command: + argv: + - talosctl + - apply-config + - --insecure + - -n + - "{{ talos_node.ip }}" + - --file + - "{{ noble_talos_dir }}/out/{{ talos_node.machine }}" + when: noble_talos_apply_node_ca_mismatch | bool + register: noble_talos_apply_node_insecure_cmd + changed_when: true + failed_when: noble_talos_apply_node_insecure_cmd.rc != 0 + + - name: Fail when apply-config failed for reasons other than unknown CA + ansible.builtin.fail: + msg: >- + talosctl apply-config failed on {{ talos_node.ip }} (TLS, no insecure fallback): + {{ ansible_failed_result.stderr | default('no stderr') }} + when: not (noble_talos_apply_node_ca_mismatch | bool) diff --git a/ansible/roles/talos_phase_a/tasks/main.yml b/ansible/roles/talos_phase_a/tasks/main.yml index d292ab6..c20bd3e 100644 --- a/ansible/roles/talos_phase_a/tasks/main.yml +++ b/ansible/roles/talos_phase_a/tasks/main.yml @@ -114,38 +114,12 @@ apply-config: {{ 'maintenance (--insecure)' if noble_talos_apply_insecure | bool else 'joined (TALOSCONFIG)' }} (noble_talos_apply_mode={{ noble_talos_apply_mode | default('auto') }}) -- name: Apply machine config to each node (first install — insecure) - ansible.builtin.command: - argv: - - talosctl - - apply-config - - --insecure - - -n - - "{{ item.ip }}" - - --file - - "{{ noble_talos_dir }}/out/{{ item.machine }}" +- name: Apply machine config to each Talos node (TLS or insecure; per-node CA fallback when joined) + ansible.builtin.include_tasks: apply_talos_node_config.yml loop: "{{ noble_talos_nodes }}" loop_control: - label: "{{ item.ip }}" - when: noble_talos_apply_insecure | bool - changed_when: true - -- name: Apply machine config to each node (cluster already has TLS — no insecure) - ansible.builtin.command: - argv: - - talosctl - - apply-config - - -n - - "{{ item.ip }}" - - --file - - "{{ noble_talos_dir }}/out/{{ item.machine }}" - environment: - TALOSCONFIG: "{{ noble_talos_dir }}/out/talosconfig" - loop: "{{ noble_talos_nodes }}" - loop_control: - label: "{{ item.ip }}" - when: not (noble_talos_apply_insecure | bool) - changed_when: true + label: "{{ talos_node.ip }}" + loop_var: talos_node # apply-config triggers reboots; apid on :50000 must accept connections before talosctl bootstrap / kubeconfig. - name: Wait for Talos machine API (apid) on bootstrap node diff --git a/clusters/noble/bootstrap/oauth2-proxy/values.yaml b/clusters/noble/bootstrap/oauth2-proxy/values.yaml index d5e3242..6d94361 100644 --- a/clusters/noble/bootstrap/oauth2-proxy/values.yaml +++ b/clusters/noble/bootstrap/oauth2-proxy/values.yaml @@ -33,6 +33,10 @@ ingress: extraArgs: provider: oidc skip-provider-button: "true" + # Authentik bootstrap / local users often omit **email_verified** in the id_token; without this, + # oauth2-proxy returns **500** on `/oauth2/callback` with: "email in id_token (...) isn't verified". + # Prefer marking the account verified in Authentik (Directory) in production. + insecure-oidc-allow-unverified-email: "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" diff --git a/talos/README.md b/talos/README.md index 89564a4..99ffa61 100644 --- a/talos/README.md +++ b/talos/README.md @@ -51,6 +51,8 @@ talosctl apply-config -n 192.168.50.20 --file out/noble-neon.yaml **Do not pass `--insecure` for (B).** With `--insecure`, `talosctl` does not use client certificates from `TALOSCONFIG`, so the node still responds with `tls: certificate required`. The flag means “maintenance API only,” not “skip server verification.” +**Mixed state:** the first node may already present the **cluster CA** (TLS works with `TALOSCONFIG`) while another node is still on the **maintenance** cert (`x509: certificate signed by unknown authority`). **`ansible-playbook playbooks/deploy.yml`** (`talos_phase_a`) probes only the first node in **`noble_talos_nodes`**; for each remaining node it tries TLS first, then retries with **`--insecure`** when stderr indicates an unknown CA / handshake failure. + **Wrong (what triggers the error):** ```bash