feat: add homelab-wide Authentik RBAC model and k8s OIDC auth wiring

Adds permissions claim + per-service admin groups in Authentik, scoped
Role/RoleBinding per service, public PKCE kubernetes OAuth2 client, and
kube-apiserver OIDC extraArgs. Also fixes paperless OIDC signup permissions
via adapter override and adds CoreDNS rewrite for authentik.riotpiao.com.
This commit is contained in:
Story Crater Bot
2026-08-25 15:03:44 -07:00
parent 8b49b347b5
commit 89f01b6f2e
17 changed files with 608 additions and 11 deletions
+145 -11
View File
@@ -155,6 +155,44 @@ groups_mapping = get_or_create(
)
GROUPS_MAPPING_PK = groups_mapping["pk"]
# Generic "permissions" claim, computed from group membership - lets each app
# (and eventually k8s RBAC via --oidc-groups-claim) check a permission string
# like "paperless:write" instead of hardcoding a group name. homelab-admins
# gets "*" (everything); every other admin group gets its own read+write pair.
# k8s-devops-admin is declared but has no k8s Role/RoleBinding target yet -
# foundation for a future short-lived federated-operator credential.
_PERMISSIONS_EXPR = """
GROUP_PERMISSIONS = {
"homelab-admins": ["*"],
"grafana-admins": ["grafana:read", "grafana:write"],
"minio-admins": ["minio:read", "minio:write"],
"forgejo-admins": ["forgejo:read", "forgejo:write"],
"homarr-admins": ["homarr:read", "homarr:write"],
"portainer-admins": ["portainer:read", "portainer:write"],
"kmsvc-admins": ["kmsvc:read", "kmsvc:write"],
"temporal-admins": ["temporal:read", "temporal:write"],
"llm-admins": ["llm:read", "llm:write"],
"paperless-admins": ["paperless:read", "paperless:write"],
"k8s-devops-admin": ["k8s:devops"],
}
perms = set()
for group in request.user.groups.all():
perms.update(GROUP_PERMISSIONS.get(group.name, []))
return {"permissions": sorted(perms)}
""".strip()
permissions_mapping = get_or_create(
"/api/v3/propertymappings/provider/scope/",
"/api/v3/propertymappings/provider/scope/",
"scope_name=permissions",
{
"name": "homelab: permissions claim",
"scope_name": "permissions",
"expression": _PERMISSIONS_EXPR,
},
patch_existing={"expression": _PERMISSIONS_EXPR},
)
PERMISSIONS_MAPPING_PK = permissions_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
@@ -180,7 +218,7 @@ 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"]}
SCOPE_PKS = [by_scope["openid"], by_scope["email"], by_scope["profile"], GROUPS_MAPPING_PK]
SCOPE_PKS = [by_scope["openid"], by_scope["email"], by_scope["profile"], GROUPS_MAPPING_PK, PERMISSIONS_MAPPING_PK]
status, res = api("GET", "/api/v3/flows/instances/?slug=default-provider-authorization-implicit-consent")
AUTHORIZATION_FLOW_PK = res["results"][0]["pk"]
@@ -190,17 +228,31 @@ status, res = api("GET", "/api/v3/crypto/certificatekeypairs/?has_key=true")
SIGNING_KEY_PK = res["results"][0]["pk"]
# -----------------------------------------------------------------------------
print("[2/5] Ensuring groups homelab-admins / grafana-admins exist...")
print("[2/5] Ensuring homelab-admins + per-service admin groups exist...")
homelab_admins = get_or_create(
"/api/v3/core/groups/", "/api/v3/core/groups/",
"name=homelab-admins",
{"name": "homelab-admins", "is_superuser": True},
)
grafana_admins = get_or_create(
"/api/v3/core/groups/", "/api/v3/core/groups/",
"name=grafana-admins",
{"name": "grafana-admins", "is_superuser": False},
)
# App-scoped, not Authentik superusers (unlike homelab-admins) - each maps to
# read+write in its own service via the "permissions" claim above (k8s Role/
# RoleBinding in k8s/infra/rbac/, or an app's own adapter e.g. paperless's).
# k8s-devops-admin is declared with no target yet - foundation for a future
# short-lived federated-operator credential.
SERVICE_ADMIN_GROUP_NAMES = [
"grafana-admins", "minio-admins", "forgejo-admins", "homarr-admins",
"portainer-admins", "kmsvc-admins", "temporal-admins", "llm-admins",
"paperless-admins", "k8s-devops-admin",
]
service_admin_groups = {}
for group_name in SERVICE_ADMIN_GROUP_NAMES:
service_admin_groups[group_name] = get_or_create(
"/api/v3/core/groups/", "/api/v3/core/groups/",
f"name={group_name}",
{"name": group_name, "is_superuser": False},
)
grafana_admins = service_admin_groups["grafana-admins"]
paperless_admins = service_admin_groups["paperless-admins"]
# -----------------------------------------------------------------------------
print("[3/5] Ensuring user 'rock' exists with admin group membership...")
@@ -209,7 +261,7 @@ rock_password = None
if res.get("results"):
rock = res["results"][0]
status, rock = api("PATCH", f"/api/v3/core/users/{rock['pk']}/", {
"groups": [homelab_admins["pk"], grafana_admins["pk"]],
"groups": [homelab_admins["pk"]] + [g["pk"] for g in service_admin_groups.values()],
"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
@@ -227,7 +279,7 @@ else:
"is_active": True,
# Required for Grafana OIDC (see PATCH branch above).
"email": "[email protected]",
"groups": [homelab_admins["pk"], grafana_admins["pk"]],
"groups": [homelab_admins["pk"]] + [g["pk"] for g in service_admin_groups.values()],
"path": "users",
"type": "internal",
})
@@ -342,6 +394,12 @@ for name, cfg in SERVICES.items():
"secret": client_secret,
"settings": {
"server_url": "https://authentik.riotpiao.com/application/o/paperless/.well-known/openid-configuration",
# "groups"/"permissions" aren't default OIDC scopes -
# must be requested explicitly for Authentik's scope
# mappings above to actually be returned. paperless's
# adapter.py ConfigMap reads the "permissions" claim
# to grant is_staff+is_superuser.
"scope": ["openid", "profile", "email", "groups", "permissions"],
},
}],
},
@@ -413,6 +471,53 @@ for name, cfg in SERVICES.items():
app_pks_for_binding.append((name, application["pk"]))
print(f" {name}: provider pk={provider['pk']} application pk={application['pk']}")
# -----------------------------------------------------------------------------
# Separate from the SERVICES loop above: this is a PUBLIC client (PKCE, no
# client_secret) for `kubectl` OIDC login, not a confidential-client app
# login. Foundation for k8s/infra/rbac/ - kube-apiserver's --oidc-* flags
# (controlplane.tftpl) validate tokens issued against this provider.
# Redirect URI matches kubelogin's (int128/kubelogin) documented default;
# adjust here if a different kubectl OIDC plugin/port is actually used.
print("Ensuring public OAuth2 client 'kubernetes' for kubectl OIDC login...")
k8s_provider = get_or_create(
"/api/v3/providers/oauth2/", "/api/v3/providers/oauth2/",
"name=kubernetes",
{
"name": "kubernetes",
"client_id": "kubernetes",
"client_type": "public",
"authorization_flow": AUTHORIZATION_FLOW_PK,
"invalidation_flow": INVALIDATION_FLOW_PK,
"signing_key": SIGNING_KEY_PK,
"property_mappings": SCOPE_PKS,
"sub_mode": "hashed_user_id",
"include_claims_in_id_token": True,
"grant_types": ["authorization_code", "refresh_token"],
"redirect_uris": [
{"matching_mode": "strict", "url": "http://localhost:8000"},
],
},
patch_existing={
"property_mappings": SCOPE_PKS,
"grant_types": ["authorization_code", "refresh_token"],
"redirect_uris": [
{"matching_mode": "strict", "url": "http://localhost:8000"},
],
},
)
k8s_application = get_or_create(
"/api/v3/core/applications/", "/api/v3/core/applications/",
"slug=kubernetes&superuser_full_list=true",
{
"name": "Kubernetes",
"slug": "kubernetes",
"provider": k8s_provider["pk"],
"meta_launch_url": "https://authentik.riotpiao.com",
},
)
app_pks_for_binding.append(("kubernetes", k8s_application["pk"]))
print(f" kubernetes: provider pk={k8s_provider['pk']} application pk={k8s_application['pk']}")
# -----------------------------------------------------------------------------
print("[5/5] Binding homelab-admins to every application (guaranteed access for rock)...")
for name, app_pk in app_pks_for_binding:
@@ -428,9 +533,38 @@ for name, app_pk in app_pks_for_binding:
)
print(f" {name}: homelab-admins bound")
# Per-service admin groups are app-scoped (unlike homelab-admins' blanket
# binding above) - only grants visibility/access to that one application.
# portainer/kmsvc/temporal/llm-serving have no Authentik Application (no OIDC
# login integration), so their groups exist for the "permissions" claim /
# future k8s RBAC only - nothing to bind here.
SERVICE_GROUP_TO_APP_SLUG = {
"grafana-admins": "grafana",
"minio-admins": "minio",
"forgejo-admins": "forgejo",
"homarr-admins": "homarr",
"paperless-admins": "paperless",
}
for group_name, app_slug in SERVICE_GROUP_TO_APP_SLUG.items():
app_pk = next((pk for n, pk in app_pks_for_binding if n == app_slug), None)
if not app_pk:
continue
group_pk = service_admin_groups[group_name]["pk"]
get_or_create(
"/api/v3/policies/bindings/", "/api/v3/policies/bindings/",
f"target={app_pk}&group={group_pk}",
{
"target": app_pk,
"group": group_pk,
"order": 0,
"enabled": True,
},
)
print(f" {app_slug}: {group_name} bound")
print("\nDone. Summary:")
print(" groups: homelab-admins (superuser), grafana-admins")
print(" user: rock -> homelab-admins + grafana-admins")
print(" groups: homelab-admins (superuser) + " + ", ".join(SERVICE_ADMIN_GROUP_NAMES))
print(" user: rock -> homelab-admins + all service admin groups")
print(f" apps: {', '.join(n for n, _ in app_pks_for_binding)}")
if rock_password:
print(" NOTE: rock's password was generated this run - see")
+32
View File
@@ -0,0 +1,32 @@
# Scoped operator access for forgejo-admins: the gitea Deployment + admin
# creds only. forgejo-db-*, forgejo-oidc, forgejo-tls, and the helm-managed
# forgejo-gitea-inline-config stay excluded. Inert until kube-apiserver's
# OIDC wiring lands.
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: forgejo-operator
namespace: cicd
rules:
- apiGroups: ["apps"]
resources: ["deployments"]
resourceNames: ["forgejo-gitea"]
verbs: ["get", "list", "watch", "update", "patch"]
- apiGroups: [""]
resources: ["secrets"]
resourceNames: ["forgejo-admin"]
verbs: ["get", "list", "watch", "update", "patch"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: forgejo-admins-binding
namespace: cicd
subjects:
- kind: Group
name: "oidc:forgejo-admins"
apiGroup: rbac.authorization.k8s.io
roleRef:
kind: Role
name: forgejo-operator
apiGroup: rbac.authorization.k8s.io
+35
View File
@@ -0,0 +1,35 @@
# Scoped operator access for grafana-admins: dashboards + admin creds only.
# grafana-oidc (client secret) stays excluded - editing it is a security
# change, not app config. Inert until kube-apiserver's OIDC wiring lands.
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: grafana-operator
namespace: logging
rules:
- apiGroups: ["apps"]
resources: ["deployments"]
resourceNames: ["grafana"]
verbs: ["get", "list", "watch", "update", "patch"]
- apiGroups: [""]
resources: ["configmaps"]
resourceNames: ["grafana-dashboards-default", "grafana-config-dashboards"]
verbs: ["get", "list", "watch", "update", "patch"]
- apiGroups: [""]
resources: ["secrets"]
resourceNames: ["grafana-admin"]
verbs: ["get", "list", "watch", "update", "patch"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: grafana-admins-binding
namespace: logging
subjects:
- kind: Group
name: "oidc:grafana-admins"
apiGroup: rbac.authorization.k8s.io
roleRef:
kind: Role
name: grafana-operator
apiGroup: rbac.authorization.k8s.io
+32
View File
@@ -0,0 +1,32 @@
# Scoped operator access for homarr-admins: the homarr Deployment + app
# secrets only. homarr-oidc, auth-oidc-secret, db-encryption stay excluded
# (security-managed, not app config). Inert until kube-apiserver's OIDC
# wiring lands.
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: homarr-operator
namespace: dashboard
rules:
- apiGroups: ["apps"]
resources: ["deployments"]
resourceNames: ["homarr"]
verbs: ["get", "list", "watch", "update", "patch"]
- apiGroups: [""]
resources: ["secrets"]
resourceNames: ["homarr-secrets"]
verbs: ["get", "list", "watch", "update", "patch"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: homarr-admins-binding
namespace: dashboard
subjects:
- kind: Group
name: "oidc:homarr-admins"
apiGroup: rbac.authorization.k8s.io
roleRef:
kind: Role
name: homarr-operator
apiGroup: rbac.authorization.k8s.io
+33
View File
@@ -0,0 +1,33 @@
# Scoped operator access for kmsvc-admins: management-service + its own
# config only. kmsvc-* CA/cluster/pool secrets and the strimzi-cluster-
# operator configmap are Strimzi-managed - hand-editing them gets reverted
# by the operator's reconcile loop or breaks the Kafka cluster. Inert until
# kube-apiserver's OIDC wiring lands.
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: kmsvc-operator
namespace: sqs
rules:
- apiGroups: ["apps"]
resources: ["deployments"]
resourceNames: ["management-service"]
verbs: ["get", "list", "watch", "update", "patch"]
- apiGroups: [""]
resources: ["configmaps"]
resourceNames: ["management-service-config"]
verbs: ["get", "list", "watch", "update", "patch"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: kmsvc-admins-binding
namespace: sqs
subjects:
- kind: Group
name: "oidc:kmsvc-admins"
apiGroup: rbac.authorization.k8s.io
roleRef:
kind: Role
name: kmsvc-operator
apiGroup: rbac.authorization.k8s.io
+20
View File
@@ -0,0 +1,20 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
# NO top-level `namespace:` transformer - each Role/RoleBinding here targets
# a different service namespace (logging/storage/cicd/dashboard/sqs/temporal/
# llm-serving); a transformer would collapse them all into one, same bug
# already fixed once in k8s/infra/minio and k8s/infra/iam. Every resource
# here sets its own explicit metadata.namespace.
resources:
- grafana-operator-role.yaml
- minio-operator-role.yaml
- forgejo-operator-role.yaml
- homarr-operator-role.yaml
- portainer-operator-role.yaml
- kmsvc-operator-role.yaml
- temporal-operator-role.yaml
- llm-serving-operator-role.yaml
# paperless's Role/RoleBinding lives in k8s/apps/paperless/rbac.yaml instead -
# that app already has its own kustomization + namespace, no need to
# duplicate it here. All of these stay inert (grant nothing) until
# kube-apiserver has --oidc-groups-claim=groups wired up.
@@ -0,0 +1,31 @@
# Scoped operator access for llm-admins: the 4 predictor Deployments only -
# no configmap/secret exists in this namespace today. Inert until
# kube-apiserver's OIDC wiring lands.
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: llm-serving-operator
namespace: llm-serving
rules:
- apiGroups: ["apps"]
resources: ["deployments"]
resourceNames:
- reasoning-predictor
- ornith-predictor
- embeddings-predictor
- reranker-predictor
verbs: ["get", "list", "watch", "update", "patch"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: llm-admins-binding
namespace: llm-serving
subjects:
- kind: Group
name: "oidc:llm-admins"
apiGroup: rbac.authorization.k8s.io
roleRef:
kind: Role
name: llm-serving-operator
apiGroup: rbac.authorization.k8s.io
+33
View File
@@ -0,0 +1,33 @@
# Scoped operator access for minio-admins: the tenant StatefulSet + root
# creds only - NOT the minio-operator Deployment (shared cluster-wide infra;
# editing it risks breaking MinIO for every tenant, not just this one).
# minio-oidc/sts-tls stay excluded (security-managed, not app config). Inert
# until kube-apiserver's OIDC wiring lands.
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: minio-operator-admin
namespace: storage
rules:
- apiGroups: ["apps"]
resources: ["statefulsets"]
resourceNames: ["minio-cluster-az-a"]
verbs: ["get", "list", "watch", "update", "patch"]
- apiGroups: [""]
resources: ["secrets"]
resourceNames: ["minio-creds"]
verbs: ["get", "list", "watch", "update", "patch"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: minio-admins-binding
namespace: storage
subjects:
- kind: Group
name: "oidc:minio-admins"
apiGroup: rbac.authorization.k8s.io
roleRef:
kind: Role
name: minio-operator-admin
apiGroup: rbac.authorization.k8s.io
@@ -0,0 +1,27 @@
# Scoped operator access for portainer-admins: just the Deployment - no
# configmap/secret exists for portainer today. Inert until kube-apiserver's
# OIDC wiring lands.
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: portainer-operator
namespace: dashboard
rules:
- apiGroups: ["apps"]
resources: ["deployments"]
resourceNames: ["portainer"]
verbs: ["get", "list", "watch", "update", "patch"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: portainer-admins-binding
namespace: dashboard
subjects:
- kind: Group
name: "oidc:portainer-admins"
apiGroup: rbac.authorization.k8s.io
roleRef:
kind: Role
name: portainer-operator
apiGroup: rbac.authorization.k8s.io
@@ -0,0 +1,31 @@
# Scoped operator access for temporal-admins: temporal-worker + its dynamic
# config only. temporal-db-* (CNPG-managed) and webhook-server-cert stay
# excluded. Inert until kube-apiserver's OIDC wiring lands.
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: temporal-operator
namespace: temporal
rules:
- apiGroups: ["apps"]
resources: ["deployments"]
resourceNames: ["temporal-worker"]
verbs: ["get", "list", "watch", "update", "patch"]
- apiGroups: [""]
resources: ["configmaps"]
resourceNames: ["temporal-dynamic-config"]
verbs: ["get", "list", "watch", "update", "patch"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: temporal-admins-binding
namespace: temporal
subjects:
- kind: Group
name: "oidc:temporal-admins"
apiGroup: rbac.authorization.k8s.io
roleRef:
kind: Role
name: temporal-operator
apiGroup: rbac.authorization.k8s.io