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)
This commit is contained in:
2026-09-03 19:23:06 -07:00
parent f1e5fe58f4
commit 8f7004c946
+60 -15
View File
@@ -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: "<api>:<action>" 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")