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 ffaa034..453ff00 100644 Binary files a/ansible/roles/noble_authentik/files/__pycache__/configure_authentik.cpython-314.pyc and b/ansible/roles/noble_authentik/files/__pycache__/configure_authentik.cpython-314.pyc differ 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 0000000..f79632f Binary files /dev/null and b/ansible/roles/noble_authentik/files/__pycache__/resolve_noble_group_pks.cpython-314.pyc differ 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 0000000..dfc7636 Binary files /dev/null and b/ansible/roles/noble_authentik/files/__pycache__/resolve_oauth_scope_mapping_pks.cpython-314.pyc differ diff --git a/ansible/roles/noble_authentik/files/__pycache__/worker_add_bootstrap_user_groups.cpython-314.pyc b/ansible/roles/noble_authentik/files/__pycache__/worker_add_bootstrap_user_groups.cpython-314.pyc new file mode 100644 index 0000000..90f4482 Binary files /dev/null and b/ansible/roles/noble_authentik/files/__pycache__/worker_add_bootstrap_user_groups.cpython-314.pyc differ 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 0000000..d41a06b Binary files /dev/null and b/ansible/roles/noble_authentik/files/__pycache__/worker_ensure_authentik_admin_access.cpython-314.pyc differ 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 0000000..b79b980 Binary files /dev/null and b/ansible/roles/noble_authentik/files/__pycache__/worker_upsert_oauth_oidc.cpython-314.pyc differ diff --git a/ansible/roles/noble_authentik/files/configure_authentik.py b/ansible/roles/noble_authentik/files/configure_authentik.py index 10db75a..f3ec571 100644 --- a/ansible/roles/noble_authentik/files/configure_authentik.py +++ b/ansible/roles/noble_authentik/files/configure_authentik.py @@ -6,9 +6,17 @@ Environment: AUTHENTIK_TOKEN bootstrap API token (Bearer) for user **akadmin** (see README if flows return 403) BOOTSTRAP_EMAIL initial admin email (added to noble-admins) CLIENT_JSON path to JSON file: { "argocd": {"client_id","client_secret","redirect_uri"}, ... } -Optional (skip flow API discovery; Authentik 2026+ flow **pk** UUID strings): +Optional (skip API discovery; Authentik 2026+ RBAC — use UUID strings from worker shell or Admin UI): AUTHENTIK_OAUTH_AUTHORIZATION_FLOW_PK AUTHENTIK_OAUTH_INVALIDATION_FLOW_PK + AUTHENTIK_OAUTH_SIGNING_KEY_PK + AUTHENTIK_OAUTH_SCOPE_MAPPING_PKS comma-separated ScopeMapping UUIDs (openid,email,profile,offline_access[,groups]) + AUTHENTIK_NOBLE_ADMINS_GROUP_PK noble-admins Group UUID (skip GET /core/groups/) + AUTHENTIK_NOBLE_EDITORS_GROUP_PK noble-editors Group UUID + AUTHENTIK_SKIP_OIDC_REST if 1/true/yes, skip OAuth2 provider + application REST calls (Ansible + uses **worker_upsert_oauth_oidc.py** when bootstrap tokens cannot **GET …/providers/oauth2/**) + AUTHENTIK_SKIP_USER_GROUP_REST if 1/true/yes, skip **GET/PATCH …/core/users/** for bootstrap group membership (Ansible uses + **worker_add_bootstrap_user_groups.py** when the token cannot **view_user**) """ from __future__ import annotations @@ -173,10 +181,19 @@ def find_flow_pk_first(base: str, slugs: list[str]) -> 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