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:
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user