diff --git a/config/permissions/akP-litellm-admin.yaml b/config/permissions/akP-litellm-admin.yaml new file mode 100644 index 0000000..dea076f --- /dev/null +++ b/config/permissions/akP-litellm-admin.yaml @@ -0,0 +1,3 @@ +# Permission group akP-litellm-admin (name = filename). Grants admin +# access to litellm: bound to the litellm application and mapped to its role. +application: litellm diff --git a/config/permissions/akP-litellm-user.yaml b/config/permissions/akP-litellm-user.yaml new file mode 100644 index 0000000..821484b --- /dev/null +++ b/config/permissions/akP-litellm-user.yaml @@ -0,0 +1,3 @@ +# Permission group akP-litellm-user (name = filename). Grants user +# access to litellm: bound to the litellm application and mapped to its role. +application: litellm diff --git a/config/providers_oauth2/litellm.yaml b/config/providers_oauth2/litellm.yaml index 061dd04..ebc28c7 100644 --- a/config/providers_oauth2/litellm.yaml +++ b/config/providers_oauth2/litellm.yaml @@ -16,3 +16,14 @@ scope_mappings: redirect_uris: - matching_mode: strict url: https://litellm.k8s.syd1.au.unkin.net/sso/callback +# Emit a `litellm_role` claim from group membership (rules in privilege order). +# LiteLLM requests the `litellm_role` scope and reads it via +# GENERIC_USER_ROLE_ATTRIBUTE. akP-litellm-admin is inherited by akR-global-admin. +role_mappings: + claim: litellm_role + default: internal_user_view_only + rules: + - group: akP-litellm-admin + role: proxy_admin + - group: akP-litellm-user + role: internal_user diff --git a/config/roles/akR-global-admin.yaml b/config/roles/akR-global-admin.yaml index cb24e50..6c3af26 100644 --- a/config/roles/akR-global-admin.yaml +++ b/config/roles/akR-global-admin.yaml @@ -3,3 +3,4 @@ permissions: - akP-grafana-admin - akP-argocd-admin - akP-rancher-admin + - akP-litellm-admin diff --git a/config/roles/akR-standard-user.yaml b/config/roles/akR-standard-user.yaml index 14b8836..705ad69 100644 --- a/config/roles/akR-standard-user.yaml +++ b/config/roles/akR-standard-user.yaml @@ -3,3 +3,4 @@ permissions: - akP-grafana-user - akP-argocd-user - akP-rancher-user + - akP-litellm-user diff --git a/modules/authentik/main.tf b/modules/authentik/main.tf index fa84126..9c2bf06 100644 --- a/modules/authentik/main.tf +++ b/modules/authentik/main.tf @@ -65,6 +65,43 @@ resource "authentik_provider_saml" "this" { signing_kp = each.value.signing_kp } +# Build a Python expression per app that emits a role claim from the user's +# effective (hierarchical) group membership. Assembled from the config rules so +# the generated code has predictable indentation (no template-directive quirks). +locals { + role_mapping_expr = { + for k, v in var.providers_oauth2 : k => join("\n", concat( + [ + "groups = {}", + "pending = list(user.ak_groups.all())", + "while pending:", + " grp = pending.pop()", + " if grp.pk in groups:", + " continue", + " groups[grp.pk] = grp.name", + " pending += list(grp.parents.all())", + "names = set(groups.values())", + ], + flatten([ + for rule in coalesce(try(v.role_mappings.rules, null), []) : [ + "if ${jsonencode(rule.group)} in names:", + " return {${jsonencode(try(v.role_mappings.claim, ""))}: ${jsonencode(rule.role)}}", + ] + ]), + ["return {${jsonencode(try(v.role_mappings.claim, ""))}: ${jsonencode(try(v.role_mappings.default, ""))}}"], + )) + if v.role_mappings != null + } +} + +resource "authentik_property_mapping_provider_scope" "role" { + for_each = { for k, v in var.providers_oauth2 : k => v if v.role_mappings != null } + + name = "unkin: ${each.key} role" + scope_name = each.value.role_mappings.claim + expression = local.role_mapping_expr[each.key] +} + # Resolve oauth2 flows by slug and scope mappings by managed identifier, and # read client secrets from Vault so nothing sensitive is committed. data "authentik_flow" "oauth2_authorization" { @@ -100,6 +137,7 @@ resource "authentik_provider_oauth2" "this" { property_mappings = concat( try(data.authentik_property_mapping_provider_scope.oauth2[each.key].ids, []), [authentik_property_mapping_provider_scope.groups_hierarchical.id], + try([authentik_property_mapping_provider_scope.role[each.key].id], []), ) signing_key = each.value.signing_key access_token_validity = each.value.access_token_validity diff --git a/modules/authentik/variables.tf b/modules/authentik/variables.tf index 3569aec..3395928 100644 --- a/modules/authentik/variables.tf +++ b/modules/authentik/variables.tf @@ -76,6 +76,18 @@ variable "providers_oauth2" { })), []) signing_key = optional(string, null) access_token_validity = optional(string, "minutes=10") + # Optional app-role claim computed from (hierarchical) group membership: emit + # `claim` = the first matching rule's role, else `default`. The app requests + # `claim` as a scope and reads it (e.g. LiteLLM GENERIC_USER_ROLE_ATTRIBUTE). + # rules are evaluated in order, so list highest privilege first. + role_mappings = optional(object({ + claim = string + default = string + rules = list(object({ + group = string # permission group name (akP-*) + role = string # app role value + })) + }), null) })) default = {} }