diff --git a/k8s/infra/iam/scripts/authentik-provision.py b/k8s/infra/iam/scripts/authentik-provision.py index 82cbf83..afd0de6 100644 --- a/k8s/infra/iam/scripts/authentik-provision.py +++ b/k8s/infra/iam/scripts/authentik-provision.py @@ -148,6 +148,28 @@ groups_mapping = get_or_create( ) GROUPS_MAPPING_PK = groups_mapping["pk"] +# MinIO maps OIDC users to a MinIO policy via a "policy" claim +# (MINIO_IDENTITY_OPENID_CLAIM_NAME=policy). Emit consoleAdmin (full admin) for +# homelab-admins members, readonly for everyone else. Without this claim MinIO +# assigns no policy and OIDC users get no access. +_POLICY_EXPR = ( + "return {\"policy\": \"consoleAdmin\" " + "if request.user.ak_groups.filter(name=\"homelab-admins\").exists() " + "else \"readonly\"}" +) +policy_mapping = get_or_create( + "/api/v3/propertymappings/provider/scope/", + "/api/v3/propertymappings/provider/scope/", + "scope_name=minio", + { + "name": "homelab: minio policy claim", + "scope_name": "minio", + "expression": _POLICY_EXPR, + }, + patch_existing={"expression": _POLICY_EXPR}, +) +POLICY_MAPPING_PK = policy_mapping["pk"] + # Fetch the standard openid/email/profile mapping pks (shipped by default). status, res = api("GET", "/api/v3/propertymappings/provider/scope/") by_scope = {m["scope_name"]: m["pk"] for m in res["results"]} @@ -182,6 +204,10 @@ if res.get("results"): status, rock = api("PATCH", f"/api/v3/core/users/{rock['pk']}/", { "groups": [homelab_admins["pk"], grafana_admins["pk"]], "is_active": True, + # email is REQUIRED: Grafana's OIDC login reads the email claim from + # userinfo; an empty email makes Grafana fall back to a GitHub-style + # /emails call, which Authentik 404s -> login fails entirely. + "email": "locartrock@gmail.com", }) if status not in (200, 201): die(f"PATCH user rock -> {status} {rock}") @@ -192,6 +218,8 @@ else: "username": "rock", "name": "Rock", "is_active": True, + # Required for Grafana OIDC (see PATCH branch above). + "email": "locartrock@gmail.com", "groups": [homelab_admins["pk"], grafana_admins["pk"]], "path": "users", "type": "internal", @@ -258,6 +286,9 @@ SERVICES = { app_pks_for_binding = [] for name, cfg in SERVICES.items(): + # MinIO also needs the "policy" claim (via the minio scope mapping) so its + # MINIO_IDENTITY_OPENID_CLAIM_NAME=policy maps homelab-admins -> consoleAdmin. + provider_mappings = SCOPE_PKS + ([POLICY_MAPPING_PK] if name == "minio" else []) ns, secret_name, key = cfg["client_secret_source"] client_secret = kubectl_get_secret_key(ns, secret_name, key) if client_secret is None: @@ -284,7 +315,7 @@ for name, cfg in SERVICES.items(): "authorization_flow": AUTHORIZATION_FLOW_PK, "invalidation_flow": INVALIDATION_FLOW_PK, "signing_key": SIGNING_KEY_PK, - "property_mappings": SCOPE_PKS, + "property_mappings": provider_mappings, "sub_mode": "hashed_user_id", "include_claims_in_id_token": True, # authentik 2026.x requires grant_types to be set explicitly; the @@ -301,7 +332,7 @@ for name, cfg in SERVICES.items(): # never touch client_secret again once created (that's the source of # truth in the k8s Secret, and re-sending it here is harmless anyway). patch_existing={ - "property_mappings": SCOPE_PKS, + "property_mappings": provider_mappings, "grant_types": ["authorization_code", "refresh_token"], "redirect_uris": [ {"matching_mode": "strict", "url": u} for u in cfg["redirect_uris"]