diff --git a/AuthBridge/AuthProxy/go-processor/main.go b/AuthBridge/AuthProxy/go-processor/main.go index f9cdf3671..e02710754 100644 --- a/AuthBridge/AuthProxy/go-processor/main.go +++ b/AuthBridge/AuthProxy/go-processor/main.go @@ -53,6 +53,11 @@ const defaultRoutesConfigPath = "/etc/authproxy/routes.yaml" var globalResolver resolver.TargetResolver +// defaultOutboundPolicy controls behavior for outbound requests that don't match +// any route in routes.yaml. "passthrough" (default) skips token exchange; +// "exchange" attempts exchange using global config (legacy behavior). +var defaultOutboundPolicy = "passthrough" + // defaultBypassInboundPaths are paths that skip inbound JWT validation by default. // These cover common public endpoints: Agent Card discovery, health/readiness probes. var defaultBypassInboundPaths = []string{"/.well-known/*", "/healthz", "/readyz", "/livez"} @@ -269,6 +274,16 @@ func denyRequest(message string) *v3.ProcessingResponse { } } +// passthroughResponse returns an empty headers response that forwards the +// request unchanged. Used when token exchange is not needed or not configured. +func passthroughResponse() *v3.ProcessingResponse { + return &v3.ProcessingResponse{ + Response: &v3.ProcessingResponse_RequestHeaders{ + RequestHeaders: &v3.HeadersResponse{}, + }, + } +} + // denyOutboundRequest returns a 503 Service Unavailable when outbound token // acquisition fails, preventing unauthenticated requests from reaching downstream. func denyOutboundRequest(message string) *v3.ProcessingResponse { @@ -490,11 +505,15 @@ func (p *processor) handleOutbound(ctx context.Context, headers *core.HeaderMap) // Handle passthrough routes - skip token exchange if targetConfig != nil && targetConfig.Passthrough { log.Printf("[Resolver] Passthrough enabled for host %q, skipping token exchange", requestHost) - return &v3.ProcessingResponse{ - Response: &v3.ProcessingResponse_RequestHeaders{ - RequestHeaders: &v3.HeadersResponse{}, - }, - } + return passthroughResponse() + } + + // When no route matches and policy is "passthrough", skip token exchange. + // This prevents unintended exchange for LLM APIs, telemetry, and other + // services that don't use Keycloak tokens. + if targetConfig == nil && defaultOutboundPolicy == "passthrough" { + log.Printf("[Outbound] No route for host %q, default policy is passthrough — skipping token exchange", requestHost) + return passthroughResponse() } // Get global configuration (from files or env vars) @@ -595,11 +614,7 @@ func (p *processor) handleOutbound(ctx context.Context, headers *core.HeaderMap) targetAudience != "", targetScopes != "") } - return &v3.ProcessingResponse{ - Response: &v3.ProcessingResponse_RequestHeaders{ - RequestHeaders: &v3.HeadersResponse{}, - }, - } + return passthroughResponse() } func (p *processor) Process(stream v3.ExternalProcessor_ProcessServer) error { @@ -702,6 +717,17 @@ func main() { } log.Printf("[Inbound] Bypass paths: %v", bypassInboundPaths) + // Initialize outbound policy (default: passthrough) + if policy, ok := os.LookupEnv("DEFAULT_OUTBOUND_POLICY"); ok { + policy = strings.TrimSpace(strings.ToLower(policy)) + if policy == "exchange" || policy == "passthrough" { + defaultOutboundPolicy = policy + } else { + log.Printf("[Outbound] Unknown DEFAULT_OUTBOUND_POLICY %q, using default %q", policy, defaultOutboundPolicy) + } + } + log.Printf("[Outbound] Default outbound policy: %s", defaultOutboundPolicy) + // Initialize the target resolver configPath := os.Getenv("ROUTES_CONFIG_PATH") if configPath == "" { diff --git a/AuthBridge/AuthProxy/go-processor/main_test.go b/AuthBridge/AuthProxy/go-processor/main_test.go index b74a6b2dd..68d688a06 100644 --- a/AuthBridge/AuthProxy/go-processor/main_test.go +++ b/AuthBridge/AuthProxy/go-processor/main_test.go @@ -1,6 +1,20 @@ package main -import "testing" +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + core "github.com/envoyproxy/go-control-plane/envoy/config/core/v3" + v3 "github.com/envoyproxy/go-control-plane/envoy/service/ext_proc/v3" + "github.com/kagenti/kagenti-extensions/AuthBridge/AuthProxy/go-processor/internal/resolver" +) func TestMatchBypassPath(t *testing.T) { tests := []struct { @@ -168,3 +182,362 @@ func TestDefaultBypassPaths(t *testing.T) { } } } + +// --- Test helpers --- + +// buildHeaders creates a core.HeaderMap with the given host and optional Authorization header. +func buildHeaders(host, authHeader string) *core.HeaderMap { + headers := []*core.HeaderValue{ + {Key: ":authority", RawValue: []byte(host)}, + {Key: ":path", RawValue: []byte("/")}, + {Key: ":method", RawValue: []byte("GET")}, + } + if authHeader != "" { + headers = append(headers, &core.HeaderValue{ + Key: "authorization", + RawValue: []byte(authHeader), + }) + } + return &core.HeaderMap{Headers: headers} +} + +// setupTestResolver creates a StaticResolver from inline YAML for testing. +func setupTestResolver(t *testing.T, yaml string) resolver.TargetResolver { + t.Helper() + dir := t.TempDir() + path := filepath.Join(dir, "routes.yaml") + if err := os.WriteFile(path, []byte(yaml), 0644); err != nil { + t.Fatalf("failed to write test routes.yaml: %v", err) + } + r, err := resolver.NewStaticResolver(path) + if err != nil { + t.Fatalf("failed to create resolver: %v", err) + } + return r +} + +// emptyResolver returns a resolver with no routes (simulates missing routes.yaml). +func emptyResolver(t *testing.T) resolver.TargetResolver { + t.Helper() + r, err := resolver.NewStaticResolver("/nonexistent/path/routes.yaml") + if err != nil { + t.Fatalf("unexpected error creating empty resolver: %v", err) + } + return r +} + +// mockKeycloak starts a test HTTP server that mimics Keycloak's token endpoint. +func mockKeycloak(t *testing.T, statusCode int, responseBody interface{}) *httptest.Server { + t.Helper() + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(statusCode) + json.NewEncoder(w).Encode(responseBody) + })) +} + +// isPassthrough returns true if the response forwards the request unchanged. +func isPassthrough(resp *v3.ProcessingResponse) bool { + rh, ok := resp.Response.(*v3.ProcessingResponse_RequestHeaders) + if !ok { + return false + } + return rh.RequestHeaders.Response == nil || rh.RequestHeaders.Response.HeaderMutation == nil +} + +// isDenied returns true if the response is an ImmediateResponse (503). +func isDenied(resp *v3.ProcessingResponse) bool { + _, ok := resp.Response.(*v3.ProcessingResponse_ImmediateResponse) + return ok +} + +// hasReplacedAuthHeader returns true if the response mutates the Authorization header. +func hasReplacedAuthHeader(resp *v3.ProcessingResponse) (string, bool) { + rh, ok := resp.Response.(*v3.ProcessingResponse_RequestHeaders) + if !ok { + return "", false + } + if rh.RequestHeaders.Response == nil || rh.RequestHeaders.Response.HeaderMutation == nil { + return "", false + } + for _, h := range rh.RequestHeaders.Response.HeaderMutation.SetHeaders { + if strings.EqualFold(h.Header.Key, "authorization") { + return string(h.Header.RawValue), true + } + } + return "", false +} + +type savedGlobals struct { + policy string + resolver resolver.TargetResolver + clientID string + clientSecret string + tokenURL string + targetAudience string + targetScopes string +} + +func saveGlobals() savedGlobals { + globalConfig.mu.RLock() + defer globalConfig.mu.RUnlock() + return savedGlobals{ + policy: defaultOutboundPolicy, + resolver: globalResolver, + clientID: globalConfig.ClientID, + clientSecret: globalConfig.ClientSecret, + tokenURL: globalConfig.TokenURL, + targetAudience: globalConfig.TargetAudience, + targetScopes: globalConfig.TargetScopes, + } +} + +func restoreGlobals(saved savedGlobals) { + defaultOutboundPolicy = saved.policy + globalResolver = saved.resolver + globalConfig.mu.Lock() + defer globalConfig.mu.Unlock() + globalConfig.ClientID = saved.clientID + globalConfig.ClientSecret = saved.clientSecret + globalConfig.TokenURL = saved.tokenURL + globalConfig.TargetAudience = saved.targetAudience + globalConfig.TargetScopes = saved.targetScopes +} + +func setGlobalConfig(clientID, clientSecret, tokenURL, audience, scopes string) { + globalConfig.mu.Lock() + defer globalConfig.mu.Unlock() + globalConfig.ClientID = clientID + globalConfig.ClientSecret = clientSecret + globalConfig.TokenURL = tokenURL + globalConfig.TargetAudience = audience + globalConfig.TargetScopes = scopes +} + +// --- Test: Default agents (weather-service pattern) --- + +// TestDefaultOutboundPolicy verifies that agents without routes.yaml get passthrough +// behavior by default. This models the weather-service scenario: an agent calling +// Ollama (LLM), otel-collector (telemetry), or any other service that doesn't +// need Keycloak token exchange. +func TestDefaultOutboundPolicy(t *testing.T) { + tests := []struct { + name string + policy string + host string + authHeader string + globalConfig bool // whether to set complete global config + expectPassthru bool + }{ + { + name: "passthrough_default_ollama", + policy: "passthrough", + host: "ollama-service.team1.svc.cluster.local", + expectPassthru: true, + }, + { + name: "passthrough_default_otel", + policy: "passthrough", + host: "otel-collector.kagenti-system.svc.cluster.local:8335", + expectPassthru: true, + }, + { + name: "passthrough_default_any_host", + policy: "passthrough", + host: "random-service.default.svc.cluster.local", + expectPassthru: true, + }, + { + name: "passthrough_with_auth_header", + policy: "passthrough", + host: "ollama-service.team1.svc.cluster.local", + authHeader: "Bearer sk-some-api-key", + expectPassthru: true, + }, + { + name: "passthrough_unset_env_defaults_to_passthrough", + policy: "", // empty = not set, should keep default "passthrough" + host: "any-host.example.com", + expectPassthru: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + saved := saveGlobals() + defer restoreGlobals(saved) + + if tt.policy != "" { + defaultOutboundPolicy = tt.policy + } + globalResolver = emptyResolver(t) + + if tt.globalConfig { + setGlobalConfig("test-client", "test-secret", "http://keycloak/token", "test-aud", "openid") + } else { + setGlobalConfig("", "", "", "", "") + } + + p := &processor{} + headers := buildHeaders(tt.host, tt.authHeader) + resp := p.handleOutbound(context.Background(), headers) + + if tt.expectPassthru && !isPassthrough(resp) { + t.Errorf("expected passthrough for host %q with policy %q, but got non-passthrough response", tt.host, tt.policy) + } + if !tt.expectPassthru && isPassthrough(resp) { + t.Errorf("expected non-passthrough for host %q with policy %q, but got passthrough", tt.host, tt.policy) + } + }) + } +} + +// TestDefaultOutboundPolicyLegacyExchange verifies backward compatibility: +// when DEFAULT_OUTBOUND_POLICY=exchange and global config is complete, unmatched +// hosts still get token exchange (the old behavior). +func TestDefaultOutboundPolicyLegacyExchange(t *testing.T) { + saved := saveGlobals() + defer restoreGlobals(saved) + + keycloak := mockKeycloak(t, http.StatusOK, tokenExchangeResponse{ + AccessToken: "legacy-exchanged-token", + TokenType: "Bearer", + ExpiresIn: 300, + }) + defer keycloak.Close() + + defaultOutboundPolicy = "exchange" + globalResolver = emptyResolver(t) + setGlobalConfig("test-client", "test-secret", keycloak.URL, "test-audience", "openid test-aud") + + p := &processor{} + headers := buildHeaders("random-service.example.com", "Bearer some-jwt-token") + resp := p.handleOutbound(context.Background(), headers) + + token, ok := hasReplacedAuthHeader(resp) + if !ok { + if isDenied(resp) { + t.Fatal("legacy exchange policy: expected token exchange to succeed, but got 503 denial") + } + t.Fatal("legacy exchange policy: expected Authorization header to be replaced, but got passthrough") + } + if token != "Bearer legacy-exchanged-token" { + t.Errorf("expected 'Bearer legacy-exchanged-token', got %q", token) + } +} + +// --- Test: Github-issue agent pattern (route-based exchange) --- + +// TestOutboundPolicyWithRoutes verifies that agents with routes.yaml entries +// get token exchange only for matched hosts. This models the github-issue agent: +// calls to github-tool get exchange, calls to the LLM pass through. +func TestOutboundPolicyWithRoutes(t *testing.T) { + saved := saveGlobals() + defer restoreGlobals(saved) + + keycloak := mockKeycloak(t, http.StatusOK, tokenExchangeResponse{ + AccessToken: "exchanged-token-for-github-tool", + TokenType: "Bearer", + ExpiresIn: 300, + }) + defer keycloak.Close() + + routesYAML := fmt.Sprintf(` +- host: "github-issue-tool-headless.team1.svc.cluster.local" + target_audience: "github-tool" + token_scopes: "openid github-tool-aud github-full-access" + token_url: %q +- host: "otel-collector.*.svc.cluster.local" + passthrough: true +`, keycloak.URL) + + defaultOutboundPolicy = "passthrough" + globalResolver = setupTestResolver(t, routesYAML) + setGlobalConfig("spiffe://localtest.me/ns/team1/sa/github-issue-agent", "client-secret-123", keycloak.URL, "", "") + + t.Run("route_match_exchanges_token", func(t *testing.T) { + p := &processor{} + headers := buildHeaders("github-issue-tool-headless.team1.svc.cluster.local", "Bearer valid-jwt-from-keycloak") + resp := p.handleOutbound(context.Background(), headers) + + token, ok := hasReplacedAuthHeader(resp) + if !ok { + if isDenied(resp) { + t.Fatal("expected exchange to succeed, but got 503 denial") + } + t.Fatal("expected Authorization header to be replaced, but got passthrough") + } + if token != "Bearer exchanged-token-for-github-tool" { + t.Errorf("expected 'Bearer exchanged-token-for-github-tool', got %q", token) + } + }) + + t.Run("route_match_no_auth_header_uses_client_credentials", func(t *testing.T) { + p := &processor{} + headers := buildHeaders("github-issue-tool-headless.team1.svc.cluster.local", "") + resp := p.handleOutbound(context.Background(), headers) + + token, ok := hasReplacedAuthHeader(resp) + if !ok { + if isDenied(resp) { + t.Fatal("expected client_credentials to succeed, but got 503 denial") + } + t.Fatal("expected Authorization header to be injected via client_credentials, but got passthrough") + } + if !strings.HasPrefix(token, "Bearer ") { + t.Errorf("expected Bearer token, got %q", token) + } + }) + + t.Run("unmatched_host_still_passthrough", func(t *testing.T) { + p := &processor{} + headers := buildHeaders("api.openai.com", "Bearer sk-openai-api-key") + resp := p.handleOutbound(context.Background(), headers) + + if !isPassthrough(resp) { + t.Error("expected passthrough for unmatched host api.openai.com, but got non-passthrough response") + } + }) + + t.Run("route_passthrough_explicit", func(t *testing.T) { + p := &processor{} + headers := buildHeaders("otel-collector.kagenti-system.svc.cluster.local", "") + resp := p.handleOutbound(context.Background(), headers) + + if !isPassthrough(resp) { + t.Error("expected passthrough for otel-collector (explicit passthrough route), but got non-passthrough response") + } + }) +} + +// TestOutboundPolicyRouteMatchExchangeFails verifies that when a route matches but +// Keycloak returns an error, the proxy returns 503 (not passthrough). +func TestOutboundPolicyRouteMatchExchangeFails(t *testing.T) { + saved := saveGlobals() + defer restoreGlobals(saved) + + keycloak := mockKeycloak(t, http.StatusBadRequest, map[string]string{ + "error": "invalid_request", + "error_description": "Invalid token", + }) + defer keycloak.Close() + + routesYAML := fmt.Sprintf(` +- host: "github-issue-tool-headless.team1.svc.cluster.local" + target_audience: "github-tool" + token_scopes: "openid github-tool-aud" + token_url: %q +`, keycloak.URL) + + defaultOutboundPolicy = "passthrough" + globalResolver = setupTestResolver(t, routesYAML) + setGlobalConfig("test-client", "test-secret", keycloak.URL, "", "") + + p := &processor{} + headers := buildHeaders("github-issue-tool-headless.team1.svc.cluster.local", "Bearer some-invalid-jwt") + resp := p.handleOutbound(context.Background(), headers) + + if !isDenied(resp) { + t.Error("expected 503 denial when exchange fails for a routed host, but got non-denied response") + } +} diff --git a/AuthBridge/CLAUDE.md b/AuthBridge/CLAUDE.md index c0b067f82..010c27b6e 100644 --- a/AuthBridge/CLAUDE.md +++ b/AuthBridge/CLAUDE.md @@ -64,16 +64,20 @@ The core ext-proc that handles both traffic directions: - Removes `x-authbridge-direction` header before forwarding to app **Outbound path** (no direction header): -- Reads `Authorization: Bearer ` from request -- Performs OAuth 2.0 Token Exchange (RFC 8693) against Keycloak -- Replaces Authorization header with the exchanged token -- If config is incomplete or exchange fails, passes request through unchanged +- Resolves the destination host against `routes.yaml` (per-host exchange config) +- If the host matches a `passthrough: true` route, forwards unchanged +- If no route matches and `DEFAULT_OUTBOUND_POLICY` is `"passthrough"` (the default), forwards unchanged +- If a route matches with audience/scopes, performs OAuth 2.0 Token Exchange (RFC 8693) +- Replaces the Authorization header with the exchanged token on success +- Returns 503 if exchange fails for a routed host (prevents unauthenticated calls) **Configuration loading:** - Waits up to 60s for credential files from client-registration (`waitForCredentials`) - Reads `CLIENT_ID` from `/shared/client-id.txt` (file) or `CLIENT_ID` env var (fallback) - Reads `CLIENT_SECRET` from `/shared/client-secret.txt` (file) or `CLIENT_SECRET` env var (fallback) - Static config from env vars: `TOKEN_URL`, `ISSUER`, `EXPECTED_AUDIENCE`, `TARGET_AUDIENCE`, `TARGET_SCOPES` +- `DEFAULT_OUTBOUND_POLICY` env var: `"passthrough"` (default) or `"exchange"` (legacy) +- Routes from `ROUTES_CONFIG_PATH` (default `/etc/authproxy/routes.yaml`) - JWKS URL is derived from TOKEN_URL: replaces `/token` suffix with `/certs` **Key types:** @@ -156,14 +160,36 @@ There are **four** setup scripts for different demo scenarios: ## Required ConfigMaps for Webhook Injection -When the kagenti-webhook injects sidecars, four ConfigMaps must exist in the target namespace. All are defined in `demos/webhook/k8s/configmaps-webhook.yaml`: +When the kagenti-webhook injects sidecars, these ConfigMaps must exist in the target namespace. All required ones are defined in `demos/webhook/k8s/configmaps-webhook.yaml`: + +| ConfigMap | Consumer | Required | Key Fields | +|-----------|----------|----------|------------| +| `environments` | client-registration | Yes | `KEYCLOAK_URL`, `KEYCLOAK_REALM`, `KEYCLOAK_ADMIN_USERNAME`, `KEYCLOAK_ADMIN_PASSWORD`, `SPIRE_ENABLED` | +| `authbridge-config` | envoy-proxy (ext-proc) | Yes | `TOKEN_URL`, `ISSUER`, `TARGET_AUDIENCE`, `TARGET_SCOPES`, `DEFAULT_OUTBOUND_POLICY` | +| `authproxy-routes` | envoy-proxy (ext-proc) | No | `routes.yaml` — per-target token exchange routes (host, audience, scopes, passthrough) | +| `spiffe-helper-config` | spiffe-helper | Yes (SPIRE) | `helper.conf` (SPIRE agent address, cert paths, JWT SVID config) | +| `envoy-config` | envoy-proxy | Yes | `envoy.yaml` (full Envoy configuration) | + +### Outbound Token Exchange Policy + +The go-processor defaults to **passthrough** for outbound requests that don't match any route in `authproxy-routes`. This means agents work out-of-the-box with any LLM provider without configuring exclusions. Token exchange only happens for hosts with explicit entries in `authproxy-routes`. + +To configure per-target exchange, create an `authproxy-routes` ConfigMap: +```yaml +apiVersion: v1 +kind: ConfigMap +metadata: + name: authproxy-routes +data: + routes.yaml: | + - host: "target-service.team1.svc.cluster.local" + target_audience: "target-client-id" + token_scopes: "openid target-aud" + - host: "otel-collector.*.svc.cluster.local" + passthrough: true +``` -| ConfigMap | Consumer | Key Fields | -|-----------|----------|------------| -| `environments` | client-registration | `KEYCLOAK_URL`, `KEYCLOAK_REALM`, `KEYCLOAK_ADMIN_USERNAME`, `KEYCLOAK_ADMIN_PASSWORD`, `SPIRE_ENABLED` | -| `authbridge-config` | envoy-proxy (ext-proc) | `TOKEN_URL`, `ISSUER`, `TARGET_AUDIENCE`, `TARGET_SCOPES` | -| `spiffe-helper-config` | spiffe-helper | `helper.conf` (SPIRE agent address, cert paths, JWT SVID config) | -| `envoy-config` | envoy-proxy | `envoy.yaml` (full Envoy configuration) | +Set `DEFAULT_OUTBOUND_POLICY: "exchange"` in `authbridge-config` to restore legacy behavior (exchange all outbound traffic). ## Shared Volume Contract @@ -288,7 +314,7 @@ kubectl apply -f k8s/auth-target-deployment-webhook.yaml # Target service 3. **Keycloak port exclusion**: When using iptables interception, Keycloak's port (8080) must be excluded from redirect via `OUTBOUND_PORTS_EXCLUDE=8080`. Otherwise, token exchange requests from the ext-proc get redirected back to Envoy, creating a loop. -4. **TLS passthrough is one-way**: Outbound HTTPS traffic passes through Envoy without token exchange. There is no mechanism to exchange tokens for HTTPS destinations. Only HTTP outbound traffic gets token exchange. +4. **TLS passthrough is one-way**: Outbound HTTPS traffic passes through Envoy without token exchange via the TLS passthrough filter chain. Only plaintext HTTP outbound traffic reaches the ext_proc. With the default outbound policy of `"passthrough"`, even plaintext HTTP traffic is forwarded unchanged unless it matches an explicit route in `authproxy-routes`. 5. **Virtualenv directory**: For local development you may create `AuthProxy/quickstart/venv/`, but it should be gitignored and is not committed to the repo. diff --git a/AuthBridge/demos/github-issue/demo-manual.md b/AuthBridge/demos/github-issue/demo-manual.md index b67459718..95c7ee724 100644 --- a/AuthBridge/demos/github-issue/demo-manual.md +++ b/AuthBridge/demos/github-issue/demo-manual.md @@ -278,7 +278,7 @@ This creates: | **Scope** | `github-tool-aud` | Realm OPTIONAL — for exchanged tokens targeting the tool | | **Scope** | `github-full-access` | Realm OPTIONAL — for privileged GitHub API access | | **User** | `alice` (password: `alice123`) | Regular user — public access | -| **User** | `bob` (password: `bob123`) | Privileged user — full access | +| **User** | `bob` (password: `bob123`) | Demo user — request with `scope=github-full-access` for privileged access | --- @@ -800,6 +800,206 @@ kubectl delete pod test-client -n team1 --ignore-not-found --- +## Step 10: Access Control — Alice vs Bob + + + +> **Known limitation:** This step requires the go-processor scope forwarding feature +> ([kagenti-extensions#139](https://github.com/kagenti/kagenti-extensions/issues/139)). +> Currently, `TARGET_SCOPES` in `authbridge-config` is static, so all exchanged tokens +> include `github-full-access` regardless of the original user's scopes. Once scope +> forwarding is implemented, Alice's exchanged token will omit `github-full-access` +> while Bob's will include it. + +This step demonstrates **scope-based access control**: two users with different +privilege levels get different GitHub API access through the same agent. + +| User | Token Scope | Tool PAT Used | Public Repos | Private Repos | +|------|-------------|---------------|:------------:|:-------------:| +| **Alice** | `openid` (no `github-full-access`) | `PUBLIC_ACCESS_PAT` | Yes | No | +| **Bob** | `openid github-full-access` | `PRIVILEGED_ACCESS_PAT` | Yes | Yes | + +The flow: +1. User authenticates with Keycloak using `password` grant +2. Alice requests a token **without** `github-full-access`; Bob explicitly requests **with** it + (`github-full-access` is a realm OPTIONAL scope — Keycloak only includes it when the + token request contains `scope=openid github-full-access`) +3. AuthBridge exchanges the token — once scope forwarding is implemented + ([#139](https://github.com/kagenti/kagenti-extensions/issues/139)), the exchanged + token will preserve the scope difference +4. The GitHub tool checks for `REQUIRED_SCOPE` (`github-full-access`) in the exchanged token +5. Tokens with the scope get the privileged PAT; tokens without get the public-only PAT + +> **Prerequisite:** You need a **private** GitHub repository that the `PRIVILEGED_ACCESS_PAT` +> can access but the `PUBLIC_ACCESS_PAT` cannot. Replace `` +> below with your own private repo. + +### 10a. Open a shell inside the test-client pod + +```bash +kubectl run test-client --image=nicolaka/netshoot -n team1 --restart=Never -- sleep 3600 2>/dev/null +kubectl wait --for=condition=ready pod/test-client -n team1 --timeout=30s +kubectl exec -it test-client -n team1 -- sh +``` + +### 10b. Get agent credentials + +Inside the test-client pod, get the agent's client credentials (needed to request +user tokens that include the agent's audience): + +```bash +ADMIN_TOKEN=$(curl -s http://keycloak-service.keycloak.svc:8080/realms/kagenti/protocol/openid-connect/token \ + -d "grant_type=password" \ + -d "client_id=admin-cli" \ + -d "username=admin" \ + -d "password=admin" | jq -r ".access_token") + +SPIFFE_ID="spiffe://localtest.me/ns/team1/sa/git-issue-agent" +CLIENTS=$(curl -s -H "Authorization: Bearer $ADMIN_TOKEN" \ + "http://keycloak-service.keycloak.svc:8080/admin/realms/kagenti/clients" \ + --data-urlencode "clientId=$SPIFFE_ID" --get) +INTERNAL_ID=$(echo "$CLIENTS" | jq -r ".[0].id") +CLIENT_ID=$(echo "$CLIENTS" | jq -r ".[0].clientId") +CLIENT_SECRET=$(echo "$CLIENTS" | jq -r ".[0].secret") +echo "Client ID: $CLIENT_ID Secret length: ${#CLIENT_SECRET}" +``` + +### 10c. Test as Alice (public access only) + +Alice authenticates with Keycloak using `password` grant **without** requesting the +`github-full-access` scope. Her token only has the default scopes. + +```bash +ALICE_TOKEN=$(curl -s -X POST \ + "http://keycloak-service.keycloak.svc:8080/realms/kagenti/protocol/openid-connect/token" \ + -d "grant_type=password" \ + -d "username=alice" \ + -d "password=alice123" \ + --data-urlencode "client_id=$CLIENT_ID" \ + --data-urlencode "client_secret=$CLIENT_SECRET" | jq -r ".access_token") + +echo "Alice token length: ${#ALICE_TOKEN}" +echo "Alice scopes: $(echo $ALICE_TOKEN | cut -d. -f2 | base64 -d 2>/dev/null | jq -r '.scope')" +``` + +**Alice queries a public repo** (should succeed): + +```bash +curl -s --max-time 300 \ + -H "Authorization: Bearer $ALICE_TOKEN" \ + -H "Content-Type: application/json" \ + -X POST http://git-issue-agent:8080/ \ + -d '{ + "jsonrpc": "2.0", + "id": "alice-public", + "method": "message/send", + "params": { + "message": { + "role": "user", + "messageId": "msg-alice-1", + "parts": [{"type": "text", "text": "List issues in kagenti/kagenti repo"}] + } + } + }' | jq '.result.artifacts[0].parts[0].text' | head -5 +``` + +**Alice queries a private repo** (should fail — PUBLIC_ACCESS_PAT cannot access it): + +```bash +curl -s --max-time 300 \ + -H "Authorization: Bearer $ALICE_TOKEN" \ + -H "Content-Type: application/json" \ + -X POST http://git-issue-agent:8080/ \ + -d '{ + "jsonrpc": "2.0", + "id": "alice-private", + "method": "message/send", + "params": { + "message": { + "role": "user", + "messageId": "msg-alice-2", + "parts": [{"type": "text", "text": "List issues in "}] + } + } + }' | jq '.result.artifacts[0].parts[0].text' | head -5 +``` + +> **Expected:** Alice's request for the private repo fails because the GitHub tool +> uses `PUBLIC_ACCESS_PAT`, which has no access to private repositories. + +### 10d. Test as Bob (privileged access) + +Bob authenticates with `scope=openid github-full-access`, explicitly requesting +the privileged scope: + +```bash +BOB_TOKEN=$(curl -s -X POST \ + "http://keycloak-service.keycloak.svc:8080/realms/kagenti/protocol/openid-connect/token" \ + -d "grant_type=password" \ + -d "username=bob" \ + -d "password=bob123" \ + -d "scope=openid github-full-access" \ + --data-urlencode "client_id=$CLIENT_ID" \ + --data-urlencode "client_secret=$CLIENT_SECRET" | jq -r ".access_token") + +echo "Bob token length: ${#BOB_TOKEN}" +echo "Bob scopes: $(echo $BOB_TOKEN | cut -d. -f2 | base64 -d 2>/dev/null | jq -r '.scope')" +``` + +**Bob queries the same private repo** (should succeed — PRIVILEGED_ACCESS_PAT has access): + +```bash +curl -s --max-time 300 \ + -H "Authorization: Bearer $BOB_TOKEN" \ + -H "Content-Type: application/json" \ + -X POST http://git-issue-agent:8080/ \ + -d '{ + "jsonrpc": "2.0", + "id": "bob-private", + "method": "message/send", + "params": { + "message": { + "role": "user", + "messageId": "msg-bob-1", + "parts": [{"type": "text", "text": "List issues in "}] + } + } + }' | jq '.result.artifacts[0].parts[0].text' | head -5 +``` + +> **Expected:** Bob's request succeeds because the exchanged token contains +> `github-full-access`, so the GitHub tool uses `PRIVILEGED_ACCESS_PAT`. + +### 10e. Verify scope-based PAT selection in tool logs + +Check the GitHub tool logs to confirm that different PATs were selected based on scopes: + +```bash +exit +kubectl logs deployment/github-tool -n team1 | grep -E "REQUIRED_SCOPE|scopes" +``` + +Expected output (two requests, different scope outcomes): + +``` +This OIDC user has scopes "openid email profile" +The REQUIRED_SCOPE "github-full-access" NOT IN scopes [openid email profile] +This OIDC user has scopes "openid email profile github-full-access" +The REQUIRED_SCOPE "github-full-access" in scopes [openid email profile github-full-access] +``` + +### 10f. Clean up + +```bash +kubectl delete pod test-client -n team1 --ignore-not-found +``` + +--- + ## How AuthBridge Changes the Original Demo | Aspect | Original Demo | With AuthBridge | diff --git a/AuthBridge/demos/github-issue/demo-ui.md b/AuthBridge/demos/github-issue/demo-ui.md index b4b0d5e86..406122a61 100644 --- a/AuthBridge/demos/github-issue/demo-ui.md +++ b/AuthBridge/demos/github-issue/demo-ui.md @@ -169,7 +169,7 @@ This creates: | **Scope** | `github-tool-aud` | Realm OPTIONAL — for exchanged tokens targeting the tool | | **Scope** | `github-full-access` | Realm OPTIONAL — for privileged GitHub API access | | **User** | `alice` (password: `alice123`) | Regular user — public access | -| **User** | `bob` (password: `bob123`) | Privileged user — full access | +| **User** | `bob` (password: `bob123`) | Demo user — request with `scope=github-full-access` for privileged access | --- @@ -646,6 +646,206 @@ kubectl delete pod test-client -n team1 --ignore-not-found --- +## Step 10: Access Control — Alice vs Bob + + + +> **Known limitation:** This step requires the go-processor scope forwarding feature +> ([kagenti-extensions#139](https://github.com/kagenti/kagenti-extensions/issues/139)). +> Currently, `TARGET_SCOPES` in `authbridge-config` is static, so all exchanged tokens +> include `github-full-access` regardless of the original user's scopes. Once scope +> forwarding is implemented, Alice's exchanged token will omit `github-full-access` +> while Bob's will include it. + +This step demonstrates **scope-based access control**: two users with different +privilege levels get different GitHub API access through the same agent. + +| User | Token Scope | Tool PAT Used | Public Repos | Private Repos | +|------|-------------|---------------|:------------:|:-------------:| +| **Alice** | `openid` (no `github-full-access`) | `PUBLIC_ACCESS_PAT` | Yes | No | +| **Bob** | `openid github-full-access` | `PRIVILEGED_ACCESS_PAT` | Yes | Yes | + +The flow: +1. User authenticates with Keycloak using `password` grant +2. Alice requests a token **without** `github-full-access`; Bob explicitly requests **with** it + (`github-full-access` is a realm OPTIONAL scope — Keycloak only includes it when the + token request contains `scope=openid github-full-access`) +3. AuthBridge exchanges the token — once scope forwarding is implemented + ([#139](https://github.com/kagenti/kagenti-extensions/issues/139)), the exchanged + token will preserve the scope difference +4. The GitHub tool checks for `REQUIRED_SCOPE` (`github-full-access`) in the exchanged token +5. Tokens with the scope get the privileged PAT; tokens without get the public-only PAT + +> **Prerequisite:** You need a **private** GitHub repository that the `PRIVILEGED_ACCESS_PAT` +> can access but the `PUBLIC_ACCESS_PAT` cannot. Replace `` +> below with your own private repo. + +### 10a. Open a shell inside the test-client pod + +```bash +kubectl run test-client --image=nicolaka/netshoot -n team1 --restart=Never -- sleep 3600 2>/dev/null +kubectl wait --for=condition=ready pod/test-client -n team1 --timeout=30s +kubectl exec -it test-client -n team1 -- sh +``` + +### 10b. Get agent credentials + +Inside the test-client pod, get the agent's client credentials (needed to request +user tokens that include the agent's audience): + +```bash +ADMIN_TOKEN=$(curl -s http://keycloak-service.keycloak.svc:8080/realms/kagenti/protocol/openid-connect/token \ + -d "grant_type=password" \ + -d "client_id=admin-cli" \ + -d "username=admin" \ + -d "password=admin" | jq -r ".access_token") + +SPIFFE_ID="spiffe://localtest.me/ns/team1/sa/git-issue-agent" +CLIENTS=$(curl -s -H "Authorization: Bearer $ADMIN_TOKEN" \ + "http://keycloak-service.keycloak.svc:8080/admin/realms/kagenti/clients" \ + --data-urlencode "clientId=$SPIFFE_ID" --get) +INTERNAL_ID=$(echo "$CLIENTS" | jq -r ".[0].id") +CLIENT_ID=$(echo "$CLIENTS" | jq -r ".[0].clientId") +CLIENT_SECRET=$(echo "$CLIENTS" | jq -r ".[0].secret") +echo "Client ID: $CLIENT_ID Secret length: ${#CLIENT_SECRET}" +``` + +### 10c. Test as Alice (public access only) + +Alice authenticates with Keycloak using `password` grant **without** requesting the +`github-full-access` scope. Her token only has the default scopes. + +```bash +ALICE_TOKEN=$(curl -s -X POST \ + "http://keycloak-service.keycloak.svc:8080/realms/kagenti/protocol/openid-connect/token" \ + -d "grant_type=password" \ + -d "username=alice" \ + -d "password=alice123" \ + --data-urlencode "client_id=$CLIENT_ID" \ + --data-urlencode "client_secret=$CLIENT_SECRET" | jq -r ".access_token") + +echo "Alice token length: ${#ALICE_TOKEN}" +echo "Alice scopes: $(echo $ALICE_TOKEN | cut -d. -f2 | base64 -d 2>/dev/null | jq -r '.scope')" +``` + +**Alice queries a public repo** (should succeed): + +```bash +curl -s --max-time 300 \ + -H "Authorization: Bearer $ALICE_TOKEN" \ + -H "Content-Type: application/json" \ + -X POST http://git-issue-agent:8080/ \ + -d '{ + "jsonrpc": "2.0", + "id": "alice-public", + "method": "message/send", + "params": { + "message": { + "role": "user", + "messageId": "msg-alice-1", + "parts": [{"type": "text", "text": "List issues in kagenti/kagenti repo"}] + } + } + }' | jq '.result.artifacts[0].parts[0].text' | head -5 +``` + +**Alice queries a private repo** (should fail — PUBLIC_ACCESS_PAT cannot access it): + +```bash +curl -s --max-time 300 \ + -H "Authorization: Bearer $ALICE_TOKEN" \ + -H "Content-Type: application/json" \ + -X POST http://git-issue-agent:8080/ \ + -d '{ + "jsonrpc": "2.0", + "id": "alice-private", + "method": "message/send", + "params": { + "message": { + "role": "user", + "messageId": "msg-alice-2", + "parts": [{"type": "text", "text": "List issues in "}] + } + } + }' | jq '.result.artifacts[0].parts[0].text' | head -5 +``` + +> **Expected:** Alice's request for the private repo fails because the GitHub tool +> uses `PUBLIC_ACCESS_PAT`, which has no access to private repositories. + +### 10d. Test as Bob (privileged access) + +Bob authenticates with `scope=openid github-full-access`, explicitly requesting +the privileged scope: + +```bash +BOB_TOKEN=$(curl -s -X POST \ + "http://keycloak-service.keycloak.svc:8080/realms/kagenti/protocol/openid-connect/token" \ + -d "grant_type=password" \ + -d "username=bob" \ + -d "password=bob123" \ + -d "scope=openid github-full-access" \ + --data-urlencode "client_id=$CLIENT_ID" \ + --data-urlencode "client_secret=$CLIENT_SECRET" | jq -r ".access_token") + +echo "Bob token length: ${#BOB_TOKEN}" +echo "Bob scopes: $(echo $BOB_TOKEN | cut -d. -f2 | base64 -d 2>/dev/null | jq -r '.scope')" +``` + +**Bob queries the same private repo** (should succeed — PRIVILEGED_ACCESS_PAT has access): + +```bash +curl -s --max-time 300 \ + -H "Authorization: Bearer $BOB_TOKEN" \ + -H "Content-Type: application/json" \ + -X POST http://git-issue-agent:8080/ \ + -d '{ + "jsonrpc": "2.0", + "id": "bob-private", + "method": "message/send", + "params": { + "message": { + "role": "user", + "messageId": "msg-bob-1", + "parts": [{"type": "text", "text": "List issues in "}] + } + } + }' | jq '.result.artifacts[0].parts[0].text' | head -5 +``` + +> **Expected:** Bob's request succeeds because the exchanged token contains +> `github-full-access`, so the GitHub tool uses `PRIVILEGED_ACCESS_PAT`. + +### 10e. Verify scope-based PAT selection in tool logs + +Check the GitHub tool logs to confirm that different PATs were selected based on scopes: + +```bash +exit +kubectl logs deployment/github-tool -n team1 | grep -E "REQUIRED_SCOPE|scopes" +``` + +Expected output (two requests, different scope outcomes): + +``` +This OIDC user has scopes "openid email profile" +The REQUIRED_SCOPE "github-full-access" NOT IN scopes [openid email profile] +This OIDC user has scopes "openid email profile github-full-access" +The REQUIRED_SCOPE "github-full-access" in scopes [openid email profile github-full-access] +``` + +### 10f. Clean up + +```bash +kubectl delete pod test-client -n team1 --ignore-not-found +``` + +--- + ## Patching Agent Environment (If Needed) If the agent is missing environment variables after UI deployment (e.g., `MCP_URL`, diff --git a/AuthBridge/demos/github-issue/demo.md b/AuthBridge/demos/github-issue/demo.md index 7fa90b0f6..351a2f9f0 100644 --- a/AuthBridge/demos/github-issue/demo.md +++ b/AuthBridge/demos/github-issue/demo.md @@ -24,6 +24,7 @@ produce identical AuthBridge security behavior. 3. **Transparent token exchange** — When the agent calls the GitHub tool, AuthBridge exchanges the token automatically 4. **Subject preservation** — The end user's identity (`sub` claim) is preserved through the exchange 5. **Scope-based access** — The tool uses token scopes to determine public or privileged GitHub API access +6. **Access control (Alice vs Bob)** — Two users with different scopes get different GitHub API access levels: Alice (public only) cannot access private repos, while Bob (privileged) can access both (requires scope forwarding: [kagenti-extensions#139](https://github.com/kagenti/kagenti-extensions/issues/139)) ## Architecture Overview diff --git a/AuthBridge/demos/github-issue/k8s/configmaps.yaml b/AuthBridge/demos/github-issue/k8s/configmaps.yaml index 8ac55ba4c..4298161fe 100644 --- a/AuthBridge/demos/github-issue/k8s/configmaps.yaml +++ b/AuthBridge/demos/github-issue/k8s/configmaps.yaml @@ -2,10 +2,13 @@ # # The Kagenti installer already creates correct defaults for environments, # spiffe-helper-config, envoy-config, and authbridge-config (with the kagenti -# realm). This file only overrides authbridge-config with demo-specific values: -# - TARGET_AUDIENCE: github-tool (the MCP GitHub tool) -# - TARGET_SCOPES: scopes for token exchange to the tool -# - EXPECTED_AUDIENCE: the agent's SPIFFE ID for inbound validation +# realm). This file overrides: +# - authbridge-config: inbound validation and outbound exchange settings +# - authproxy-routes: per-target token exchange routes +# +# The go-processor defaults to "passthrough" for outbound traffic, meaning +# requests to unknown hosts (LLM APIs, telemetry, etc.) pass through without +# token exchange. Only hosts listed in authproxy-routes get token exchange. # # Usage: # kubectl apply -f configmaps.yaml @@ -34,9 +37,27 @@ data: # Format: spiffe://localtest.me/ns//sa/ # Update if deploying to a different namespace or using a different service account. EXPECTED_AUDIENCE: "spiffe://localtest.me/ns/team1/sa/git-issue-agent" - # TARGET_AUDIENCE: The GitHub tool's Keycloak client ID. - # AuthBridge exchanges tokens for this audience when the agent calls the tool. + # TARGET_AUDIENCE and TARGET_SCOPES are used as fallback values when a route + # in authproxy-routes matches but doesn't specify its own audience/scopes. TARGET_AUDIENCE: "github-tool" - # TARGET_SCOPES: Scopes to request in the exchanged token. - # github-tool-aud adds "github-tool" to the token's audience claim. TARGET_SCOPES: "openid github-tool-aud github-full-access" + +--- +# authproxy-routes ConfigMap - Per-target token exchange routes +# +# Defines which outbound hosts should get token exchange. All other hosts +# pass through unchanged (the default outbound policy is "passthrough"). +# This allows the agent to call LLM APIs, telemetry endpoints, and other +# services without interference from the token exchange proxy. +# +# Update the host pattern if the github-tool service name or namespace changes. +apiVersion: v1 +kind: ConfigMap +metadata: + name: authproxy-routes + namespace: team1 +data: + routes.yaml: | + - host: "**.github-issue-tool**.svc.cluster.local" + target_audience: "github-tool" + token_scopes: "openid github-tool-aud github-full-access" diff --git a/AuthBridge/demos/github-issue/k8s/github-tool-deployment.yaml b/AuthBridge/demos/github-issue/k8s/github-tool-deployment.yaml index e02c8af6b..ca64fe66a 100644 --- a/AuthBridge/demos/github-issue/k8s/github-tool-deployment.yaml +++ b/AuthBridge/demos/github-issue/k8s/github-tool-deployment.yaml @@ -63,6 +63,11 @@ spec: secretKeyRef: name: github-tool-secrets key: UPSTREAM_HEADER_TO_USE_IF_NOT_IN_AUDIENCE + # Scope-based access control: tokens with this scope get + # UPSTREAM_HEADER_TO_USE_IF_IN_AUDIENCE (privileged PAT), + # all others get UPSTREAM_HEADER_TO_USE_IF_NOT_IN_AUDIENCE (public PAT) + - name: REQUIRED_SCOPE + value: "github-full-access" # Keycloak validation settings for incoming tokens - name: ISSUER value: "http://keycloak.localtest.me:8080/realms/kagenti" diff --git a/AuthBridge/demos/webhook/k8s/configmaps-webhook.yaml b/AuthBridge/demos/webhook/k8s/configmaps-webhook.yaml index d72f3c566..9f240df8e 100644 --- a/AuthBridge/demos/webhook/k8s/configmaps-webhook.yaml +++ b/AuthBridge/demos/webhook/k8s/configmaps-webhook.yaml @@ -54,8 +54,38 @@ data: # Update this value if deploying to a different namespace or using a different service account. # If not set, audience validation is skipped (issuer and signature validation still occur). EXPECTED_AUDIENCE: "spiffe://localtest.me/ns/team1/sa/agent" + # TARGET_AUDIENCE and TARGET_SCOPES: Fallback values for token exchange when a + # route in authproxy-routes matches but doesn't specify its own audience/scopes. TARGET_AUDIENCE: "auth-target" TARGET_SCOPES: "openid auth-target-aud" + # DEFAULT_OUTBOUND_POLICY: Controls behavior for outbound requests that don't + # match any route in authproxy-routes. Default is "passthrough" (skip exchange). + # Set to "exchange" to restore legacy behavior (exchange all outbound traffic). + # DEFAULT_OUTBOUND_POLICY: "passthrough" + +--- +# authproxy-routes ConfigMap (optional) - Per-target token exchange routes +# +# Defines which outbound hosts should get token exchange. All other hosts pass +# through unchanged when DEFAULT_OUTBOUND_POLICY is "passthrough" (the default). +# +# If this ConfigMap does not exist, no outbound token exchange occurs (all +# traffic passes through). Create it only when your agents need to authenticate +# to specific services via Keycloak token exchange. +# +# Example: +# apiVersion: v1 +# kind: ConfigMap +# metadata: +# name: authproxy-routes +# namespace: team1 +# data: +# routes.yaml: | +# - host: "auth-target.team1.svc.cluster.local" +# target_audience: "auth-target" +# token_scopes: "openid auth-target-aud" +# - host: "otel-collector.*.svc.cluster.local" +# passthrough: true --- # spiffe-helper-config ConfigMap - Used by spiffe-helper container (SPIRE mode only) diff --git a/CLAUDE.md b/CLAUDE.md index 2e9cfdc6e..ede5d2f13 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -200,10 +200,17 @@ When the webhook injects sidecars, the target namespace needs these ConfigMaps: |----------|------|---------|------| | `environments` | ConfigMap | client-registration | `KEYCLOAK_URL`, `KEYCLOAK_REALM` | | `keycloak-admin-secret` | Secret | client-registration | `KEYCLOAK_ADMIN_USERNAME`, `KEYCLOAK_ADMIN_PASSWORD` | -| `authbridge-config` | ConfigMap | envoy-proxy (ext-proc) | `TOKEN_URL`, `ISSUER`, `TARGET_AUDIENCE`, `TARGET_SCOPES` | +| `authbridge-config` | ConfigMap | envoy-proxy (ext-proc) | `TOKEN_URL`, `ISSUER`, `TARGET_AUDIENCE`, `TARGET_SCOPES`, `DEFAULT_OUTBOUND_POLICY` | +| `authproxy-routes` | ConfigMap (optional) | envoy-proxy (ext-proc) | `routes.yaml` — per-target token exchange routes | | `spiffe-helper-config` | ConfigMap | spiffe-helper | SPIFFE helper configuration file | | `envoy-config` | ConfigMap | envoy-proxy | Envoy YAML configuration | +### Outbound Token Exchange Policy + +The go-processor defaults to **passthrough** for outbound requests that don't match any route in `authproxy-routes`. This means agents work out-of-the-box with any LLM provider (Ollama, OpenAI, etc.) without needing token exchange exclusions. Only hosts with explicit route entries in `authproxy-routes` get token exchange. + +Set `DEFAULT_OUTBOUND_POLICY: "exchange"` in `authbridge-config` to restore the legacy behavior (exchange for all outbound traffic when global config is set). + ## Common Development Tasks ### Building Everything Locally diff --git a/kagenti-webhook/internal/webhook/injector/container_builder.go b/kagenti-webhook/internal/webhook/injector/container_builder.go index c5a79a014..3930dccdc 100644 --- a/kagenti-webhook/internal/webhook/injector/container_builder.go +++ b/kagenti-webhook/internal/webhook/injector/container_builder.go @@ -288,6 +288,11 @@ func (b *ContainerBuilder) BuildEnvoyProxyContainerWithSpireOption(spireEnabled MountPath: "/shared", ReadOnly: true, }, + { + Name: "authproxy-routes", + MountPath: "/etc/authproxy", + ReadOnly: true, + }, } if spireEnabled { volumeMounts = append(volumeMounts, corev1.VolumeMount{ @@ -393,6 +398,22 @@ func (b *ContainerBuilder) BuildEnvoyProxyContainerWithSpireOption(spireEnabled Name: "CLIENT_SECRET_FILE", Value: "/shared/client-secret.txt", }, + { + Name: "ROUTES_CONFIG_PATH", + Value: "/etc/authproxy/routes.yaml", + }, + { + Name: "DEFAULT_OUTBOUND_POLICY", + ValueFrom: &corev1.EnvVarSource{ + ConfigMapKeyRef: &corev1.ConfigMapKeySelector{ + LocalObjectReference: corev1.LocalObjectReference{ + Name: "authbridge-config", + }, + Key: "DEFAULT_OUTBOUND_POLICY", + Optional: ptr.To(true), + }, + }, + }, }, SecurityContext: &corev1.SecurityContext{ RunAsUser: ptr.To(b.cfg.Proxy.UID), diff --git a/kagenti-webhook/internal/webhook/injector/volume_builder.go b/kagenti-webhook/internal/webhook/injector/volume_builder.go index e740d693e..a624293c1 100644 --- a/kagenti-webhook/internal/webhook/injector/volume_builder.go +++ b/kagenti-webhook/internal/webhook/injector/volume_builder.go @@ -18,6 +18,7 @@ package injector import ( corev1 "k8s.io/api/core/v1" + "k8s.io/utils/ptr" ) // BuildRequiredVolumes creates all volumes required for sidecar containers (with SPIRE) @@ -68,6 +69,17 @@ func BuildRequiredVolumes() []corev1.Volume { }, }, }, + { + Name: "authproxy-routes", + VolumeSource: corev1.VolumeSource{ + ConfigMap: &corev1.ConfigMapVolumeSource{ + LocalObjectReference: corev1.LocalObjectReference{ + Name: "authproxy-routes", + }, + Optional: ptr.To(true), + }, + }, + }, } } @@ -91,5 +103,16 @@ func BuildRequiredVolumesNoSpire() []corev1.Volume { }, }, }, + { + Name: "authproxy-routes", + VolumeSource: corev1.VolumeSource{ + ConfigMap: &corev1.ConfigMapVolumeSource{ + LocalObjectReference: corev1.LocalObjectReference{ + Name: "authproxy-routes", + }, + Optional: ptr.To(true), + }, + }, + }, } }