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:
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
110
ansible/roles/noble_authentik/files/worker_upsert_oauth_oidc.py
Normal file
110
ansible/roles/noble_authentik/files/worker_upsert_oauth_oidc.py
Normal 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()
|
||||
Reference in New Issue
Block a user