From 8f7004c9469f5e2688e48fb4285b2d14ebe2c25d Mon Sep 17 00:00:00 2001 From: rock Date: Thu, 3 Sep 2026 19:23:06 -0700 Subject: [PATCH] iam: switch service accounts to roles-based auth - Roles stored in user attributes, not groups - Property mapping looks up roles by client_id for client_credentials - Service account apps have no policy bindings (client_secret = access control) - Cleanup stale bindings on re-provision - JWT claims: azp (service identity) + roles (capabilities) --- scripts/iam/authentik-provision.py | 75 ++++++++++++++++++++++++------ 1 file changed, 60 insertions(+), 15 deletions(-) diff --git a/scripts/iam/authentik-provision.py b/scripts/iam/authentik-provision.py index a8b3e91..1155596 100644 --- a/scripts/iam/authentik-provision.py +++ b/scripts/iam/authentik-provision.py @@ -204,6 +204,38 @@ permissions_mapping = get_or_create( ) PERMISSIONS_MAPPING_PK = permissions_mapping["pk"] +# Roles claim for service accounts - for client_credentials flow, the auto-generated +# user doesn't have roles, so we look up the actual service account by client_id. +# Format: ["llm:inference", "memory:read", "memory:write"] +_ROLES_EXPR = """ +from authentik.core.models import User + +# Try user's own roles first (password grant uses the actual user) +roles = request.user.attributes.get("roles", []) + +# For client_credentials, user is auto-generated - look up by client_id +if not roles and request.http_request: + client_id = request.http_request.POST.get("client_id", "") + if client_id: + sa_user = User.objects.filter(username=client_id, path="service-accounts").first() + if sa_user: + roles = sa_user.attributes.get("roles", []) + +return {"roles": roles} +""".strip() +roles_mapping = get_or_create( + "/api/v3/propertymappings/provider/scope/", + "/api/v3/propertymappings/provider/scope/", + "scope_name=roles", + { + "name": "homelab: roles claim", + "scope_name": "roles", + "expression": _ROLES_EXPR, + }, + patch_existing={"expression": _ROLES_EXPR}, +) +ROLES_MAPPING_PK = roles_mapping["pk"] + # Immich reads a "immich_role" claim on every login (not just user-creation - # fixed upstream in immich-app/immich#29991) and syncs isAdmin from it, so # this is the actual mechanism that makes "rock" an Immich admin - not @@ -769,9 +801,12 @@ else: # They authenticate via password grant to get JWTs with user claims. print("\n[SERVICE ACCOUNTS] Creating service accounts for API access...") +# Roles define what APIs a service can access - stored in user attributes, +# output as "roles" claim in JWT. Gateway checks roles, not groups. +# Format: ":" e.g. "llm:inference", "memory:write" SERVICE_ACCOUNTS = { "portfolio-agent": { - "groups": ["llm-users", "memory-users"], # LLM inference + memory read + "roles": ["llm:inference", "memory:read"], "attributes": { "memory_projects": ["homelab", "portfolio"], "memory_visibility": "public", @@ -780,7 +815,7 @@ SERVICE_ACCOUNTS = { "secret_name": "portfolio-agent-oidc", }, "memory-agent": { - "groups": ["llm-users", "memory-writers"], # Internal memory service + "roles": ["llm:inference", "memory:read", "memory:write"], "attributes": { "memory_projects": ["*"], "memory_visibility": "private", @@ -799,33 +834,34 @@ for sa_name, sa_cfg in SERVICE_ACCOUNTS.items(): if status != 200: die(f"GET users for {sa_name} -> {status} {res}") + # Merge roles into attributes + sa_attributes = {**sa_cfg["attributes"], "roles": sa_cfg["roles"]} + 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] + # Update attributes (roles stored here, not in groups) status, sa_user = api("PATCH", f"/api/v3/core/users/{sa_user['pk']}/", { - "attributes": sa_cfg["attributes"], - "groups": group_pks, + "attributes": sa_attributes, "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") + print(f" {sa_name}: updated (roles: {sa_cfg['roles']})") 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, + "attributes": sa_attributes, }) if status not in (200, 201): die(f"POST service account {sa_name} -> {status} {sa_user}") - print(f" {sa_name}: created new service account") + print(f" {sa_name}: created (roles: {sa_cfg['roles']})") + + print(f" {sa_name}: roles={sa_cfg['roles']}") # Create OAuth provider for this service account # Supports both password grant (user claims) and client_credentials (fallback) @@ -844,14 +880,14 @@ for sa_name, sa_cfg in SERVICE_ACCOUNTS.items(): "authentication_flow": SA_AUTHENTICATION_FLOW_PK, # Headless flow for password grant "invalidation_flow": INVALIDATION_FLOW_PK, "signing_key": SIGNING_KEY_PK, - "property_mappings": SCOPE_PKS + [MEMORY_MAPPING_PK], + "property_mappings": SCOPE_PKS + [ROLES_MAPPING_PK, MEMORY_MAPPING_PK], "sub_mode": "hashed_user_id", "include_claims_in_id_token": True, "grant_types": sa_grant_types, "redirect_uris": [], }, patch_existing={ - "property_mappings": SCOPE_PKS + [MEMORY_MAPPING_PK], + "property_mappings": SCOPE_PKS + [ROLES_MAPPING_PK, MEMORY_MAPPING_PK], "grant_types": sa_grant_types, "authentication_flow": SA_AUTHENTICATION_FLOW_PK, }, @@ -868,9 +904,18 @@ for sa_name, sa_cfg in SERVICE_ACCOUNTS.items(): "meta_launch_url": "", }, ) - app_pks_for_binding.append((sa_name, sa_application["pk"])) - # Create App Password token for password grant + # Service account apps have NO policy bindings - client_secret is the access control. + # Clean up any existing bindings (from old provisioning logic). + status, existing_bindings = api("GET", f"/api/v3/policies/bindings/?target={sa_application['pk']}") + if status == 200: + for binding in existing_bindings.get("results", []): + api("DELETE", f"/api/v3/policies/bindings/{binding['pk']}/") + print(f" {sa_name}: removed stale binding {binding.get('group_obj', {}).get('name', binding['pk'])}") + + print(f" {sa_name}: provider pk={sa_provider['pk']} application pk={sa_application['pk']}") + + # Create App Password token for password grant (optional, client_credentials also works) # Authentik's password grant checks against Token with intent=app_password, not user password # Note: API doesn't allow setting key at creation, must use set_key endpoint after existing_app_password = kubectl_get_secret_key(sa_cfg["secret_ns"], sa_cfg["secret_name"], "APP_PASSWORD")