Add LiteLLM RBAC: akP-litellm groups + role claim mapping #10

Merged
benvin merged 1 commits from benvin/litellm-rbac into main 2026-07-19 22:00:59 +10:00
7 changed files with 69 additions and 0 deletions
@@ -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
+3
View File
@@ -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
+11
View File
@@ -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
+1
View File
@@ -3,3 +3,4 @@ permissions:
- akP-grafana-admin
- akP-argocd-admin
- akP-rancher-admin
- akP-litellm-admin
+1
View File
@@ -3,3 +3,4 @@ permissions:
- akP-grafana-user
- akP-argocd-user
- akP-rancher-user
- akP-litellm-user
+38
View File
@@ -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
+12
View File
@@ -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 = {}
}