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"]
|
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 -
|
# 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
|
# 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
|
# 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.
|
# They authenticate via password grant to get JWTs with user claims.
|
||||||
print("\n[SERVICE ACCOUNTS] Creating service accounts for API access...")
|
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 = {
|
SERVICE_ACCOUNTS = {
|
||||||
"portfolio-agent": {
|
"portfolio-agent": {
|
||||||
"groups": ["llm-users", "memory-users"], # LLM inference + memory read
|
"roles": ["llm:inference", "memory:read"],
|
||||||
"attributes": {
|
"attributes": {
|
||||||
"memory_projects": ["homelab", "portfolio"],
|
"memory_projects": ["homelab", "portfolio"],
|
||||||
"memory_visibility": "public",
|
"memory_visibility": "public",
|
||||||
@@ -780,7 +815,7 @@ SERVICE_ACCOUNTS = {
|
|||||||
"secret_name": "portfolio-agent-oidc",
|
"secret_name": "portfolio-agent-oidc",
|
||||||
},
|
},
|
||||||
"memory-agent": {
|
"memory-agent": {
|
||||||
"groups": ["llm-users", "memory-writers"], # Internal memory service
|
"roles": ["llm:inference", "memory:read", "memory:write"],
|
||||||
"attributes": {
|
"attributes": {
|
||||||
"memory_projects": ["*"],
|
"memory_projects": ["*"],
|
||||||
"memory_visibility": "private",
|
"memory_visibility": "private",
|
||||||
@@ -799,33 +834,34 @@ for sa_name, sa_cfg in SERVICE_ACCOUNTS.items():
|
|||||||
if status != 200:
|
if status != 200:
|
||||||
die(f"GET users for {sa_name} -> {status} {res}")
|
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"):
|
if res.get("results"):
|
||||||
sa_user = res["results"][0]
|
sa_user = res["results"][0]
|
||||||
# Update attributes and groups
|
# Update attributes (roles stored here, not in 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']}/", {
|
status, sa_user = api("PATCH", f"/api/v3/core/users/{sa_user['pk']}/", {
|
||||||
"attributes": sa_cfg["attributes"],
|
"attributes": sa_attributes,
|
||||||
"groups": group_pks,
|
|
||||||
"is_active": True,
|
"is_active": True,
|
||||||
})
|
})
|
||||||
if status not in (200, 201):
|
if status not in (200, 201):
|
||||||
die(f"PATCH service account {sa_name} -> {status} {sa_user}")
|
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:
|
else:
|
||||||
# Create new service account user
|
# 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/", {
|
status, sa_user = api("POST", "/api/v3/core/users/", {
|
||||||
"username": sa_name,
|
"username": sa_name,
|
||||||
"name": f"Service Account: {sa_name}",
|
"name": f"Service Account: {sa_name}",
|
||||||
"type": "service_account",
|
"type": "service_account",
|
||||||
"path": "service-accounts",
|
"path": "service-accounts",
|
||||||
"is_active": True,
|
"is_active": True,
|
||||||
"attributes": sa_cfg["attributes"],
|
"attributes": sa_attributes,
|
||||||
"groups": group_pks,
|
|
||||||
})
|
})
|
||||||
if status not in (200, 201):
|
if status not in (200, 201):
|
||||||
die(f"POST service account {sa_name} -> {status} {sa_user}")
|
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
|
# Create OAuth provider for this service account
|
||||||
# Supports both password grant (user claims) and client_credentials (fallback)
|
# 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
|
"authentication_flow": SA_AUTHENTICATION_FLOW_PK, # Headless flow for password grant
|
||||||
"invalidation_flow": INVALIDATION_FLOW_PK,
|
"invalidation_flow": INVALIDATION_FLOW_PK,
|
||||||
"signing_key": SIGNING_KEY_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",
|
"sub_mode": "hashed_user_id",
|
||||||
"include_claims_in_id_token": True,
|
"include_claims_in_id_token": True,
|
||||||
"grant_types": sa_grant_types,
|
"grant_types": sa_grant_types,
|
||||||
"redirect_uris": [],
|
"redirect_uris": [],
|
||||||
},
|
},
|
||||||
patch_existing={
|
patch_existing={
|
||||||
"property_mappings": SCOPE_PKS + [MEMORY_MAPPING_PK],
|
"property_mappings": SCOPE_PKS + [ROLES_MAPPING_PK, MEMORY_MAPPING_PK],
|
||||||
"grant_types": sa_grant_types,
|
"grant_types": sa_grant_types,
|
||||||
"authentication_flow": SA_AUTHENTICATION_FLOW_PK,
|
"authentication_flow": SA_AUTHENTICATION_FLOW_PK,
|
||||||
},
|
},
|
||||||
@@ -868,9 +904,18 @@ for sa_name, sa_cfg in SERVICE_ACCOUNTS.items():
|
|||||||
"meta_launch_url": "",
|
"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
|
# 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
|
# 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")
|
existing_app_password = kubectl_get_secret_key(sa_cfg["secret_ns"], sa_cfg["secret_name"], "APP_PASSWORD")
|
||||||
|
|||||||
Reference in New Issue
Block a user