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.

This commit is contained in:
Nikholas Pcenicni
2026-05-14 14:26:43 -04:00
parent c392ce1e5a
commit 5e5c6ef671
24 changed files with 868 additions and 99 deletions

View File

@@ -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`): Authentiks 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 users 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 tokens 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 groups 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/<app>/oauth2/...`**. Use **issuer discovery** (Grafana **`server_url`** at **`…/application/o/<slug>/`**; 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`**).

View File

@@ -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

View File

@@ -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)

View File

@@ -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()

View File

@@ -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()

View File

@@ -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()

View File

@@ -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()

View File

@@ -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": "<bootstrap email>", "group_pks": ["<uuid>", ...]}
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()

View File

@@ -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()

View File

@@ -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": "<uuid>",
# "invalidation_flow": "<uuid>",
# "signing_key": "<uuid>",
# "property_mappings": ["<uuid>", ...],
# "clients": { "<slug>": {"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()

View File

@@ -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

View File

@@ -0,0 +1,3 @@
{
"bootstrap_email": {{ noble_authentik_bootstrap_email | default('') | trim | to_json }}
}

View File

@@ -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"
}
}
}

View File

@@ -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 }}
]
}

View File

@@ -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)

View File

@@ -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