From 9098c7dcfcd4d16dde20ef66e761998b3ff092c9 Mon Sep 17 00:00:00 2001 From: Ben Vincent Date: Sun, 19 Jul 2026 02:19:19 +1000 Subject: [PATCH 1/2] Add LiteLLM OAuth2/OIDC provider SSO for LiteLLM (litellm.k8s.syd1.au.unkin.net) via Authentik generic OIDC. Confidential OAuth2 provider + application, openid/email/profile scopes, /sso/callback redirect. client_secret read from Vault at kv/kubernetes/namespace/litellm/default/oauth-credentials (seeded). --- config/providers_oauth2/litellm.yaml | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 config/providers_oauth2/litellm.yaml diff --git a/config/providers_oauth2/litellm.yaml b/config/providers_oauth2/litellm.yaml new file mode 100644 index 0000000..061dd04 --- /dev/null +++ b/config/providers_oauth2/litellm.yaml @@ -0,0 +1,18 @@ +# OAuth2/OIDC provider + application for LiteLLM +# (litellm.k8s.syd1.au.unkin.net). client_secret is read from Vault, not committed. +# LiteLLM's generic SSO redirects back to /sso/callback. +name: LiteLLM +authorization_flow: default-provider-authorization-implicit-consent +invalidation_flow: default-provider-invalidation-flow +client_type: confidential +client_id: litellm +client_secret_vault: + mount: kv + path: kubernetes/namespace/litellm/default/oauth-credentials +scope_mappings: + - goauthentik.io/providers/oauth2/scope-openid + - goauthentik.io/providers/oauth2/scope-email + - goauthentik.io/providers/oauth2/scope-profile +redirect_uris: + - matching_mode: strict + url: https://litellm.k8s.syd1.au.unkin.net/sso/callback From 761062716814ca43b67fde7c2a52a194179e6bed Mon Sep 17 00:00:00 2001 From: Ben Vincent Date: Sun, 19 Jul 2026 18:42:36 +1000 Subject: [PATCH 2/2] Add LiteLLM RBAC: akP-litellm groups + role claim mapping Bring LiteLLM into the two-tier RBAC and map groups to LiteLLM roles. - akP-litellm-admin / akP-litellm-user permission groups (bound to the litellm app for access); added to akR-global-admin / akR-standard-user roles. - Generic per-provider role_mappings: emit an app role claim computed from effective (hierarchical) group membership. LiteLLM: emits `litellm_role` (proxy_admin for akP-litellm-admin, internal_user for akP-litellm-user, else internal_user_view_only); LiteLLM reads it via GENERIC_USER_ROLE_ATTRIBUTE. Validated: plan 5 to add, 3 to change; generated role expression renders correctly. --- config/permissions/akP-litellm-admin.yaml | 3 ++ config/permissions/akP-litellm-user.yaml | 3 ++ config/providers_oauth2/litellm.yaml | 11 +++++++ config/roles/akR-global-admin.yaml | 1 + config/roles/akR-standard-user.yaml | 1 + modules/authentik/main.tf | 38 +++++++++++++++++++++++ modules/authentik/variables.tf | 12 +++++++ 7 files changed, 69 insertions(+) create mode 100644 config/permissions/akP-litellm-admin.yaml create mode 100644 config/permissions/akP-litellm-user.yaml 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 = {} }