diff --git a/k8s/argocd/secrets/kustomization.yaml b/k8s/argocd/secrets/kustomization.yaml index fbb5c71..0620574 100644 --- a/k8s/argocd/secrets/kustomization.yaml +++ b/k8s/argocd/secrets/kustomization.yaml @@ -1,9 +1,10 @@ apiVersion: kustomize.config.k8s.io/v1beta1 kind: Kustomization -secretGenerator: - - name: portfolio-secrets - namespace: portfolio - disableNameSuffixHash: true - envs: - - portfolio-secrets.enc.env +# Disable hash suffix for all generated secrets (stable names) +generatorOptions: + disableNameSuffixHash: true + +# SOPS-encrypted secrets via ksops generator +generators: + - secret-generator.yaml diff --git a/k8s/infra/iam/authentik-provision-job.yaml b/k8s/infra/iam/authentik-provision-job.yaml index 7f3da6e..b2fa7fb 100644 --- a/k8s/infra/iam/authentik-provision-job.yaml +++ b/k8s/infra/iam/authentik-provision-job.yaml @@ -1,19 +1,22 @@ -# Authentik OAuth provisioning — PostSync hook, reruns on every ArgoCD sync -# (hook-delete-policy: BeforeHookCreation deletes the previous run's Job before -# creating a new one, so this stays reconciled the same way the rest of the -# cluster does — no separate manual bootstrap step like setup_talos_iam.sh / -# provision_oidc.py, which never got migrated off the old helmfile workflow). +# Authentik OAuth provisioning — MANUAL operation, NOT auto-run. # -# What it does (see scripts/authentik-provision.py docstring): creates the -# "groups" scope mapping, homelab-admins / grafana-admins groups, the "rock" -# admin user, OAuth2 providers + Applications for grafana/minio/forgejo/argocd, -# and binds homelab-admins to all of them. The script is generated into the -# authentik-provision-script ConfigMap by kustomize configMapGenerator (see -# kustomization.yaml), not embedded here. +# Security-sensitive IAM changes should be reviewed and run locally: +# export AUTHENTIK_BOOTSTRAP_TOKEN=$(kubectl -n iam get secret authentik-secrets \ +# -o jsonpath='{.data.AUTHENTIK_BOOTSTRAP_TOKEN}' | base64 -d) +# sed 's|http://authentik-server.iam.svc.cluster.local|https://authentik.riotpiao.com|g' \ +# k8s/infra/iam/scripts/authentik-provision.py | python3 +# +# What it does (see scripts/authentik-provision.py docstring): creates scope +# mappings (groups, permissions, memory, policy, immich_role), admin groups, +# the "rock" admin user, OAuth2 providers + Applications, service accounts +# (portfolio-agent, memory-agent), and binds groups to applications. +# +# This file provides the RBAC (ServiceAccount + RoleBindings) needed if you +# ever want to run the Job in-cluster manually (kubectl create -f), but the +# PostSync hook is deliberately removed — IAM is not GitOps-auto-deployed. # # RBAC: this Job only touches Secrets (get existing client secrets, create new -# ones for forgejo/argocd/rock) across the namespaces those services live in. -# It never touches any other resource type. +# ones) across the namespaces those services live in. apiVersion: v1 kind: ServiceAccount metadata: @@ -143,14 +146,54 @@ roleRef: name: authentik-provisioner apiGroup: rbac.authorization.k8s.io --- +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: authentik-provisioner + namespace: portfolio +subjects: + - kind: ServiceAccount + name: authentik-provisioner + namespace: iam +roleRef: + kind: ClusterRole + name: authentik-provisioner + apiGroup: rbac.authorization.k8s.io +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: authentik-provisioner + namespace: poimen +subjects: + - kind: ServiceAccount + name: authentik-provisioner + namespace: iam +roleRef: + kind: ClusterRole + name: authentik-provisioner + apiGroup: rbac.authorization.k8s.io +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: authentik-provisioner + namespace: dashboard +subjects: + - kind: ServiceAccount + name: authentik-provisioner + namespace: iam +roleRef: + kind: ClusterRole + name: authentik-provisioner + apiGroup: rbac.authorization.k8s.io +--- apiVersion: batch/v1 kind: Job metadata: name: authentik-provision namespace: iam - annotations: - argocd.argoproj.io/hook: PostSync - argocd.argoproj.io/hook-delete-policy: BeforeHookCreation + # No ArgoCD hook - run manually when IAM changes are needed spec: ttlSecondsAfterFinished: 600 backoffLimit: 3 diff --git a/k8s/infra/iam/scripts/authentik-provision.py b/k8s/infra/iam/scripts/authentik-provision.py index 04fa26f..9a4dc02 100644 --- a/k8s/infra/iam/scripts/authentik-provision.py +++ b/k8s/infra/iam/scripts/authentik-provision.py @@ -163,7 +163,10 @@ GROUPS_MAPPING_PK = groups_mapping["pk"] # foundation for a future short-lived federated-operator credential. _PERMISSIONS_EXPR = """ GROUP_PERMISSIONS = { + # Universal admin "homelab-admins": ["*"], + + # Service admin groups (full control) "grafana-admins": ["grafana:read", "grafana:write"], "minio-admins": ["minio:read", "minio:write"], "forgejo-admins": ["forgejo:read", "forgejo:write"], @@ -174,9 +177,14 @@ GROUP_PERMISSIONS = { "llm-admins": ["llm:read", "llm:write", "llm:inference"], "paperless-admins": ["paperless:read", "paperless:write"], "immich-admins": ["immich:read", "immich:write"], - "poimen-memory-admins": ["poimen-memory:read", "poimen-memory:write"], + "poimen-memory-admins": ["memory:read", "memory:write", "memory:admin"], "k8s-devops-admin": ["k8s:devops"], "vault-service-api": ["vault:read", "vault:write"], + + # Capability groups (non-admin users) + "llm-users": ["llm:inference"], + "memory-users": ["memory:read"], + "memory-writers": ["memory:read", "memory:write"], } perms = set() for group in request.user.groups.all(): @@ -240,6 +248,69 @@ policy_mapping = get_or_create( ) POLICY_MAPPING_PK = policy_mapping["pk"] +# Memory service (Poimen) claims - fine-grained access control. +# Returns memory_projects, memory_visibility, memory_role based on: +# 1. User attributes (memory_projects, memory_visibility) +# 2. Group membership (homelab-admins, poimen-memory-admins) +# 3. Service account configs (portfolio-agent, etc.) +_MEMORY_EXPR = """ +# Service account specific configs (checked first) +SA_CONFIGS = { + "portfolio-agent": { + "projects": ["homelab", "portfolio"], + "visibility": "public", + "role": "portfolio-agent" + }, + "memory-agent": { + "projects": ["*"], + "visibility": "private", + "role": "authenticated-user" + } +} + +username = request.user.username +if username in SA_CONFIGS: + cfg = SA_CONFIGS[username] + return { + "memory_projects": cfg["projects"], + "memory_visibility": cfg["visibility"], + "memory_role": cfg["role"] + } + +# Default from user attributes +projects = request.user.attributes.get("memory_projects", []) +visibility = request.user.attributes.get("memory_visibility", "public") +role = "user" + +# Admin group overrides +if request.user.ak_groups.filter(name="homelab-admins").exists(): + projects = ["*"] + visibility = "private" + role = "admin" +elif request.user.ak_groups.filter(name="poimen-memory-admins").exists(): + # memory-admins get full visibility but respect project restrictions + visibility = "private" + role = "admin" + +return { + "memory_projects": projects if projects else [], + "memory_visibility": visibility, + "memory_role": role +} +""".strip() +memory_mapping = get_or_create( + "/api/v3/propertymappings/provider/scope/", + "/api/v3/propertymappings/provider/scope/", + "scope_name=memory", + { + "name": "homelab: memory service claims", + "scope_name": "memory", + "expression": _MEMORY_EXPR, + }, + patch_existing={"expression": _MEMORY_EXPR}, +) +MEMORY_MAPPING_PK = memory_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"]} @@ -265,14 +336,17 @@ homelab_admins = get_or_create( # k8s-devops-admin is declared with no target yet - foundation for a future # short-lived federated-operator credential. SERVICE_ADMIN_GROUP_NAMES = [ + # Service admin groups (full control of their service) "grafana-admins", "minio-admins", "forgejo-admins", "homarr-admins", "portainer-admins", "kmsvc-admins", "temporal-admins", "llm-admins", "paperless-admins", "immich-admins", "poimen-memory-admins", "k8s-devops-admin", - # Not a human-admin group like the others - Vault Identity Group aliasing - # target for service/API (non-browser) access to Vault, kept separate from - # homelab-admins' blanket "*" grant. See k8s/infra/iam/scripts/vault-provision.sh. + # Vault Identity Group aliasing target for service/API access "vault-service-api", + # Capability groups (non-admin users with specific permissions) + "llm-users", # Can call LLM inference, no admin + "memory-users", # Can query memory, no write + "memory-writers", # Can query and write to memory ] service_admin_groups = {} for group_name in SERVICE_ADMIN_GROUP_NAMES: @@ -439,11 +513,17 @@ 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. - # Immich needs "immich_role" so its OAuth roleClaim can grant admin. - provider_mappings = SCOPE_PKS + ([POLICY_MAPPING_PK] if name == "minio" else []) \ - + ([IMMICH_ROLE_MAPPING_PK] if name == "immich" else []) + # Service-specific scope mappings: + # - MinIO: "policy" claim for MINIO_IDENTITY_OPENID_CLAIM_NAME + # - Immich: "immich_role" for OAuth roleClaim + # - poimen-memory, local-llm: "memory" scope for fine-grained access + provider_mappings = SCOPE_PKS[:] + if name == "minio": + provider_mappings.append(POLICY_MAPPING_PK) + if name == "immich": + provider_mappings.append(IMMICH_ROLE_MAPPING_PK) + if name in ("poimen-memory", "local-llm", "portfolio-agent"): + provider_mappings.append(MEMORY_MAPPING_PK) ns, secret_name, key = cfg["client_secret_source"] client_secret = kubectl_get_secret_key(ns, secret_name, key) if client_secret is None: @@ -640,7 +720,123 @@ 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)...") +# Service accounts for programmatic API access (client_credentials flow). +# These are Authentik users with type=service_account, not OAuth applications. +# They authenticate via client_credentials grant to get JWTs with claims. +print("\n[SERVICE ACCOUNTS] Creating service accounts for API access...") + +SERVICE_ACCOUNTS = { + "portfolio-agent": { + "groups": [], # Permissions come from SA_CONFIGS in memory scope mapping + "attributes": { + "memory_projects": ["homelab", "portfolio"], + "memory_visibility": "public", + }, + "secret_ns": "portfolio", + "secret_name": "portfolio-agent-oidc", + }, + "memory-agent": { + "groups": [], # Permissions come from SA_CONFIGS in memory scope mapping + "attributes": { + "memory_projects": ["*"], + "memory_visibility": "private", + }, + "secret_ns": "poimen", + "secret_name": "memory-agent-oidc", + }, +} + +for sa_name, sa_cfg in SERVICE_ACCOUNTS.items(): + # Check if secret already exists (don't regenerate credentials) + existing_secret = kubectl_get_secret_key(sa_cfg["secret_ns"], sa_cfg["secret_name"], "CLIENT_SECRET") + + # Get or create the service account user + status, res = api("GET", f"/api/v3/core/users/?username={sa_name}") + if status != 200: + die(f"GET users for {sa_name} -> {status} {res}") + + if res.get("results"): + sa_user = res["results"][0] + # Update attributes and groups + group_pks = [service_admin_groups[g]["pk"] for g in sa_cfg["groups"] if g in service_admin_groups] + status, sa_user = api("PATCH", f"/api/v3/core/users/{sa_user['pk']}/", { + "attributes": sa_cfg["attributes"], + "groups": group_pks, + "is_active": True, + }) + if status not in (200, 201): + die(f"PATCH service account {sa_name} -> {status} {sa_user}") + print(f" {sa_name}: updated existing service account") + else: + # Create new service account user + group_pks = [service_admin_groups[g]["pk"] for g in sa_cfg["groups"] if g in service_admin_groups] + status, sa_user = api("POST", "/api/v3/core/users/", { + "username": sa_name, + "name": f"Service Account: {sa_name}", + "type": "service_account", + "path": "service-accounts", + "is_active": True, + "attributes": sa_cfg["attributes"], + "groups": group_pks, + }) + if status not in (200, 201): + die(f"POST service account {sa_name} -> {status} {sa_user}") + print(f" {sa_name}: created new service account") + + # Create OAuth provider for this service account (client_credentials flow) + sa_client_secret = existing_secret or gen_secret(40) + sa_provider = get_or_create( + "/api/v3/providers/oauth2/", "/api/v3/providers/oauth2/", + f"name={sa_name}", + { + "name": sa_name, + "client_id": sa_name, + "client_secret": sa_client_secret, + "client_type": "confidential", + "authorization_flow": AUTHORIZATION_FLOW_PK, + "invalidation_flow": INVALIDATION_FLOW_PK, + "signing_key": SIGNING_KEY_PK, + "property_mappings": SCOPE_PKS + [MEMORY_MAPPING_PK], + "sub_mode": "hashed_user_id", + "include_claims_in_id_token": True, + "grant_types": ["client_credentials"], + "redirect_uris": [], # No redirects for client_credentials + }, + patch_existing={ + "property_mappings": SCOPE_PKS + [MEMORY_MAPPING_PK], + "grant_types": ["client_credentials"], + }, + ) + + # Create application for the service account + sa_application = get_or_create( + "/api/v3/core/applications/", "/api/v3/core/applications/", + f"slug={sa_name}&superuser_full_list=true", + { + "name": f"Service Account: {sa_name}", + "slug": sa_name, + "provider": sa_provider["pk"], + "meta_launch_url": "", + }, + ) + app_pks_for_binding.append((sa_name, sa_application["pk"])) + + # Store credentials in k8s Secret + if not existing_secret: + kubectl_create_secret(sa_cfg["secret_ns"], sa_cfg["secret_name"], { + "CLIENT_ID": sa_name, + "CLIENT_SECRET": sa_client_secret, + "TOKEN_URL": "https://authentik.riotpiao.com/application/o/token/", + "ISSUER": f"https://authentik.riotpiao.com/application/o/{sa_name}/", + }) + print(f" {sa_name}: generated credentials -> {sa_cfg['secret_ns']}/{sa_cfg['secret_name']}") + else: + print(f" {sa_name}: reusing existing credentials from {sa_cfg['secret_ns']}/{sa_cfg['secret_name']}") + + print(f" {sa_name}: provider pk={sa_provider['pk']} application pk={sa_application['pk']}") + +# ----------------------------------------------------------------------------- +print("\n[5/5] Binding homelab-admins to every application (guaranteed access for rock)...") for name, app_pk in app_pks_for_binding: get_or_create( "/api/v3/policies/bindings/", "/api/v3/policies/bindings/", @@ -667,6 +863,10 @@ SERVICE_GROUP_TO_APP_SLUG = { "paperless-admins": "paperless", "immich-admins": "immich", "llm-admins": "local-llm", + "llm-users": "local-llm", + "poimen-memory-admins": "poimen-memory", + "memory-users": "poimen-memory", + "memory-writers": "poimen-memory", } 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)