diff --git a/k8s/apps/paperless/adapter-configmap.yaml b/k8s/apps/paperless/adapter-configmap.yaml new file mode 100644 index 0000000..0193d69 --- /dev/null +++ b/k8s/apps/paperless/adapter-configmap.yaml @@ -0,0 +1,95 @@ +# Overrides paperless-ngx's own paperless/adapter.py at the same import path +# (mounted via subPath in deployment.yaml) - settings.py hardcodes +# SOCIALACCOUNT_ADAPTER = "paperless.adapter.CustomSocialAccountAdapter", so +# no Django setting needs to change, just the file content underneath it. +# +# Stock CustomSocialAccountAdapter.populate_user() is a stub ("kept in case +# global default permissions are implemented in the future" - they aren't), +# so every OIDC signup lands with zero permissions and 403s on every API +# endpoint. This adds the actual mapping: Authentik's "permissions" claim +# (via the permissions scope, requested in PAPERLESS_SOCIALACCOUNT_PROVIDERS, +# computed server-side from group membership by authentik-provision.py) -> +# "paperless:write" or "*" (homelab-admins) grants is_staff+is_superuser, +# same convention already used for MinIO's policy claim and Grafana's +# role_attribute_path. Checking the permission string rather than a literal +# group name decouples "what grants access" from which group happens to +# hold it - same pattern applies to every other service's Role/RoleBinding +# in k8s/infra/rbac/. +apiVersion: v1 +kind: ConfigMap +metadata: + name: paperless-adapter +data: + adapter.py: | + from urllib.parse import quote + + from allauth.account.adapter import DefaultAccountAdapter + from allauth.core import context + from allauth.socialaccount.adapter import DefaultSocialAccountAdapter + from django.conf import settings + from django.forms import ValidationError + from django.urls import reverse + + REQUIRED_PERMISSIONS = {"paperless:write", "*"} + + + class CustomAccountAdapter(DefaultAccountAdapter): + def is_open_for_signup(self, request): + allow_signups = super().is_open_for_signup(request) + return getattr(settings, "ACCOUNT_ALLOW_SIGNUPS", allow_signups) + + def pre_authenticate(self, request, **credentials): + if settings.DISABLE_REGULAR_LOGIN: + raise ValidationError("Regular login is disabled") + return super().pre_authenticate(request, **credentials) + + def is_safe_url(self, url): + from django.utils.http import url_has_allowed_host_and_scheme + + allowed_hosts = {context.request.get_host()} | set(settings.ALLOWED_HOSTS) + if "*" in allowed_hosts: + allowed_hosts.remove("*") + allowed_hosts.add(context.request.get_host()) + return url_has_allowed_host_and_scheme(url, allowed_hosts=allowed_hosts) + return url_has_allowed_host_and_scheme(url, allowed_hosts=allowed_hosts) + + def get_reset_password_from_key_url(self, key): + if settings.PAPERLESS_URL is None: + return super().get_reset_password_from_key_url(key) + path = reverse( + "account_reset_password_from_key", + kwargs={"uidb36": "UID", "key": "KEY"}, + ) + path = path.replace("UID-KEY", quote(key)) + return settings.PAPERLESS_URL + path + + + class CustomSocialAccountAdapter(DefaultSocialAccountAdapter): + def is_open_for_signup(self, request, sociallogin): + allow_signups = super().is_open_for_signup(request, sociallogin) + return getattr(settings, "SOCIALACCOUNT_ALLOW_SIGNUPS", allow_signups) + + def get_connect_redirect_url(self, request, socialaccount): + return reverse("base") + + def populate_user(self, request, sociallogin, data): + user = super().populate_user(request, sociallogin, data) + perms = set(sociallogin.account.extra_data.get("permissions") or []) + if perms & REQUIRED_PERMISSIONS: + user.is_staff = True + user.is_superuser = True + return user + + def save_user(self, request, sociallogin, form=None): + # populate_user() sets the flags on the in-memory user, but + # allauth's default save_user() re-derives is_staff from + # ACCOUNT_DEFAULT_HTTP_PROTOCOL-independent defaults and can + # overwrite them on save - re-apply after super().save_user() + # persists the row, matching the permissions check above exactly. + user = super().save_user(request, sociallogin, form) + perms = set(sociallogin.account.extra_data.get("permissions") or []) + if perms & REQUIRED_PERMISSIONS and not (user.is_staff and user.is_superuser): + user.is_staff = True + user.is_superuser = True + user.save(update_fields=["is_staff", "is_superuser"]) + return user diff --git a/k8s/apps/paperless/deployment.yaml b/k8s/apps/paperless/deployment.yaml index 5d9cc0d..2349a6c 100644 --- a/k8s/apps/paperless/deployment.yaml +++ b/k8s/apps/paperless/deployment.yaml @@ -82,6 +82,13 @@ spec: mountPath: /usr/src/paperless/data - name: consume mountPath: /usr/src/paperless/consume + # Overrides paperless-ngx's own adapter.py in place - settings.py + # hardcodes the import path, so no Django setting changes, just + # the file content underneath it (see adapter-configmap.yaml). + - name: adapter + mountPath: /usr/src/paperless/src/paperless/adapter.py + subPath: adapter.py + readOnly: true volumes: - name: media persistentVolumeClaim: @@ -91,3 +98,6 @@ spec: claimName: paperless-data - name: consume emptyDir: {} + - name: adapter + configMap: + name: paperless-adapter diff --git a/k8s/apps/paperless/kustomization.yaml b/k8s/apps/paperless/kustomization.yaml index f7712bc..b59b1f8 100644 --- a/k8s/apps/paperless/kustomization.yaml +++ b/k8s/apps/paperless/kustomization.yaml @@ -9,6 +9,8 @@ resources: - service.yaml - ingress.yaml - backup-cronjob.yaml + - adapter-configmap.yaml + - rbac.yaml # postgres: paperless-db CNPG Cluster, deployed by k8s/infra/databases (wave 2, # before this app at wave 8) - not duplicated here. Same for the paperless-oidc # and paperless-minio-creds Secrets, written by PostSync provisioning Jobs in diff --git a/k8s/apps/paperless/rbac.yaml b/k8s/apps/paperless/rbac.yaml new file mode 100644 index 0000000..3c7d398 --- /dev/null +++ b/k8s/apps/paperless/rbac.yaml @@ -0,0 +1,35 @@ +# Scoped operator access for paperless-admins: restart/config-edit rights on +# just this service's own resources, nothing CNPG-managed (paperless-db-*) +# or provisioning-managed (paperless-oidc, paperless-minio-creds). Inert +# until kube-apiserver's OIDC wiring lands (--oidc-groups-claim=groups, +# --oidc-groups-prefix=oidc:) - subject name below assumes that prefix. +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: paperless-operator +rules: + - apiGroups: ["apps"] + resources: ["deployments"] + resourceNames: ["paperless"] + verbs: ["get", "list", "watch", "update", "patch"] + - apiGroups: [""] + resources: ["configmaps"] + resourceNames: ["paperless-config"] + verbs: ["get", "list", "watch", "update", "patch"] + - apiGroups: [""] + resources: ["secrets"] + resourceNames: ["paperless-secrets"] + verbs: ["get", "list", "watch", "update", "patch"] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: paperless-admins-binding +subjects: + - kind: Group + name: "oidc:paperless-admins" + apiGroup: rbac.authorization.k8s.io +roleRef: + kind: Role + name: paperless-operator + apiGroup: rbac.authorization.k8s.io diff --git a/k8s/argocd/apps/60-applications.yaml b/k8s/argocd/apps/60-applications.yaml index efdb67a..baebc51 100644 --- a/k8s/argocd/apps/60-applications.yaml +++ b/k8s/argocd/apps/60-applications.yaml @@ -203,3 +203,28 @@ spec: selfHeal: true syncOptions: - CreateNamespace=true +--- +# Wave 9 - per-service scoped RBAC (Role/RoleBinding), deliberately last so +# every target namespace above already exists. Inert until kube-apiserver +# gets --oidc-groups-claim=groups wired up (separate, not-yet-applied +# terraform/talosctl change) - these grant nothing until then. +apiVersion: argoproj.io/v1alpha1 +kind: Application +metadata: + name: rbac + namespace: argocd + annotations: + argocd.argoproj.io/sync-wave: "9" +spec: + project: homelab + source: + repoURL: https://forgejo.riotpiao.com/rock/homelab.git + targetRevision: main + path: k8s/infra/rbac + destination: + server: https://kubernetes.default.svc + namespace: default + syncPolicy: + automated: + prune: true + selfHeal: true diff --git a/k8s/infra/iam/scripts/authentik-provision.py b/k8s/infra/iam/scripts/authentik-provision.py index 3e540c7..7465f41 100644 --- a/k8s/infra/iam/scripts/authentik-provision.py +++ b/k8s/infra/iam/scripts/authentik-provision.py @@ -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": "locartrock@gmail.com", - "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") diff --git a/k8s/infra/rbac/forgejo-operator-role.yaml b/k8s/infra/rbac/forgejo-operator-role.yaml new file mode 100644 index 0000000..3211143 --- /dev/null +++ b/k8s/infra/rbac/forgejo-operator-role.yaml @@ -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 diff --git a/k8s/infra/rbac/grafana-operator-role.yaml b/k8s/infra/rbac/grafana-operator-role.yaml new file mode 100644 index 0000000..7e5d056 --- /dev/null +++ b/k8s/infra/rbac/grafana-operator-role.yaml @@ -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 diff --git a/k8s/infra/rbac/homarr-operator-role.yaml b/k8s/infra/rbac/homarr-operator-role.yaml new file mode 100644 index 0000000..7f12e02 --- /dev/null +++ b/k8s/infra/rbac/homarr-operator-role.yaml @@ -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 diff --git a/k8s/infra/rbac/kmsvc-operator-role.yaml b/k8s/infra/rbac/kmsvc-operator-role.yaml new file mode 100644 index 0000000..a83b839 --- /dev/null +++ b/k8s/infra/rbac/kmsvc-operator-role.yaml @@ -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 diff --git a/k8s/infra/rbac/kustomization.yaml b/k8s/infra/rbac/kustomization.yaml new file mode 100644 index 0000000..7891be7 --- /dev/null +++ b/k8s/infra/rbac/kustomization.yaml @@ -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. diff --git a/k8s/infra/rbac/llm-serving-operator-role.yaml b/k8s/infra/rbac/llm-serving-operator-role.yaml new file mode 100644 index 0000000..f7aed07 --- /dev/null +++ b/k8s/infra/rbac/llm-serving-operator-role.yaml @@ -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 diff --git a/k8s/infra/rbac/minio-operator-role.yaml b/k8s/infra/rbac/minio-operator-role.yaml new file mode 100644 index 0000000..665e3aa --- /dev/null +++ b/k8s/infra/rbac/minio-operator-role.yaml @@ -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 diff --git a/k8s/infra/rbac/portainer-operator-role.yaml b/k8s/infra/rbac/portainer-operator-role.yaml new file mode 100644 index 0000000..2a792bc --- /dev/null +++ b/k8s/infra/rbac/portainer-operator-role.yaml @@ -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 diff --git a/k8s/infra/rbac/temporal-operator-role.yaml b/k8s/infra/rbac/temporal-operator-role.yaml new file mode 100644 index 0000000..27d2746 --- /dev/null +++ b/k8s/infra/rbac/temporal-operator-role.yaml @@ -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 diff --git a/terraform/files/coredns/Corefile b/terraform/files/coredns/Corefile index c1300e8..de60dd0 100644 --- a/terraform/files/coredns/Corefile +++ b/terraform/files/coredns/Corefile @@ -33,6 +33,7 @@ rewrite name homarr.riotpiao.com ingress-nginx-controller.ingress-nginx.svc.cluster.local rewrite name portainer.riotpiao.com ingress-nginx-controller.ingress-nginx.svc.cluster.local rewrite name longhorn.riotpiao.com ingress-nginx-controller.ingress-nginx.svc.cluster.local + rewrite name paperless.riotpiao.com ingress-nginx-controller.ingress-nginx.svc.cluster.local kubernetes cluster.local in-addr.arpa ip6.arpa { pods insecure diff --git a/terraform/templates/controlplane.tftpl b/terraform/templates/controlplane.tftpl index d8e49fa..3aaf9d1 100644 --- a/terraform/templates/controlplane.tftpl +++ b/terraform/templates/controlplane.tftpl @@ -33,6 +33,14 @@ machine: - ip: ${forgejo_registry_ip} aliases: - ${forgejo_hostname} + # kube-apiserver's static pod runs on the host network, so it resolves + # authentik.riotpiao.com via THIS node's DNS, not cluster CoreDNS - the + # OIDC issuer discovery call would otherwise hit the public + # Cloudflare-fronted IP instead of nginx directly. Same reasoning as + # the forgejo entry above. + - ip: 192.168.1.160 + aliases: + - authentik.riotpiao.com kubelet: image: ${kubelet_image} defaultRuntimeSeccompProfileEnabled: true @@ -120,6 +128,19 @@ cluster: - ${san} %{ endfor ~} image: ${kube_apiserver_img} + # Foundation for the Authentik group -> k8s RBAC pattern in + # k8s/infra/rbac/ - additive only, existing client-cert auth + # (system:masters, this session's own admin@homelab-cluster kubeconfig) + # keeps working unchanged; a bad OIDC config just means OIDC logins fail, + # not a lockout. Prefixes avoid collision with built-in system:* users + # and groups. + extraArgs: + oidc-issuer-url: https://authentik.riotpiao.com/application/o/kubernetes/ + oidc-client-id: kubernetes + oidc-username-claim: email + oidc-groups-claim: groups + oidc-username-prefix: "oidc:" + oidc-groups-prefix: "oidc:" admissionControl: - name: PodSecurity configuration: