From 69a0a7627f8cd4836f1d49aef304cfebc201862d Mon Sep 17 00:00:00 2001 From: Mariusz Sabath Date: Thu, 5 Mar 2026 23:40:10 -0500 Subject: [PATCH] feat: add agent audience scope to platform clients during registration The client-registration sidecar now creates an audience scope for the agent and adds it to platform clients (e.g., the kagenti UI client) so AuthBridge-protected agents are reachable from the UI without manual Keycloak configuration. New env vars (all optional with sensible defaults): - PLATFORM_CLIENT_IDS: comma-separated client IDs (default: "kagenti") - KEYCLOAK_AUDIENCE_SCOPE_ENABLED: toggle scope management (default: "true") Closes: kagenti/kagenti-extensions#169 Assisted-By: Claude (Anthropic AI) Signed-off-by: Mariusz Sabath --- .../client_registration.py | 133 ++++++++++++++++++ .../demos/webhook/k8s/configmaps-webhook.yaml | 5 + .../webhook/injector/container_builder.go | 12 ++ .../injector/container_builder_test.go | 27 ++++ 4 files changed, 177 insertions(+) diff --git a/AuthBridge/client-registration/client_registration.py b/AuthBridge/client-registration/client_registration.py index 0866481bb..760cdef10 100644 --- a/AuthBridge/client-registration/client_registration.py +++ b/AuthBridge/client-registration/client_registration.py @@ -2,10 +2,16 @@ client_registration.py Registers a Keycloak client and stores its secret in a file. +Also creates an audience scope for the agent and adds it to +platform clients (e.g., the UI client) so they can reach +AuthBridge-protected agents without manual Keycloak configuration. + Idempotent: - Creates the client if it does not exist. - If the client already exists, reuses it. - Always retrieves and stores the client secret. +- Creates audience scope if it does not exist. +- Adds audience scope to platform clients if not already assigned. """ import os @@ -100,6 +106,96 @@ def get_client_id() -> str: raise Exception('SVID JWT does not contain a "sub" claim.') return decoded["sub"] + +def get_or_create_audience_scope( + keycloak_admin: KeycloakAdmin, scope_name: str, audience: str +) -> str | None: + """ + Create a client scope with an audience mapper if it doesn't exist. + Returns the scope ID, or None on failure. + """ + scopes = keycloak_admin.get_client_scopes() + for scope in scopes: + if scope["name"] == scope_name: + print(f'Audience scope "{scope_name}" already exists with ID: {scope["id"]}') + return scope["id"] + + try: + scope_id = keycloak_admin.create_client_scope( + { + "name": scope_name, + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "display.on.consent.screen": "true", + }, + } + ) + print(f'Created audience scope "{scope_name}": {scope_id}') + except KeycloakPostError as e: + print(f'Could not create audience scope "{scope_name}": {e}') + return None + + # Add audience mapper to the scope + mapper_payload = { + "name": scope_name, + "protocol": "openid-connect", + "protocolMapper": "oidc-audience-mapper", + "consentRequired": False, + "config": { + "included.custom.audience": audience, + "id.token.claim": "false", + "access.token.claim": "true", + "userinfo.token.claim": "false", + }, + } + try: + keycloak_admin.add_mapper_to_client_scope(scope_id, mapper_payload) + print(f'Added audience mapper for "{audience}" to scope "{scope_name}"') + except Exception as e: + print(f'Note: Could not add audience mapper (might already exist): {e}') + + return scope_id + + +def add_scope_to_platform_clients( + keycloak_admin: KeycloakAdmin, + scope_id: str, + scope_name: str, + platform_client_ids: list[str], +) -> None: + """ + Add an audience scope as a default client scope on each platform client. + This ensures existing clients (like the UI) include the agent's audience + in their tokens without requiring manual Keycloak configuration. + """ + for platform_client_id in platform_client_ids: + internal_id = keycloak_admin.get_client_id(platform_client_id) + if not internal_id: + print( + f'Platform client "{platform_client_id}" not found in realm. ' + f'Skipping scope assignment.' + ) + continue + try: + keycloak_admin.add_client_default_client_scope( + internal_id, scope_id, {} + ) + print( + f'Added scope "{scope_name}" to platform client "{platform_client_id}".' + ) + except Exception as e: + # 409 Conflict means it's already assigned — that's fine + if "409" in str(e) or "already" in str(e).lower(): + print( + f'Scope "{scope_name}" already assigned to "{platform_client_id}".' + ) + else: + print( + f'Could not add scope "{scope_name}" to "{platform_client_id}": {e}' + ) + + client_name = get_env_var("CLIENT_NAME") # If SPIFFE is enabled, use the client ID from the SVID JWT. @@ -176,4 +272,41 @@ def get_client_id() -> str: secret_file_path=secret_file_path, ) +# --- Audience scope management --- +# Create an audience scope for this agent and add it to platform clients +# so their tokens include this agent's audience (required by AuthBridge). +AUDIENCE_SCOPE_ENABLED = ( + get_env_var("KEYCLOAK_AUDIENCE_SCOPE_ENABLED", "true").lower() == "true" +) + +if AUDIENCE_SCOPE_ENABLED: + # Derive scope name from client_name (namespace/sa → agent-namespace-sa-aud) + scope_name = "agent-" + client_name.replace("/", "-") + "-aud" + + print(f'\n--- Audience scope management for "{scope_name}" ---') + + scope_id = get_or_create_audience_scope(keycloak_admin, scope_name, client_id) + + if scope_id: + # Add as realm default so new clients automatically get this scope + try: + keycloak_admin.add_default_default_client_scope(scope_id) + print(f'Added "{scope_name}" as realm default scope.') + except Exception as e: + print(f'Note: Could not add "{scope_name}" as realm default (might already exist): {e}') + + # Add to platform clients (e.g., the UI client) + platform_clients_raw = get_env_var("PLATFORM_CLIENT_IDS", "kagenti") + platform_client_ids = [ + c.strip() for c in platform_clients_raw.split(",") if c.strip() + ] + if platform_client_ids: + print(f"Adding scope to platform clients: {platform_client_ids}") + add_scope_to_platform_clients( + keycloak_admin, scope_id, scope_name, platform_client_ids + ) + else: + print(f'Warning: Could not create audience scope "{scope_name}". ' + f'Platform clients will not automatically include this agent\'s audience.') + print("Client registration complete.") diff --git a/AuthBridge/demos/webhook/k8s/configmaps-webhook.yaml b/AuthBridge/demos/webhook/k8s/configmaps-webhook.yaml index 406c5a897..6cb6f6b33 100644 --- a/AuthBridge/demos/webhook/k8s/configmaps-webhook.yaml +++ b/AuthBridge/demos/webhook/k8s/configmaps-webhook.yaml @@ -22,6 +22,11 @@ data: # them from your Pod/Deployment using env.valueFrom.secretKeyRef instead of this ConfigMap. KEYCLOAK_ADMIN_USERNAME: "admin" KEYCLOAK_ADMIN_PASSWORD: "admin" + # Comma-separated list of Keycloak client IDs that should receive the agent's + # audience scope. This allows existing clients (like the UI) to include the + # agent's audience in their tokens without manual Keycloak configuration. + # Default: "kagenti" (the platform UI client) + # PLATFORM_CLIENT_IDS: "kagenti" --- # authbridge-config ConfigMap - Used by envoy-proxy for token exchange and inbound validation diff --git a/kagenti-webhook/internal/webhook/injector/container_builder.go b/kagenti-webhook/internal/webhook/injector/container_builder.go index ea176f2ff..5e08d8544 100644 --- a/kagenti-webhook/internal/webhook/injector/container_builder.go +++ b/kagenti-webhook/internal/webhook/injector/container_builder.go @@ -162,6 +162,18 @@ func (b *ContainerBuilder) BuildClientRegistrationContainerWithSpireOption(name, Name: "SECRET_FILE_PATH", Value: "/shared/client-secret.txt", }, + { + Name: "PLATFORM_CLIENT_IDS", + ValueFrom: &corev1.EnvVarSource{ + ConfigMapKeyRef: &corev1.ConfigMapKeySelector{ + LocalObjectReference: corev1.LocalObjectReference{ + Name: "environments", + }, + Key: "PLATFORM_CLIENT_IDS", + Optional: ptr.To(true), + }, + }, + }, } // Volume mounts depend on SPIRE enablement diff --git a/kagenti-webhook/internal/webhook/injector/container_builder_test.go b/kagenti-webhook/internal/webhook/injector/container_builder_test.go index 14dc63eee..6a06f1cf0 100644 --- a/kagenti-webhook/internal/webhook/injector/container_builder_test.go +++ b/kagenti-webhook/internal/webhook/injector/container_builder_test.go @@ -107,3 +107,30 @@ func TestBuildEnvoyProxyContainer_Name(t *testing.T) { t.Errorf("container name = %q, want %q", container.Name, EnvoyProxyContainerName) } } + +func TestBuildClientRegistrationContainer_HasPlatformClientIDsEnv(t *testing.T) { + builder := NewContainerBuilder(config.CompiledDefaults()) + container := builder.BuildClientRegistrationContainerWithSpireOption("test-agent", "team1", true) + + found := false + for _, env := range container.Env { + if env.Name == "PLATFORM_CLIENT_IDS" { + found = true + if env.ValueFrom == nil || env.ValueFrom.ConfigMapKeyRef == nil { + t.Error("PLATFORM_CLIENT_IDS should reference a ConfigMap key") + break + } + if env.ValueFrom.ConfigMapKeyRef.Key != "PLATFORM_CLIENT_IDS" { + t.Errorf("PLATFORM_CLIENT_IDS key = %q, want PLATFORM_CLIENT_IDS", + env.ValueFrom.ConfigMapKeyRef.Key) + } + if env.ValueFrom.ConfigMapKeyRef.Optional == nil || !*env.ValueFrom.ConfigMapKeyRef.Optional { + t.Error("PLATFORM_CLIENT_IDS should be optional") + } + break + } + } + if !found { + t.Error("client-registration container missing PLATFORM_CLIENT_IDS env var") + } +}