diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 293dca512..09cc047c6 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -22,53 +22,26 @@ jobs: strategy: matrix: image_config: - # Client Registration - registers workloads with Keycloak using SPIFFE ID - - name: client-registration - context: ./authbridge/client-registration - dockerfile: Dockerfile - - # AuthProxy components - token exchange and traffic interception - - name: auth-proxy - context: ./authbridge/authproxy - dockerfile: Dockerfile - + # iptables init container — used by envoy-sidecar mode to + # set up traffic redirection. Proxy-sidecar mode does not + # need this (HTTP_PROXY env var routing replaces iptables). - name: proxy-init context: ./authbridge/authproxy dockerfile: Dockerfile.init - # SPIFFE Helper - fetches SPIFFE credentials from SPIRE (UBI-based fork) - - name: spiffe-helper - context: ./authbridge/spiffe-helper - dockerfile: Dockerfile - - # Combined AuthBridge sidecar (envoy + authbridge + spiffe-helper + client-registration) - - name: authbridge - context: ./authbridge - dockerfile: authproxy/Dockerfile.authbridge - - # Unified AuthBridge binary (Envoy + authbridge in one container) - # Drop-in replacement for envoy-with-processor - # DEPRECATED: use authbridge-envoy instead - - name: authbridge-unified - context: ./authbridge - dockerfile: cmd/authbridge/Dockerfile - - # AuthBridge with Envoy (envoy-sidecar mode) - # New canonical name for authbridge-unified + # AuthBridge envoy-sidecar combined image — + # Envoy + authbridge (ext_proc) + spiffe-helper. Spiffe-helper + # starts conditionally based on SPIRE_ENABLED. - name: authbridge-envoy context: ./authbridge - dockerfile: cmd/authbridge/Dockerfile + dockerfile: cmd/authbridge/Dockerfile.envoy - # Lightweight AuthBridge (Go binary only, no Envoy) - # Used for waypoint and proxy-sidecar modes - - name: authbridge-light + # AuthBridge proxy-sidecar combined image (default mode) — + # authbridge-proxy + spiffe-helper. No Envoy, no gRPC. + # Spiffe-helper starts conditionally based on SPIRE_ENABLED. + - name: authbridge context: ./authbridge - dockerfile: cmd/authbridge/Dockerfile.light - - # Demo application for testing - - name: demo-app - context: ./authbridge/authproxy/quickstart/demo-app - dockerfile: Dockerfile + dockerfile: cmd/authbridge/Dockerfile.proxy steps: # 1. Checkout code diff --git a/.github/workflows/security-scans.yaml b/.github/workflows/security-scans.yaml index d40714868..0f49afb79 100644 --- a/.github/workflows/security-scans.yaml +++ b/.github/workflows/security-scans.yaml @@ -147,7 +147,7 @@ jobs: echo "=== Bandit Python Security Scan ===" # Scan AuthBridge Python code and tests PYTHON_DIRS="" - for dir in authbridge/client-registration authbridge tests; do + for dir in authbridge tests; do if [ -d "$dir" ]; then PYTHON_DIRS="$PYTHON_DIRS $dir" fi diff --git a/CLAUDE.md b/CLAUDE.md index a1f3fde72..bfb5b675b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -30,18 +30,20 @@ kagenti-extensions/ │ │ ├── routing/ # Host-to-audience router │ │ ├── auth/ # HandleInbound + HandleOutbound composition │ │ └── config/ # Mode presets, YAML config, validation -│ ├── cmd/authbridge/ # Unified binary — 3 modes, 1 codebase -│ │ ├── listener/ # Protocol adapters (ext_proc, ext_authz, forward/reverse proxy) -│ │ ├── entrypoint.sh # Envoy + authbridge process supervision -│ │ └── Dockerfile # Combined Envoy + authbridge image -│ ├── authproxy/ # Auth proxy support files and demos +│ ├── cmd/authbridge/ # Unified binary + combined Dockerfiles + entrypoints +│ │ ├── listener/ # Protocol adapters (ext_proc, ext_authz) +│ │ ├── Dockerfile.envoy # envoy-sidecar combined image +│ │ ├── Dockerfile.proxy # proxy-sidecar combined image (default) +│ │ ├── entrypoint-envoy.sh +│ │ └── entrypoint-proxy.sh +│ ├── cmd/authbridge-proxy/ # Lite binary — proxy-sidecar mode only +│ ├── cmd/authbridge-envoy/ # Lite binary — envoy-sidecar mode only +│ ├── authproxy/ # iptables init container + standalone quickstart │ │ ├── quickstart/ # Standalone demo (no SPIFFE) │ │ └── k8s/ # Standalone K8s manifests -│ ├── client-registration/ # Keycloak auto-registration (Python) -│ ├── spiffe-helper/ # SPIFFE helper Dockerfile (fetches JWT-SVIDs from SPIRE) │ ├── demos/ # Demo scenarios (weather-agent, github-issue, webhook, single-target, multi-target) │ └── keycloak_sync.py # Declarative Keycloak sync tool -├── tests/ # Python tests (client-registration, keycloak_sync) +├── tests/ # Python tests (keycloak_sync) ├── .github/ │ ├── workflows/ # CI/CD (ci.yaml, build.yaml, security-scans, scorecard, spellcheck) │ └── ISSUE_TEMPLATE/ # Bug report, feature request, epic templates @@ -68,38 +70,42 @@ A **single binary** providing transparent traffic interception for both inbound **Ports:** 15123 (outbound), 15124 (inbound), 9090 (ext-proc/ext-authz), 9901 (admin), 9093 (stats and config) -### 2. Client Registration (Python) +### 2. Client Registration -A Python script that **automatically registers Kubernetes workloads as Keycloak OAuth2 clients** using their SPIFFE identity. - -**Location:** `authbridge/client-registration/` -**Language:** Python 3.12 -**Detailed guide:** [`authbridge/CLAUDE.md`](authbridge/CLAUDE.md) - -**Flow:** Reads SPIFFE ID from JWT, registers client in Keycloak, writes secret to `/shared/client-secret.txt` +Keycloak client registration for workloads is handled by the +**kagenti-operator** (separate repo) — see `kagenti-operator/docs/operator-managed-client-registration.md`. +The operator creates a Secret with `client-id.txt` + `client-secret.txt` +and the webhook mounts it at `/shared/` in the workload pod. The +in-pod `client-registration` sidecar that previously lived in this +repo has been removed. ## How the Components Work Together -The kagenti-operator (in a separate repo) injects AuthBridge sidecars into workload pods. Once injected, the sidecars work together: +The kagenti-operator (in a separate repo) injects AuthBridge sidecars +into workload pods. Default deployment shape (proxy-sidecar mode): ``` ┌────────────────────────────────────┐ │ WORKLOAD POD │ │ │ - │ proxy-init (init) ─► iptables │ - │ │ - │ spiffe-helper ──► SPIRE Agent │ - │ │ writes JWT SVID │ - │ ▼ │ - │ client-registration ──► Keycloak │ - │ │ writes client secret │ - │ ▼ │ - │ envoy-proxy (+ authbridge) │ - │ - Inbound: JWT validation │ - │ - Outbound: token exchange │ + │ spiffe-helper ──► SPIRE Agent │ (in-container, + │ │ writes JWT SVID │ conditional on + │ ▼ │ SPIRE_ENABLED) + │ authbridge-proxy │ + │ - Reverse proxy: inbound JWT │ + │ - Forward proxy: outbound │ + │ token exchange │ │ │ │ │ Your Application │ + │ (HTTP_PROXY → forward proxy) │ └────────────────────────────────────┘ + + The operator also creates a Secret with client-id + + client-secret and mounts it at /shared/. + + For envoy-sidecar mode, replace authbridge-proxy with + the authbridge-envoy image (Envoy + ext_proc + spiffe-helper) + and add a proxy-init container for iptables. ``` ## Unified AuthBridge Binary @@ -146,17 +152,24 @@ Types: `feat`, `fix`, `docs`, `style`, `refactor`, `perf`, `test`, `build`, `ci` ## Container Images -All images are pushed to `ghcr.io/kagenti/kagenti-extensions/`: +All images are pushed to `ghcr.io/kagenti/kagenti-extensions/` from +`.github/workflows/build.yaml`: | Image | Source | Description | |-------|--------|-------------| -| **`authbridge-unified`** | **`authbridge/cmd/authbridge/Dockerfile`** | **Unified Envoy + authbridge binary (recommended)** | -| `authbridge` | `authbridge/authproxy/Dockerfile.authbridge` | Combined sidecar (Envoy + authbridge + spiffe-helper + client-registration) | -| `proxy-init` | `authbridge/authproxy/Dockerfile.init` | Alpine + iptables init container | -| `client-registration` | `authbridge/client-registration/Dockerfile` | Python Keycloak client registrar | -| `spiffe-helper` | `authbridge/spiffe-helper/Dockerfile` | Fetches SPIFFE credentials from SPIRE | -| `auth-proxy` | `authbridge/authproxy/Dockerfile` | Example pass-through proxy (for demos) | -| `demo-app` | `authbridge/authproxy/quickstart/demo-app/Dockerfile` | Demo target service | +| **`authbridge`** | **`authbridge/cmd/authbridge/Dockerfile.proxy`** | **proxy-sidecar combined image (default mode): authbridge-proxy + spiffe-helper. No Envoy.** | +| `authbridge-envoy` | `authbridge/cmd/authbridge/Dockerfile.envoy` | envoy-sidecar combined image: Envoy + authbridge (ext_proc) + spiffe-helper | +| `proxy-init` | `authbridge/authproxy/Dockerfile.init` | Alpine + iptables init container (envoy-sidecar mode only) | + +In both combined images, `spiffe-helper` is started conditionally based +on the `SPIRE_ENABLED` env var (set by the operator when SPIRE +identity is enabled for the workload). + +The legacy `authbridge-unified`, `authbridge-light`, `client-registration`, +`spiffe-helper`, `auth-proxy`, and `demo-app` standalone images have +been removed from CI (the auth-proxy / demo-app source is still in-tree +for the standalone quickstart). Older release tags continue to publish +the old images. ## Pre-commit Hooks diff --git a/authbridge/CLAUDE.md b/authbridge/CLAUDE.md index 0f2a08a5d..8ddfa2819 100644 --- a/authbridge/CLAUDE.md +++ b/authbridge/CLAUDE.md @@ -38,27 +38,26 @@ authbridge/ │ ├── auth/ # HandleInbound + HandleOutbound composition │ └── config/ # Mode presets, YAML config, validation │ -├── cmd/authbridge/ # Unified binary -- 3 modes, 1 codebase -│ ├── listener/ # Protocol adapters (ext_proc, ext_authz, forward/reverse proxy) -│ ├── entrypoint.sh # Envoy + authbridge process supervision -│ └── Dockerfile # Combined Envoy + authbridge image +├── cmd/authbridge/ # Unified binary — 3 listener modes, 1 codebase +│ ├── listener/extauthz/ # ext_authz adapter (waypoint mode) +│ ├── listener/extproc/ # ext_proc adapter (envoy-sidecar mode) +│ ├── Dockerfile.envoy # envoy-sidecar combined image (Envoy + authbridge + spiffe-helper) +│ ├── Dockerfile.proxy # proxy-sidecar combined image (authbridge-proxy + spiffe-helper) +│ ├── entrypoint-envoy.sh # Process supervisor for Dockerfile.envoy +│ └── entrypoint-proxy.sh # Process supervisor for Dockerfile.proxy │ -├── authproxy/ # Legacy sidecar support (iptables, quickstart) -│ ├── init-iptables.sh # iptables setup (outbound + inbound, Istio ambient compatible) +├── cmd/authbridge-proxy/ # Lite binary — proxy-sidecar mode only, no gRPC +│ +├── cmd/authbridge-envoy/ # Lite binary — envoy-sidecar mode only +│ +├── authproxy/ # iptables init container + standalone quickstart +│ ├── init-iptables.sh # iptables setup for envoy-sidecar mode │ ├── Dockerfile.init # proxy-init container image -│ ├── Dockerfile.authbridge # Combined sidecar image │ ├── k8s/ # Standalone K8s manifests │ └── quickstart/ # Standalone demo (no SPIFFE) │ ├── setup_keycloak.py │ └── demo-app/main.go # Test target: JWT validation (:8081), TLS echo (:8443) │ -├── client-registration/ # Keycloak auto-registration (Python) -│ ├── client_registration.py # Main script: register client, write secret -│ └── Dockerfile # Python 3.12-slim, UID/GID 1000 -│ -├── spiffe-helper/ # SPIFFE helper (Dockerfile only) -│ └── Dockerfile # Fetches JWT-SVIDs from SPIRE agent -│ ├── demos/ # Demo scenarios with full setup │ ├── README.md # Demo index (recommended starting order) │ ├── weather-agent/ # Getting-started demo (inbound validation only) @@ -116,7 +115,7 @@ with protocol-specific listeners in `cmd/authbridge/listener/`: - The operator-supplied env vars (`KEYCLOAK_URL`, `KEYCLOAK_REALM`, `TOKEN_URL`, `ISSUER`, `DEFAULT_OUTBOUND_POLICY`, `CLIENT_ID`) are consumed by the default `authbridge-combined.yaml` via `${VAR}` expansion — they land inside the appropriate plugin's `config:` block rather than a top-level section. - `jwt-validation` derives `jwks_url` from `issuer` when omitted (appends `/protocol/openid-connect/certs`). - `token-exchange` derives `token_url` from `keycloak_url + keycloak_realm` when omitted (Keycloak convention). -- Credential files: `client-registration` writes `/shared/client-id.txt` and `/shared/client-secret.txt`; `spiffe-helper` writes `/opt/jwt_svid.token`. `jwt-validation` reads the audience from `/shared/client-id.txt` via `audience_file`; `token-exchange` reads client credentials via `client_id_file` / `client_secret_file` / `jwt_svid_path`. Each plugin attempts a synchronous read at Configure time and falls back to a background poll from its `Init` goroutine if the file isn't yet readable. +- Credential files: the **kagenti-operator** registers each workload with Keycloak and creates a Secret containing `client-id.txt` + `client-secret.txt`; the operator's webhook mounts that Secret at `/shared/client-id.txt` and `/shared/client-secret.txt` in containers that share the `shared-data` volume. `spiffe-helper` (bundled in both combined images, started conditionally on `SPIRE_ENABLED=true`) writes `/opt/jwt_svid.token`. `jwt-validation` reads the audience from `/shared/client-id.txt` via `audience_file`; `token-exchange` reads client credentials via `client_id_file` / `client_secret_file` / `jwt_svid_path`. Each plugin attempts a synchronous read at Configure time and falls back to a background poll from its `Init` goroutine if the file isn't yet readable. The legacy in-pod `client-registration` sidecar has been removed; workloads opting back into that path use `kagenti.io/client-registration-inject: "true"` and the operator's bundled client-registration image (separate repo). - Outbound route config: `token-exchange` reads `/etc/authproxy/routes.yaml` by default (path is per-plugin, configured via `routes.file` in its config block); inline rules can be declared under `routes.rules`. - Outbound `default_policy`: `passthrough` (default) or `exchange`, configured per-plugin (no top-level `DEFAULT_OUTBOUND_POLICY` field anymore; the env var is still expanded into the plugin config by `authbridge-combined.yaml`). @@ -236,9 +235,9 @@ Sidecars communicate through files on shared volumes: | Path | Writer | Reader | Content | |------|--------|--------|---------| -| `/opt/jwt_svid.token` | spiffe-helper | client-registration | JWT SVID from SPIRE | -| `/shared/client-id.txt` | client-registration | authbridge | SPIFFE ID or CLIENT_NAME | -| `/shared/client-secret.txt` | client-registration | authbridge | Keycloak client secret | +| `/opt/jwt_svid.token` | spiffe-helper | authbridge (token-exchange) | JWT SVID from SPIRE | +| `/shared/client-id.txt` | operator (Secret mount) | authbridge | SPIFFE ID or workload name | +| `/shared/client-secret.txt` | operator (Secret mount) | authbridge | Keycloak client secret | ## Build and Deploy @@ -253,9 +252,15 @@ make build-images # Load into Kind cluster make load-images # Uses KIND_CLUSTER_NAME env var (default: kagenti) -# Build the authbridge-unified sidecar image separately (from authbridge/ context) -cd .. && podman build -f cmd/authbridge/Dockerfile -t authbridge-unified:latest . -kind load docker-image authbridge-unified:latest --name kagenti +# Build the combined sidecar images (from authbridge/ context). +# Pick the one matching your deployment mode: +# +# authbridge-envoy = Envoy + authbridge (ext_proc) + spiffe-helper +# authbridge = authbridge-proxy + spiffe-helper (default mode, no Envoy) +cd .. && podman build -f cmd/authbridge/Dockerfile.envoy -t authbridge-envoy:latest . +podman build -f cmd/authbridge/Dockerfile.proxy -t authbridge:latest . +kind load docker-image authbridge-envoy:latest --name kagenti +kind load docker-image authbridge:latest --name kagenti # Deploy auth-proxy + demo-app make deploy diff --git a/authbridge/authlib/config/resolve.go b/authbridge/authlib/config/resolve.go index 78dc970e0..3448a9d4d 100644 --- a/authbridge/authlib/config/resolve.go +++ b/authbridge/authlib/config/resolve.go @@ -18,9 +18,10 @@ import ( // ReadCredentialFile performs a one-shot read of a credential file, // returning its whitespace-trimmed contents. Used by plugins from -// Configure to opportunistically pick up values that client-registration -// has already written; when it returns an error, the plugin should fall -// back to WaitForCredentialFile from Init to wait for the file. +// Configure to opportunistically pick up values the operator has +// already mounted from a Secret; when it returns an error, the +// plugin should fall back to WaitForCredentialFile from Init to wait +// for the file. func ReadCredentialFile(path string) (string, error) { info, err := os.Stat(path) if err != nil { @@ -45,8 +46,8 @@ var heartbeatInterval = 60 * time.Second // WaitForCredentialFile blocks until the file is readable with non-zero // length, or until ctx is cancelled. Plugins call this from Init (via a -// goroutine) to wait out the race with client-registration's secret -// provisioning. +// goroutine) to wait out the race with the operator's Secret-mount +// propagation. // // Polls at 2s intervals — fast enough for human-observable boot times, // slow enough that a pod full of plugins isn't hammering the kubelet. diff --git a/authbridge/authlib/pipeline/plugin.go b/authbridge/authlib/pipeline/plugin.go index 9428828ea..199fe1aea 100644 --- a/authbridge/authlib/pipeline/plugin.go +++ b/authbridge/authlib/pipeline/plugin.go @@ -203,8 +203,8 @@ type Finisher interface { // ANDs Ready() across all implementers to decide whether the pipeline // is ready to serve traffic. A plugin whose Configure succeeded but // whose Init is still waiting (e.g. for a credential file to be -// written by client-registration) returns false — the kubelet keeps -// traffic off the pod until Init completes. +// mounted by the operator from a Secret) returns false — the kubelet +// keeps traffic off the pod until Init completes. // // Plugins without deferred state don't implement this interface and // are treated as always-ready. Pipeline.Ready() returns true when diff --git a/authbridge/authlib/plugins/jwtvalidation/plugin.go b/authbridge/authlib/plugins/jwtvalidation/plugin.go index 08740aafa..cf1449be3 100644 --- a/authbridge/authlib/plugins/jwtvalidation/plugin.go +++ b/authbridge/authlib/plugins/jwtvalidation/plugin.go @@ -61,9 +61,10 @@ type jwtValidationConfig struct { Audience string `json:"audience"` // AudienceFile reads the expected audience from a file. Used - // together with client-registration's /shared/client-id.txt. The - // file may not exist at Configure time; a background poll started - // by Init waits for it and updates the plugin when it appears. + // together with /shared/client-id.txt (mounted by the operator + // from a Keycloak-credentials Secret). The file may not exist at + // Configure time; a background poll started by Init waits for it + // and updates the plugin when it appears. // // Note: an empty-string value is treated as "unset" — applyDefaults // will fill in /shared/client-id.txt. To opt out of any file poll, @@ -100,9 +101,9 @@ func (c *jwtValidationConfig) applyDefaults() { c.AudienceMode = "static" } // When neither Audience nor AudienceFile is set, fall back to the - // Kagenti convention: client-registration writes the agent's client - // ID (which doubles as the inbound audience) to this path. - // Deployments that don't run client-registration should set + // Kagenti convention: the operator's webhook mounts a Secret + // containing the agent's client ID (which doubles as the inbound + // audience) at this path. Deployments outside Kagenti should set // Audience explicitly — the Configure-time read is best-effort and // Init's poll will give up silently if ctx is cancelled. if c.AudienceMode == "static" && c.Audience == "" && c.AudienceFile == "" { @@ -146,8 +147,8 @@ type JWTValidation struct { // bgCancel stops the background audience-file poller started by // Init. It's created with context.Background() (not Init's ctx) so // the poller's lifetime is the plugin's lifetime, not Start's - // 60-second budget — otherwise a slow client-registration during - // pod boot would orphan the plugin after the initCtx deadline. + // 60-second budget — otherwise a slow Secret-mount propagation + // during pod boot would orphan the plugin after the initCtx deadline. // // Held in an atomic.Pointer so a future caller can invoke Shutdown // from a goroutine other than the one that ran Init without racing @@ -173,8 +174,8 @@ func (p *JWTValidation) Capabilities() pipeline.PluginCapabilities { // Configure decodes the plugin's config subtree, applies defaults, // validates, and constructs the internal auth handler. If AudienceFile -// is set but the file isn't yet readable (client-registration still -// provisioning during pod boot), the handler is created with an empty +// is set but the file isn't yet readable (Secret mount still +// propagating during pod boot), the handler is created with an empty // audience and Init's goroutine fills it in when the file appears. func (p *JWTValidation) Configure(raw json.RawMessage) error { var c jwtValidationConfig diff --git a/authbridge/authlib/plugins/plugins_test.go b/authbridge/authlib/plugins/plugins_test.go index 32ddc905f..850ed2f37 100644 --- a/authbridge/authlib/plugins/plugins_test.go +++ b/authbridge/authlib/plugins/plugins_test.go @@ -3,8 +3,6 @@ package plugins_test import ( "context" "net/http" - "os" - "path/filepath" "strings" "testing" @@ -50,45 +48,6 @@ func TestBuiltinsRegistered(t *testing.T) { } } -// TestAuthbridgeCombinedYAML_Loads asserts that the in-repo default -// config consumed by the combined sidecar image parses and produces -// working pipelines. A future rename of any plugin default constant -// that silently breaks the shipped image fails this test first. -func TestAuthbridgeCombinedYAML_Loads(t *testing.T) { - yamlPath := filepath.Join("..", "..", "authproxy", "authbridge-combined.yaml") - if _, err := os.Stat(yamlPath); err != nil { - t.Skipf("authbridge-combined.yaml not found (repo layout changed?): %v", err) - } - - envs := map[string]string{ - "ISSUER": "http://keycloak.localtest.me:8080/realms/kagenti", - "KEYCLOAK_URL": "http://keycloak-service.keycloak.svc:8080", - "KEYCLOAK_REALM": "kagenti", - "DEFAULT_OUTBOUND_POLICY": "passthrough", - "TOKEN_URL": "", - } - for k, v := range envs { - t.Setenv(k, v) - } - - cfg, err := config.Load(yamlPath) - if err != nil { - t.Fatalf("Load(%s): %v", yamlPath, err) - } - if cfg.Mode != config.ModeEnvoySidecar { - t.Errorf("mode = %q, want %q", cfg.Mode, config.ModeEnvoySidecar) - } - if err := config.Validate(cfg); err != nil { - t.Errorf("Validate: %v", err) - } - if _, err := plugins.Build(cfg.Pipeline.Inbound.Plugins); err != nil { - t.Errorf("Build inbound: %v", err) - } - if _, err := plugins.Build(cfg.Pipeline.Outbound.Plugins); err != nil { - t.Errorf("Build outbound: %v", err) - } -} - // --- Stats aggregation --- func TestCollectStats_CollectsOnlyStatsSources(t *testing.T) { diff --git a/authbridge/authlib/plugins/tokenexchange/plugin.go b/authbridge/authlib/plugins/tokenexchange/plugin.go index 439488744..9809c0bed 100644 --- a/authbridge/authlib/plugins/tokenexchange/plugin.go +++ b/authbridge/authlib/plugins/tokenexchange/plugin.go @@ -333,7 +333,7 @@ func credentialsAreReady(id tokenExchangeIdentity) bool { return id.ClientSecret != "" case "spiffe": // JWT-SVID source reads the file lazily; existence of ClientID - // is the signal that client-registration has completed. + // is the signal that the operator's Secret mount has completed. return true } return false diff --git a/authbridge/authproxy/Dockerfile.authbridge b/authbridge/authproxy/Dockerfile.authbridge deleted file mode 100644 index b09b1e115..000000000 --- a/authbridge/authproxy/Dockerfile.authbridge +++ /dev/null @@ -1,54 +0,0 @@ -# Combined AuthBridge sidecar image -# Includes: Envoy proxy, unified authbridge binary, spiffe-helper, client-registration (Python) -# -# Build context: ./authbridge (needs access to authlib/, cmd/authbridge/, and client-registration/) - -# Stage 1: Build unified authbridge binary -FROM golang:1.26.2-alpine AS go-builder - -RUN apk add --no-cache git - -WORKDIR /app - -# Copy go.mod/go.sum first for better Docker layer caching — -# dependency layer is only invalidated when go.mod/go.sum change. -COPY cmd/authbridge/go.mod cmd/authbridge/go.sum cmd/authbridge/ -COPY authlib/go.mod authlib/go.sum authlib/ -ENV GOWORK=off -RUN cd cmd/authbridge && go mod download - -COPY authlib/ authlib/ -COPY cmd/authbridge/ cmd/authbridge/ - -RUN cd cmd/authbridge && CGO_ENABLED=0 GOOS=linux go build -o /authbridge . - -# Stage 2: Get spiffe-helper binary -FROM ghcr.io/spiffe/spiffe-helper:0.11.0 AS spiffe - -# Stage 3: Combined runtime -FROM docker.io/envoyproxy/envoy:v1.37.2 - -USER root -RUN apt-get update && \ - apt-get install -y --no-install-recommends ca-certificates python3 python3-pip && \ - rm -rf /var/lib/apt/lists/* - -# Copy binaries -COPY --from=go-builder /authbridge /usr/local/bin/authbridge -COPY --from=spiffe /spiffe-helper /usr/local/bin/spiffe-helper - -# Copy client-registration Python code -COPY client-registration/client_registration.py /app/ -COPY client-registration/requirements.txt /app/ -RUN pip3 install --no-cache-dir -r /app/requirements.txt - -# Copy entrypoint and default config -COPY authproxy/entrypoint-authbridge.sh /usr/local/bin/entrypoint-authbridge.sh -COPY authproxy/authbridge-combined.yaml /etc/authbridge/config.yaml -RUN chmod +x /usr/local/bin/entrypoint-authbridge.sh - -USER 1337 - -EXPOSE 15123 15124 9090 9901 - -ENTRYPOINT ["/usr/local/bin/entrypoint-authbridge.sh"] diff --git a/authbridge/authproxy/Makefile b/authbridge/authproxy/Makefile index 9440e6634..0e7181b4d 100644 --- a/authbridge/authproxy/Makefile +++ b/authbridge/authproxy/Makefile @@ -13,8 +13,9 @@ docker-build-init: podman build -f Dockerfile.init -t proxy-init:latest . # Build demo Docker images (auth-proxy, demo-app, proxy-init). -# The authbridge-unified sidecar image is built from cmd/authbridge/Dockerfile: -# cd authbridge && podman build -f cmd/authbridge/Dockerfile -t authbridge-unified:latest . +# The combined sidecar images are built from authbridge/ context: +# cd authbridge && podman build -f cmd/authbridge/Dockerfile.proxy -t authbridge:latest . +# cd authbridge && podman build -f cmd/authbridge/Dockerfile.envoy -t authbridge-envoy:latest . build-images: docker-build-proxy docker-build-target docker-build-init # Load Docker images into kind cluster and re-tag for podman compatibility. diff --git a/authbridge/authproxy/authbridge-combined.yaml b/authbridge/authproxy/authbridge-combined.yaml deleted file mode 100644 index b4bcf5515..000000000 --- a/authbridge/authproxy/authbridge-combined.yaml +++ /dev/null @@ -1,45 +0,0 @@ -# Default config for the combined AuthBridge sidecar image. -# Env vars are expanded at startup by the unified binary's config loader. -# These env vars match the operator's ConfigMap contract (authbridge-config). -# At runtime they land on the sidecar via envFrom on the Deployment — -# injected by the kagenti-operator admission webhook (or by the chart -# template for pre-declared namespaces). Keys are listed in -# authbridge/CLAUDE.md "Required ConfigMaps for Webhook Injection". -# -# Plugin settings live under pipeline.*.plugins[].config; see -# authbridge/docs/plugin-reference.md for the per-plugin schema. -# Fields omitted below fall back to plugin defaults — notably: -# jwt-validation: audience_file=/shared/client-id.txt, bypass_paths -# = .well-known/* + health/ready/live probes, -# jwks_url derived from keycloak_url + keycloak_realm -# (internal URL; see split-horizon note below). -# token-exchange: identity file paths under /shared/, jwt_svid_path -# =/opt/jwt_svid.token, routes.file -# =/etc/authproxy/routes.yaml -# -# Split-horizon DNS note: `issuer` is the PUBLIC Keycloak URL (matches -# the `iss` claim Keycloak stamps on tokens); `keycloak_url` is the -# INTERNAL service URL that the sidecar actually reaches. The -# jwt-validation plugin uses the latter to build its JWKS fetch URL, -# which is why KEYCLOAK_URL has to be passed into inbound as well as -# outbound — even though pre-PR-#378 the binary derived jwks_url from -# outbound.token_url via a cross-plugin pass. -mode: envoy-sidecar - -pipeline: - inbound: - plugins: - - name: jwt-validation - config: - issuer: "${ISSUER}" - keycloak_url: "${KEYCLOAK_URL}" - keycloak_realm: "${KEYCLOAK_REALM}" - outbound: - plugins: - - name: token-exchange - config: - keycloak_url: "${KEYCLOAK_URL}" - keycloak_realm: "${KEYCLOAK_REALM}" - default_policy: "${DEFAULT_OUTBOUND_POLICY}" - identity: - type: "client-secret" diff --git a/authbridge/authproxy/entrypoint-authbridge.sh b/authbridge/authproxy/entrypoint-authbridge.sh deleted file mode 100644 index 57c125960..000000000 --- a/authbridge/authproxy/entrypoint-authbridge.sh +++ /dev/null @@ -1,101 +0,0 @@ -#!/bin/bash -set -eu - -# AuthBridge combined entrypoint with process supervision. -# Manages: spiffe-helper, client-registration, authbridge (unified binary), envoy -# -# Startup order preserves current multi-container timing: -# 1. spiffe-helper (background, long-running) -- writes JWT SVID -# 2. authbridge (background) -- resolves credentials lazily via file polling -# 3. client-registration (background one-shot) -- writes credentials when ready -# 4. envoy (background) -- inbound JWT validation works immediately -# -# Runs as UID 1337 (Envoy UID, excluded from iptables redirect). -# -# Process management: The shell stays as PID 1 and monitors critical long-running -# processes (envoy, authbridge, spiffe-helper). If any critical process exits, -# all others are killed and the container exits non-zero so Kubernetes restarts it. -# Client-registration is a one-shot process and is NOT monitored -- authbridge -# handles missing credentials gracefully via passthrough mode. - -# PIDs of critical long-running processes to monitor. -CRITICAL_PIDS="" - -cleanup() { - echo "[AuthBridge] Received signal, shutting down..." - # shellcheck disable=SC2086 - kill $CRITICAL_PIDS 2>/dev/null || true - wait - exit 0 -} -trap cleanup TERM INT - -# --- Phase 1: Start spiffe-helper (if enabled) --- -if [ "${SPIRE_ENABLED:-}" = "true" ]; then - echo "[AuthBridge] Starting spiffe-helper..." - /usr/local/bin/spiffe-helper -config=/etc/spiffe-helper/helper.conf run & - CRITICAL_PIDS="$CRITICAL_PIDS $!" -fi - -# --- Phase 2: Start authbridge (unified binary) --- -# authbridge resolves credentials lazily via file polling (60s timeout). -# The gRPC listener starts immediately so Envoy can connect without waiting. -echo "[AuthBridge] Starting authbridge..." -/usr/local/bin/authbridge --config /etc/authbridge/config.yaml & -CRITICAL_PIDS="$CRITICAL_PIDS $!" -sleep 2 - -# --- Phase 3: Start client-registration (background, non-blocking) --- -# This runs asynchronously so Envoy starts immediately. -# Failures are non-fatal: authbridge handles missing credentials gracefully. -# NOT added to CRITICAL_PIDS -- this is a one-shot process. -( - if [ "${SPIRE_ENABLED:-}" = "true" ]; then - echo "[AuthBridge] Waiting for SPIFFE credentials..." - while [ ! -f /opt/jwt_svid.token ]; do sleep 1; done - echo "[AuthBridge] SPIFFE credentials ready" - - # Extract client ID from JWT SVID payload. - # Each step is validated individually to avoid silent failures in the pipeline. - JWT_PAYLOAD=$(cut -d'.' -f2 < /opt/jwt_svid.token) - if [ -z "$JWT_PAYLOAD" ]; then - echo "[AuthBridge] ERROR: Failed to extract JWT payload from SVID" >&2 - fi - CLIENT_ID=$(echo "${JWT_PAYLOAD}==" | base64 -d 2>/dev/null | \ - python3 -c "import sys,json; print(json.load(sys.stdin).get('sub',''))") - if [ -z "$CLIENT_ID" ]; then - echo "[AuthBridge] ERROR: Failed to decode client ID from JWT SVID" >&2 - fi - echo "$CLIENT_ID" > /shared/client-id.txt - echo "[AuthBridge] Client ID (SPIFFE ID): $CLIENT_ID" - else - echo "$CLIENT_NAME" > /shared/client-id.txt - echo "[AuthBridge] Client ID: $CLIENT_NAME" - fi - - if [ "${CLIENT_REGISTRATION_ENABLED:-}" != "false" ]; then - echo "[AuthBridge] Starting client registration..." - python3 /app/client_registration.py || \ - echo "[AuthBridge] WARNING: Client registration failed, continuing without" - echo "[AuthBridge] Client registration phase complete" - fi -) & - -# --- Phase 4: Start Envoy (background) --- -# Envoy runs in the background so the shell can monitor all critical processes. -# Kubernetes sends SIGTERM to PID 1 (this shell), which forwards via trap. -echo "[AuthBridge] Starting Envoy..." -/usr/local/bin/envoy -c /etc/envoy/envoy.yaml \ - --service-cluster auth-proxy --service-node auth-proxy & -CRITICAL_PIDS="$CRITICAL_PIDS $!" - -# Wait for the first critical process to exit. -# If any critical process dies, restart the container. -# shellcheck disable=SC2086 -wait -n $CRITICAL_PIDS -EXIT_CODE=$? -echo "[AuthBridge] A critical process exited unexpectedly (exit code $EXIT_CODE), terminating container" -# shellcheck disable=SC2086 -kill $CRITICAL_PIDS 2>/dev/null || true -wait -exit 1 diff --git a/authbridge/client-registration/Dockerfile b/authbridge/client-registration/Dockerfile deleted file mode 100644 index f5a8145c0..000000000 --- a/authbridge/client-registration/Dockerfile +++ /dev/null @@ -1,27 +0,0 @@ -# Use minimal Python base image -FROM python:3.14-slim - -# Create non-root user with UID/GID 1000 (matches spiffe-helper and fsGroup) -# IMPORTANT: These values MUST match the runAsUser/runAsGroup set by the -# operator's webhook when injecting the client-registration container. -# See https://github.com/kagenti/kagenti-operator -RUN groupadd -g 1000 appuser && useradd -u 1000 -g appuser appuser - -# Set work directory -WORKDIR /app - -# Copy your script -COPY client_registration.py . - -# Install dependencies -COPY requirements.txt . -RUN pip install --no-cache-dir -r requirements.txt - -# Ensure non-root user owns application directory -RUN chown -R 1000:1000 /app - -# Switch to non-root user -USER 1000:1000 - -# Register client -CMD ["python", "client_registration.py"] diff --git a/authbridge/client-registration/README.md b/authbridge/client-registration/README.md deleted file mode 100644 index 55675283a..000000000 --- a/authbridge/client-registration/README.md +++ /dev/null @@ -1,386 +0,0 @@ -# Client Registration - -Client Registration is an **automated OAuth2/OIDC client provisioning** tool for Kubernetes workloads. It automatically registers pods as Keycloak clients, eliminating the need for manual client configuration and static credentials. - -## What Client Registration Does - -Client Registration solves a common challenge in Kubernetes environments: **how can workloads authenticate to OAuth2/OIDC providers without pre-provisioned static credentials?** - -### The Problem - -Traditional OAuth2 authentication requires: -1. Pre-creating clients in Keycloak for each service -2. Generating and distributing client secrets -3. Managing secret rotation manually -4. Tracking which client belongs to which workload - -``` -┌─────────────────┐ ┌─────────────────┐ -│ Admin │ │ Keycloak │ -│ │─────────│ │ -│ 1. Create client│ Manual │ Client: svc-a │ -│ 2. Copy secret │ Process │ Secret: │ -│ 3. Create K8s │ │ │ -│ secret │ │ │ -└─────────────────┘ └─────────────────┘ - │ - ▼ Manual secret distribution -┌─────────────────┐ -│ Workload Pod │ -│ (uses secret) │ -└─────────────────┘ -``` - -### The Solution - -Client Registration automates this entire process: - -``` -┌─────────────────────────────────────────────────────────────────────┐ -│ POD │ -│ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ │ -│ │ SPIFFE Helper │───►│ Client │───►│ Application │ │ -│ │ (gets SVID) │ │ Registration │ │ (uses secret) │ │ -│ └─────────────────┘ └────────┬────────┘ └─────────────────┘ │ -│ │ │ -└──────────────────────────────────┼──────────────────────────────────┘ - │ - ┌──────────────▼──────────────┐ - │ Keycloak │ - │ │ - │ 1. Register client │ - │ (using SPIFFE ID) │ - │ 2. Generate secret │ - │ 3. Return credentials │ - └─────────────────────────────┘ -``` - -**Benefits:** -- **Zero manual configuration** - Clients are created automatically at pod startup -- **Cryptographic identity** - Uses SPIFFE ID as client identifier (unique, verifiable) -- **Dynamic secrets** - Each pod instance gets its own credentials -- **Self-service** - No admin intervention needed for new workloads - -## How It Works - -### With SPIFFE/SPIRE (Recommended) - -When SPIRE is enabled, the client registration uses the pod's **SPIFFE ID** as the Keycloak client ID: - -``` -SPIFFE ID: spiffe://localtest.me/ns/authbridge/sa/caller - └──────────┘ └─────────┘ └──────┘ - Trust Domain Namespace Service Account -``` - -1. **SPIFFE Helper** obtains a JWT SVID from the SPIRE Agent -2. **Client Registration** extracts the SPIFFE ID from the JWT's `sub` claim -3. **Client Registration** creates a Keycloak client with: - - `clientId`: The SPIFFE ID (e.g., `spiffe://localtest.me/ns/authbridge/sa/caller`) - - `name`: The friendly name from `CLIENT_NAME` env var -4. **Client Registration** retrieves the generated client secret -5. **Client Registration** writes the secret to a shared volume for the application to use - -### Without SPIFFE - -When SPIRE is not available, the client registration uses a static client name: - -1. **Client Registration** uses the `CLIENT_NAME` environment variable as both client ID and name -2. The rest of the flow is the same - -## Architecture - -```mermaid -sequenceDiagram - participant SPIRE as SPIRE Agent - participant Helper as SPIFFE Helper - participant Reg as Client Registration - participant KC as Keycloak - participant App as Application - - Note over Helper,SPIRE: Pod Startup - SPIRE->>Helper: Issue JWT SVID - Helper->>Helper: Write to /opt/jwt_svid.token - - Reg->>Reg: Wait for SVID file - Reg->>Reg: Extract SPIFFE ID from JWT - - Reg->>KC: Register client (SPIFFE ID) - KC-->>Reg: Client created - - Reg->>KC: Get client secret - KC-->>Reg: Secret value - - Reg->>Reg: Write to /shared/client-secret.txt - - App->>App: Read secret from shared volume - App->>KC: Authenticate with credentials -``` - -## Configuration - -### Environment Variables - -| Variable | Required | Description | Example | -|----------|----------|-------------|---------| -| `SPIRE_ENABLED` | No | Enable SPIFFE ID extraction (default: `false`) | `true` | -| `CLIENT_NAME` | Yes | Friendly name for the client | `my-service` | -| `KEYCLOAK_URL` | Yes* | Keycloak server URL (auto-derived from `TOKEN_URL` if not provided) | `http://keycloak:8080` | -| `KEYCLOAK_REALM` | Yes* | Keycloak realm name (auto-derived from `TOKEN_URL` if not provided) | `kagenti` | -| `TOKEN_URL` | No | Keycloak token endpoint (used to auto-derive `KEYCLOAK_URL` and `KEYCLOAK_REALM`) | `http://keycloak:8080/realms/kagenti/protocol/openid-connect/token` | -| `KEYCLOAK_ADMIN_USERNAME` | Yes | Admin username | `admin` | -| `KEYCLOAK_ADMIN_PASSWORD` | Yes | Admin password | `admin` | -| `KEYCLOAK_TOKEN_EXCHANGE_ENABLED` | No | Enable token exchange for client (default: `true`) | `true` | -| `KEYCLOAK_CLIENT_REGISTRATION_ENABLED` | No | Enable/disable registration (default: `true`) | `true` | -| `SECRET_FILE_PATH` | No | Path to write client secret (default: `/shared/client-secret.txt`) | `/shared/client-secret.txt` | - -**Note:** `KEYCLOAK_URL` and `KEYCLOAK_REALM` can be automatically derived from `TOKEN_URL` if not explicitly provided. For example: -- `TOKEN_URL`: `http://keycloak-service.keycloak.svc:8080/realms/kagenti/protocol/openid-connect/token` -- Derived `KEYCLOAK_URL`: `http://keycloak-service.keycloak.svc:8080` -- Derived `KEYCLOAK_REALM`: `kagenti` - -### Created Client Configuration - -The registered Keycloak client is configured with: - -| Setting | Value | Description | -|---------|-------|-------------| -| `clientId` | SPIFFE ID or CLIENT_NAME | Unique client identifier | -| `name` | CLIENT_NAME | Human-readable name | -| `publicClient` | `false` | Client authentication enabled (confidential client) | -| `serviceAccountsEnabled` | `true` | Allows `client_credentials` grant | -| `standardFlowEnabled` | `true` | Allows authorization code flow | -| `directAccessGrantsEnabled` | `true` | Allows password grant | -| `standard.token.exchange.enabled` | `true` | Allows token exchange | - -## Quick Start - -### Prerequisites - -- Kubernetes cluster -- Keycloak deployed and accessible -- (Optional) SPIRE installed for SPIFFE support - -### Usage with SPIFFE/SPIRE - -1. **Ensure SPIRE is installed** (see [SPIRE Installation](#install-spire)) - -2. **Deploy your workload with client-registration:** - -```yaml -apiVersion: apps/v1 -kind: Deployment -metadata: - name: my-app -spec: - template: - spec: - serviceAccountName: my-service-account - containers: - # Your application - - name: my-app - image: my-app:latest - volumeMounts: - - name: shared-data - mountPath: /shared - - # SPIFFE Helper - obtains SVID from SPIRE - - name: spiffe-helper - image: ghcr.io/spiffe/spiffe-helper:nightly - command: ["/spiffe-helper", "-config=/etc/spiffe-helper/helper.conf", "run"] - volumeMounts: - - name: spiffe-helper-config - mountPath: /etc/spiffe-helper - - name: spire-agent-socket - mountPath: /spiffe-workload-api - - name: svid-output - mountPath: /opt - - # Client Registration - registers with Keycloak - - name: client-registration - image: ghcr.io/kagenti/kagenti-extensions/client-registration:latest - command: - - /bin/sh - - -c - - | - while [ ! -f /opt/jwt_svid.token ]; do sleep 1; done - python client_registration.py - tail -f /dev/null - env: - - name: SPIRE_ENABLED - value: "true" - - name: CLIENT_NAME - value: "my-app" - - name: KEYCLOAK_URL - value: "http://keycloak-service.keycloak.svc:8080" - - name: KEYCLOAK_REALM - value: "kagenti" - - name: KEYCLOAK_ADMIN_USERNAME - value: "admin" - - name: KEYCLOAK_ADMIN_PASSWORD - value: "admin" - - name: SECRET_FILE_PATH - value: "/shared/client-secret.txt" - volumeMounts: - - name: shared-data - mountPath: /shared - - name: svid-output - mountPath: /opt - - volumes: - - name: shared-data - emptyDir: {} - - name: svid-output - emptyDir: {} - - name: spire-agent-socket - hostPath: - path: /run/spire/agent-sockets - - name: spiffe-helper-config - configMap: - name: spiffe-helper-config -``` - -3. **Verify registration:** - -```bash -# Check client-registration logs -kubectl logs deployment/my-app -c client-registration - -# Expected output: -# Created Keycloak client "spiffe://localtest.me/ns/default/sa/my-service-account": -# Successfully retrieved secret for client "my-app". -# Secret written to file: "/shared/client-secret.txt" -# Client registration complete. -``` - -4. **Use the credentials in your application:** - -```bash -# From inside your application container -CLIENT_SECRET=$(cat /shared/client-secret.txt) -CLIENT_ID="spiffe://localtest.me/ns/default/sa/my-service-account" - -# Get a token from Keycloak -curl -X POST http://keycloak:8080/realms/kagenti/protocol/openid-connect/token \ - -d "grant_type=client_credentials" \ - -d "client_id=$CLIENT_ID" \ - -d "client_secret=$CLIENT_SECRET" -``` - -### Usage without SPIFFE - -For environments without SPIRE, use a static client name: - -```yaml -- name: client-registration - image: ghcr.io/kagenti/kagenti-extensions/client-registration:latest - command: ["python", "client_registration.py"] - env: - - name: SPIRE_ENABLED - value: "false" - - name: CLIENT_NAME - value: "my-app" # Used as both client ID and name - # ... other env vars -``` - -## Installation - -### Install SPIRE - -If using SPIFFE, install SPIRE first: - -```bash -# Install SPIRE CRDs -helm upgrade --install spire-crds spire-crds \ - -n spire-mgmt \ - --repo https://spiffe.github.io/helm-charts-hardened/ \ - --create-namespace --wait - -# Install SPIRE (Server + Agent) -helm upgrade --install spire spire \ - -n spire-mgmt \ - --repo https://spiffe.github.io/helm-charts-hardened/ \ - -f https://raw.githubusercontent.com/kagenti/kagenti/main/kagenti/installer/app/resources/spire-helm-values.yaml -``` - -### Install Keycloak - -```bash -# Create namespace -kubectl apply -f "https://raw.githubusercontent.com/kagenti/kagenti/refs/heads/main/kagenti/installer/app/resources/keycloak-namespace.yaml" - -# Deploy Keycloak with Postgres -kubectl apply -f "https://raw.githubusercontent.com/kagenti/kagenti/refs/heads/main/kagenti/installer/app/resources/keycloak.yaml" -n keycloak - -# Wait for Keycloak to be ready -kubectl rollout status statefulset/keycloak -n keycloak --timeout=120s - -# Port forward to access Keycloak UI -kubectl port-forward service/keycloak-service -n keycloak 8080:8080 -``` - -Access Keycloak at http://keycloak.localtest.me:8080 (admin/admin) - -## Example Deployments - -### With SPIFFE - -```bash -kubectl apply -f example_deployment_spiffe.yaml -``` - -Creates: -- Namespace `my-agent` -- ServiceAccount `my-service-account` -- Deployment with SPIFFE Helper and Client Registration - -**Verify in Keycloak:** -Navigate to Clients and confirm a new client with SPIFFE ID: - -![Client with SPIFFE ID](images/clients_with_spire.png) - -### Without SPIFFE - -```bash -kubectl apply -f example_deployment.yaml -``` - -**Verify in Keycloak:** -Navigate to Clients and confirm a new client with static name: - -![Client without SPIFFE](images/clients.png) - -## Troubleshooting - -### Client Registration Fails to Connect to Keycloak - -**Symptom:** `Connection refused` or timeout errors - -**Fix:** Ensure the pod can reach Keycloak. If using AuthProxy with iptables, exclude Keycloak's port: -```yaml -env: - - name: OUTBOUND_PORTS_EXCLUDE - value: "8080" -``` - -### "Client not enabled to retrieve service account" - -**Symptom:** Token request fails with this error - -**Fix:** The client needs `serviceAccountsEnabled: true`. This is set by default in the latest version. For existing clients, enable it manually in Keycloak or delete and re-register. - -### SVID File Not Found - -**Symptom:** `Error: The file /opt/jwt_svid.token was not found` - -**Fix:** Ensure SPIFFE Helper is running and has access to the SPIRE Agent socket. Check: -```bash -kubectl logs deployment/my-app -c spiffe-helper -``` - -## Related Documentation - -- [AuthBridge Demo](../README.md) - Complete end-to-end demo -- [AuthProxy](../authproxy/README.md) - Token validation and exchange -- [SPIFFE/SPIRE Documentation](https://spiffe.io/docs/latest/) -- [Keycloak Client Registration](https://www.keycloak.org/docs/latest/securing_apps/#_client_registration) diff --git a/authbridge/client-registration/client_registration.py b/authbridge/client-registration/client_registration.py deleted file mode 100644 index 97ba9f82c..000000000 --- a/authbridge/client-registration/client_registration.py +++ /dev/null @@ -1,358 +0,0 @@ -""" -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 -import re -from typing import Any - -import jwt -from keycloak import KeycloakAdmin, KeycloakPostError - - -def get_env_var(name: str, default: str | None = None) -> str: - """ - Fetch an environment variable or return default if provided. - Raise ValueError if missing and no default is set. - """ - value = os.environ.get(name) - if value is not None and value != "": - return value - if default is not None: - return default - raise ValueError(f"Missing required environment variable: {name}") - - -def derive_keycloak_config_from_token_url(token_url: str) -> tuple[str | None, str | None]: - """ - Derive KEYCLOAK_URL and KEYCLOAK_REALM from TOKEN_URL. - - Example: - TOKEN_URL: http://keycloak-service.keycloak.svc:8080/realms/kagenti/protocol/openid-connect/token - Returns: ("http://keycloak-service.keycloak.svc:8080", "kagenti") - - Returns (None, None) if parsing fails. - """ - # Pattern: /realms//protocol/openid-connect/token - match = re.match(r"^(https?://[^/]+)/realms/([^/]+)/", token_url) - if match: - return match.group(1), match.group(2) - return None, None - - -def write_client_secret( - keycloak_admin: KeycloakAdmin, - internal_client_id: str, - client_name: str, - secret_file_path: str = "secret.txt", -) -> None: - """ - Retrieve the secret for a Keycloak client and write it to a file. - """ - try: - # There will be a value field if client authentication is enabled - # client authentication is enabled if "publicClient" is False - secret = keycloak_admin.get_client_secrets(internal_client_id)["value"] - print(f'Successfully retrieved secret for client "{client_name}".') - except KeycloakPostError as e: - print(f"Could not retrieve secret for client '{client_name}': {e}") - return - - try: - with open(secret_file_path, "w") as f: - f.write(secret) - print(f'Secret written to file: "{secret_file_path}"') - except OSError as ose: - print(f"Error writing secret to file: {ose}") - - -# TODO: refactor this function so kagenti-client-registration image can use it -def register_client(keycloak_admin: KeycloakAdmin, client_id: str, client_payload: dict[str, Any]) -> str: - """ - Ensure a Keycloak client exists. - Returns the internal client ID. - """ - internal_client_id = keycloak_admin.get_client_id(client_id) - if internal_client_id: - print(f'Client "{client_id}" already exists with ID: {internal_client_id}') - return internal_client_id - - # Create client - try: - internal_client_id = keycloak_admin.create_client(client_payload) - - print(f'Created Keycloak client "{client_id}": {internal_client_id}') - return internal_client_id - except KeycloakPostError as e: - if getattr(e, "response_code", None) == 409: - # Client exists but get_client_id missed it (URL encoding issue - # with SPIFFE IDs containing ://). Search all clients instead. - print(f'Client "{client_id}" already exists (409). Looking up by listing all clients...') - all_clients = keycloak_admin.get_clients() - for c in all_clients: - if c.get("clientId") == client_id: - print(f'Found existing client "{client_id}" with ID: {c["id"]}') - return c["id"] - print(f'Could not create client "{client_id}": {e}') - raise - - -def get_client_id() -> str: - """ - Read the SVID JWT from file and extract the client ID from the "sub" claim. - """ - # Read SVID JWT from file to get client ID - jwt_file_path = "/opt/jwt_svid.token" - content = None - try: - with open(jwt_file_path, "r") as file: - content = file.read() - - except FileNotFoundError: - print(f"Error: The file {jwt_file_path} was not found.") - except Exception as e: - print(f"An error occurred: {e}") - - if content is None or content.strip() == "": - raise Exception("No content read from SVID JWT.") - - # Decode JWT to get client ID - decoded = jwt.decode(content, options={"verify_signature": False}) - if "sub" not in decoded: - 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. 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. -# Otherwise, use the client name as the client ID. -if get_env_var("SPIRE_ENABLED", "false").lower() == "true": - client_id = get_client_id() -else: - client_id = client_name - -# Try to derive KEYCLOAK_URL and KEYCLOAK_REALM from TOKEN_URL if not directly provided -# This provides backwards compatibility and reduces configuration duplication -TOKEN_URL = os.environ.get("TOKEN_URL") -DERIVED_KEYCLOAK_URL = None -DERIVED_KEYCLOAK_REALM = None -if TOKEN_URL: - DERIVED_KEYCLOAK_URL, DERIVED_KEYCLOAK_REALM = derive_keycloak_config_from_token_url(TOKEN_URL) - if DERIVED_KEYCLOAK_URL: - print(f"Derived KEYCLOAK_URL from TOKEN_URL: {DERIVED_KEYCLOAK_URL}") - if DERIVED_KEYCLOAK_REALM: - print(f"Derived KEYCLOAK_REALM from TOKEN_URL: {DERIVED_KEYCLOAK_REALM}") - -try: - # Try explicit env var first, then fall back to derived value from TOKEN_URL - KEYCLOAK_URL = get_env_var("KEYCLOAK_URL", DERIVED_KEYCLOAK_URL) - KEYCLOAK_REALM = get_env_var("KEYCLOAK_REALM", DERIVED_KEYCLOAK_REALM) - KEYCLOAK_TOKEN_EXCHANGE_ENABLED = get_env_var("KEYCLOAK_TOKEN_EXCHANGE_ENABLED", "true").lower() == "true" - KEYCLOAK_CLIENT_REGISTRATION_ENABLED = get_env_var("KEYCLOAK_CLIENT_REGISTRATION_ENABLED", "true").lower() == "true" - # CLIENT_AUTH_TYPE controls how the client authenticates to Keycloak: - # - "client-secret": Traditional client_secret authentication (default) - # - "federated-jwt": JWT-SVID authentication via SPIFFE identity provider - CLIENT_AUTH_TYPE = get_env_var("CLIENT_AUTH_TYPE", "client-secret") -except ValueError as e: - print(f"Expected environment variable missing. Skipping client registration of {client_id}.") - print(e) - exit(1) - -if not KEYCLOAK_CLIENT_REGISTRATION_ENABLED: - print( - "Client registration (KEYCLOAK_CLIENT_REGISTRATION_ENABLED=false) disabled." - f" Skipping registration of {client_id}." - ) - exit(0) - -keycloak_admin = KeycloakAdmin( - server_url=KEYCLOAK_URL, - username=get_env_var("KEYCLOAK_ADMIN_USERNAME"), - password=get_env_var("KEYCLOAK_ADMIN_PASSWORD"), - realm_name=KEYCLOAK_REALM, - user_realm_name="master", -) - -# Build client payload based on authentication type -client_payload = { - "name": client_name, - "clientId": client_id, - "standardFlowEnabled": True, - "directAccessGrantsEnabled": True, - "serviceAccountsEnabled": True, # Required for client_credentials grant - "fullScopeAllowed": False, - "publicClient": False, # Enable client authentication - # Enable token exchange for this client. - # Token exchange allows this client to exchange tokens for other tokens, potentially across different clients. - # Use case: [EXPLAIN THE SPECIFIC USE CASE HERE, e.g., - # "Required for service-to-service authentication in microservices architecture."] - # Security considerations: Ensure only trusted clients have this capability, - # restrict scopes and permissions as needed, - # and audit usage to prevent privilege escalation or unauthorized access. - "attributes": { - "standard.token.exchange.enabled": str(KEYCLOAK_TOKEN_EXCHANGE_ENABLED).lower(), # Enable token exchange - }, -} - -# Configure client authentication type -if CLIENT_AUTH_TYPE == "federated-jwt": - print("Configuring client for JWT-SVID authentication (federated-jwt)") - client_payload["clientAuthenticatorType"] = "federated-jwt" - # Add federated JWT attributes for SPIFFE authentication - # These tell Keycloak to validate JWT-SVIDs from the SPIFFE identity provider - spiffe_idp_alias = get_env_var("SPIFFE_IDP_ALIAS", "spire-spiffe") - client_payload["attributes"].update( - { - "jwt.credential.issuer": spiffe_idp_alias, - "jwt.credential.sub": client_id, # Must match JWT sub claim (SPIFFE ID) - } - ) -else: - print("Configuring client for client-secret authentication") - client_payload["clientAuthenticatorType"] = "client-secret" - -internal_client_id = register_client( - keycloak_admin, - client_id, - client_payload, -) - -try: - secret_file_path = get_env_var("SECRET_FILE_PATH") -except ValueError: - secret_file_path = "/shared/client-secret.txt" -print( - f'Writing secret for client ID: "{client_id}"' - f' (internal client ID: "{internal_client_id}")' - f' to file: "{secret_file_path}"' -) -write_client_secret( - keycloak_admin, - internal_client_id, - client_name, - 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/client-registration/example_deployment.yaml b/authbridge/client-registration/example_deployment.yaml deleted file mode 100644 index c33a5f0bd..000000000 --- a/authbridge/client-registration/example_deployment.yaml +++ /dev/null @@ -1,108 +0,0 @@ -apiVersion: v1 -kind: Namespace -metadata: - name: my-agent ---- -apiVersion: v1 -kind: ConfigMap -metadata: - name: authbridge-config - namespace: my-agent -data: - SPIRE_ENABLED: "false" - # TOKEN_URL is used to auto-derive KEYCLOAK_URL and KEYCLOAK_REALM - # Replace with your actual Keycloak URL - TOKEN_URL: "http://keycloak-service.keycloak.svc:8080/realms/kagenti/protocol/openid-connect/token" - # Note: Without SPIRE, CLIENT_AUTH_TYPE defaults to "client-secret" - KEYCLOAK_TOKEN_EXCHANGE_ENABLED: "true" - KEYCLOAK_CLIENT_REGISTRATION_ENABLED: "true" ---- -# keycloak-admin-secret - Keycloak admin credentials for client registration -# Replace the placeholder values with your actual admin credentials. -apiVersion: v1 -kind: Secret -metadata: - name: keycloak-admin-secret - namespace: my-agent -type: Opaque -stringData: - KEYCLOAK_ADMIN_USERNAME: "admin" - KEYCLOAK_ADMIN_PASSWORD: "changeme" ---- -apiVersion: v1 -kind: ServiceAccount -metadata: - name: my-service-account - namespace: my-agent ---- -apiVersion: apps/v1 -kind: Deployment -metadata: - name: my-app - namespace: my-agent -spec: - replicas: 1 - selector: - matchLabels: - app.kubernetes.io/name: my-app - template: - metadata: - labels: - app.kubernetes.io/name: my-app - spec: - serviceAccountName: my-service-account - securityContext: - fsGroup: 1000 # Allow shared volume access between containers - imagePullSecrets: - - name: ghcr-secret - containers: - - name: my-app - image: busybox:latest - command: ["sh", "-c", "sleep 3600"] - volumeMounts: - - name: shared-data - mountPath: /shared - - name: client-registration - image: ghcr.io/kagenti/kagenti-extensions/client-registration:latest - command: - - /bin/sh - - -c - - > - if [ "$SPIRE_ENABLED" = "true" ]; then while [ ! -f /opt/jwt_svid.token ]; do echo waiting for SVID; sleep 1; done; fi; - python client_registration.py; - tail -f /dev/null - env: - - name: SPIRE_ENABLED - valueFrom: - configMapKeyRef: - name: authbridge-config - key: SPIRE_ENABLED - - name: KEYCLOAK_URL - valueFrom: - configMapKeyRef: - name: authbridge-config - key: KEYCLOAK_URL - optional: true - - name: KEYCLOAK_REALM - valueFrom: - configMapKeyRef: - name: authbridge-config - key: KEYCLOAK_REALM - - name: KEYCLOAK_ADMIN_USERNAME - valueFrom: - secretKeyRef: - name: keycloak-admin-secret - key: KEYCLOAK_ADMIN_USERNAME - - name: KEYCLOAK_ADMIN_PASSWORD - valueFrom: - secretKeyRef: - name: keycloak-admin-secret - key: KEYCLOAK_ADMIN_PASSWORD - - name: CLIENT_NAME - value: my-app - volumeMounts: - - name: shared-data - mountPath: /shared - volumes: - - name: shared-data - emptyDir: {} diff --git a/authbridge/client-registration/example_deployment_spiffe.yaml b/authbridge/client-registration/example_deployment_spiffe.yaml deleted file mode 100644 index 2ea551b3b..000000000 --- a/authbridge/client-registration/example_deployment_spiffe.yaml +++ /dev/null @@ -1,165 +0,0 @@ -apiVersion: v1 -kind: Namespace -metadata: - name: my-agent ---- -apiVersion: v1 -kind: ConfigMap -metadata: - name: authbridge-config - namespace: my-agent -data: - SPIRE_ENABLED: "true" - # TOKEN_URL is used to auto-derive KEYCLOAK_URL and KEYCLOAK_REALM - # Replace with your actual Keycloak URL - TOKEN_URL: "http://keycloak-service.keycloak.svc:8080/realms/kagenti/protocol/openid-connect/token" - # For federated-jwt authentication (JWT-SVID instead of client-secret) - CLIENT_AUTH_TYPE: "federated-jwt" - SPIFFE_IDP_ALIAS: "spire-spiffe" - KEYCLOAK_TOKEN_EXCHANGE_ENABLED: "true" - KEYCLOAK_CLIENT_REGISTRATION_ENABLED: "true" ---- -# keycloak-admin-secret - Keycloak admin credentials for client registration -# Replace the placeholder values with your actual admin credentials. -apiVersion: v1 -kind: Secret -metadata: - name: keycloak-admin-secret - namespace: my-agent -type: Opaque -stringData: - KEYCLOAK_ADMIN_USERNAME: "admin" - KEYCLOAK_ADMIN_PASSWORD: "changeme" ---- -apiVersion: v1 -kind: ServiceAccount -metadata: - name: my-service-account - namespace: my-agent ---- -apiVersion: apps/v1 -kind: Deployment -metadata: - name: my-app - namespace: my-agent -spec: - replicas: 1 - selector: - matchLabels: - app.kubernetes.io/name: my-app - template: - metadata: - labels: - app.kubernetes.io/name: my-app - spiffe.io/spiffe-id: my-agent - spec: - serviceAccountName: my-service-account - securityContext: - fsGroup: 1000 # Allow shared volume access between containers - imagePullSecrets: - - name: ghcr-secret - containers: - - name: my-app - image: busybox:latest - command: ["sh", "-c", "sleep 3600"] - volumeMounts: - - name: shared-data - mountPath: /shared - - name: spiffe-helper - image: ghcr.io/spiffe/spiffe-helper:nightly - command: - - /spiffe-helper - - -config=/etc/spiffe-helper/helper.conf - - run - securityContext: - runAsUser: 1000 - runAsGroup: 1000 - volumeMounts: - - name: spiffe-helper-config - mountPath: /etc/spiffe-helper - - name: spire-agent-socket - mountPath: /spiffe-workload-api - - name: svid-output - mountPath: /opt - - name: client-registration - image: ghcr.io/kagenti/kagenti-extensions/client-registration:latest - command: - - /bin/sh - - -c - - > - if [ "$SPIRE_ENABLED" = "true" ]; then while [ ! -f /opt/jwt_svid.token ]; do echo waiting for SVID; sleep 1; done; fi; - python client_registration.py; - tail -f /dev/null - env: - - name: SPIRE_ENABLED - valueFrom: - configMapKeyRef: - name: authbridge-config - key: SPIRE_ENABLED - - name: CLIENT_AUTH_TYPE - valueFrom: - configMapKeyRef: - name: authbridge-config - key: CLIENT_AUTH_TYPE - - name: SPIFFE_IDP_ALIAS - valueFrom: - configMapKeyRef: - name: authbridge-config - key: SPIFFE_IDP_ALIAS - - name: TOKEN_URL - valueFrom: - configMapKeyRef: - name: authbridge-config - key: TOKEN_URL - - name: KEYCLOAK_ADMIN_USERNAME - valueFrom: - secretKeyRef: - name: keycloak-admin-secret - key: KEYCLOAK_ADMIN_USERNAME - - name: KEYCLOAK_ADMIN_PASSWORD - valueFrom: - secretKeyRef: - name: keycloak-admin-secret - key: KEYCLOAK_ADMIN_PASSWORD - - name: CLIENT_NAME - value: my-app - securityContext: - runAsUser: 1000 - runAsGroup: 1000 - volumeMounts: - - name: shared-data - mountPath: /shared - - name: svid-output - mountPath: /opt - volumes: - - name: shared-data - emptyDir: {} - - name: spiffe-helper-config - configMap: - name: spiffe-helper-config - - name: spire-agent-socket - csi: - driver: csi.spiffe.io - readOnly: true - - name: svid-output - emptyDir: {} ---- -apiVersion: v1 -kind: ConfigMap -metadata: - name: spiffe-helper-config - namespace: my-agent -data: - helper.conf: | - agent_address = "/spiffe-workload-api/spire-agent.sock" - cmd = "" - cmd_args = "" - cert_dir = "/opt" - svid_file_name = "svid.pem" - svid_key_file_name = "svid_key.pem" - svid_bundle_file_name = "svid_bundle.pem" - # jwt_audience must match Keycloak's issuer URL for JWT-SVID authentication - # Replace with your actual Keycloak public URL - jwt_svids = [{jwt_audience="http://keycloak.localtest.me:8080/realms/kagenti", jwt_svid_file_name="jwt_svid.token"}] - jwt_svid_file_mode = 0644 - include_federated_domains = true diff --git a/authbridge/client-registration/images/clients.png b/authbridge/client-registration/images/clients.png deleted file mode 100644 index 96d919076..000000000 Binary files a/authbridge/client-registration/images/clients.png and /dev/null differ diff --git a/authbridge/client-registration/images/clients_with_spire.png b/authbridge/client-registration/images/clients_with_spire.png deleted file mode 100644 index cc91e091d..000000000 Binary files a/authbridge/client-registration/images/clients_with_spire.png and /dev/null differ diff --git a/authbridge/client-registration/requirements.txt b/authbridge/client-registration/requirements.txt deleted file mode 100644 index 9efb5564d..000000000 --- a/authbridge/client-registration/requirements.txt +++ /dev/null @@ -1,4 +0,0 @@ -python-keycloak==7.1.1 -pyjwt==2.12.1 -# cryptography 47.0.0 causes Illegal Instruction (SIGILL) on ARM64 when importing cryptography.hazmat.primitives -cryptography<47 diff --git a/authbridge/cmd/authbridge/Dockerfile b/authbridge/cmd/authbridge/Dockerfile.envoy similarity index 51% rename from authbridge/cmd/authbridge/Dockerfile rename to authbridge/cmd/authbridge/Dockerfile.envoy index a038f7d3c..8828e8022 100644 --- a/authbridge/cmd/authbridge/Dockerfile +++ b/authbridge/cmd/authbridge/Dockerfile.envoy @@ -1,11 +1,13 @@ -# AuthBridge unified image — Envoy + authbridge binary in one container. -# Drop-in replacement for envoy-with-processor (Dockerfile.envoy). +# AuthBridge envoy-sidecar combined image — Envoy + authbridge (ext_proc) +# + spiffe-helper in a single container. # -# Uses UBI9-micro base (~24 MB) instead of UBI9-minimal (~134 MB) -# for a ~42% smaller image while remaining glibc-compatible for Envoy. -# Total image size: ~140 MB (24 MB base + 87 MB Envoy + 26 MB authbridge). +# Spiffe-helper starts conditionally based on the SPIRE_ENABLED env var +# (set by the operator when the workload opts into SPIRE identity). # -# Build context: ./authbridge (needs access to both authlib/ and cmd/authbridge/) +# Image lineage: replaces the older `authbridge` (envoy + everything, +# UBI9-micro base ~140 MB combined) and `authbridge-unified` images. +# +# Build context: ./authbridge (needs access to authlib/ and cmd/authbridge/) # Stage 1: Build authbridge binary FROM golang:1.24-alpine@sha256:8bee1901f1e530bfb4a7850aa7a479d17ae3a18beb6e09064ed54cfd245b7191 AS builder @@ -14,28 +16,26 @@ RUN apk add --no-cache git WORKDIR /app -# Copy both modules with Docker-compatible layout COPY authlib/ authlib/ COPY cmd/authbridge/ cmd/authbridge/ -# The go.mod has: replace authlib => ../../authlib -# In Docker the layout is /app/authlib and /app/cmd/authbridge, so the -# relative path ../../authlib resolves to /app/authlib. Correct. ENV GOWORK=off RUN cd cmd/authbridge && CGO_ENABLED=0 GOOS=linux go build -o /authbridge . # Stage 2: Get Envoy binary FROM docker.io/envoyproxy/envoy:v1.37.1 AS envoy-source -# Stage 3: Runtime — UBI9-micro (glibc-compatible, ~24 MB) -# UBI9-micro has no package manager and no CA certificates. -# CA certs are copied from the Alpine builder for HTTPS (JWKS, Keycloak). +# Stage 3: Get spiffe-helper binary +FROM ghcr.io/spiffe/spiffe-helper:0.11.0 AS spiffe-source + +# Stage 4: Runtime — UBI9-micro (glibc-compatible, ~24 MB, has bash) FROM registry.access.redhat.com/ubi9/ubi-micro@sha256:2173487b3b72b1a7b11edc908e9bbf1726f9df46a4f78fd6d19a2bab0a701f38 COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt COPY --from=envoy-source --chmod=755 /usr/local/bin/envoy /usr/local/bin/envoy +COPY --from=spiffe-source --chmod=755 /spiffe-helper /usr/local/bin/spiffe-helper COPY --from=builder --chmod=755 /authbridge /usr/local/bin/authbridge -COPY --chmod=755 cmd/authbridge/entrypoint.sh /usr/local/bin/entrypoint.sh +COPY --chmod=755 cmd/authbridge/entrypoint-envoy.sh /usr/local/bin/entrypoint.sh # Envoy writes hot-restart lock files and admin socket here RUN mkdir -p /tmp/envoy && chmod 775 /tmp/envoy diff --git a/authbridge/cmd/authbridge/Dockerfile.light b/authbridge/cmd/authbridge/Dockerfile.light deleted file mode 100644 index 731b4afac..000000000 --- a/authbridge/cmd/authbridge/Dockerfile.light +++ /dev/null @@ -1,33 +0,0 @@ -# AuthBridge lightweight image — Go binary only, no Envoy. -# Used for waypoint and proxy-sidecar modes where Envoy is not needed. -# For envoy-sidecar mode, use the main Dockerfile which includes Envoy. -# -# Uses distroless base (~2 MB) for minimal attack surface. -# The binary is statically linked (CGO_ENABLED=0), so no glibc needed. -# No shell — debug via kubectl logs, not kubectl exec. -# -# Build context: ./authbridge (needs access to both authlib/ and cmd/authbridge/) - -# Stage 1: Build authbridge binary -FROM golang:1.24-alpine@sha256:8bee1901f1e530bfb4a7850aa7a479d17ae3a18beb6e09064ed54cfd245b7191 AS builder - -RUN apk add --no-cache git - -WORKDIR /app - -# Copy both modules with Docker-compatible layout -COPY authlib/ authlib/ -COPY cmd/authbridge/ cmd/authbridge/ - -ENV GOWORK=off -RUN cd cmd/authbridge && CGO_ENABLED=0 GOOS=linux go build -o /authbridge . - -# Stage 2: Runtime — distroless static (~2 MB, includes CA certs) -FROM gcr.io/distroless/static-debian12:nonroot@sha256:a9329520abc449e3b14d5bc3a6ffae065bdde0f02667fa10880c49b35c109fd1 - -COPY --from=builder --chmod=755 /authbridge /usr/local/bin/authbridge - -# ext_authz/ext_proc (9090), forward proxy (8080), reverse proxy (8081) -EXPOSE 9090 8080 8081 - -ENTRYPOINT ["/usr/local/bin/authbridge"] diff --git a/authbridge/cmd/authbridge/Dockerfile.proxy b/authbridge/cmd/authbridge/Dockerfile.proxy new file mode 100644 index 000000000..f171d6561 --- /dev/null +++ b/authbridge/cmd/authbridge/Dockerfile.proxy @@ -0,0 +1,49 @@ +# AuthBridge proxy-sidecar combined image — authbridge-proxy (no Envoy, +# no gRPC) + spiffe-helper in a single container. +# +# The agent uses HTTP_PROXY env var to route outbound traffic through +# the forward proxy; inbound traffic flows through the reverse proxy +# that takes over the agent's original port. Spiffe-helper starts +# conditionally based on the SPIRE_ENABLED env var. +# +# Image lineage: replaces the older `authbridge-light` image and is +# the new home for the default proxy-sidecar deployment shape. +# +# Uses alpine base (~5 MB) — needs bash for the supervisor entrypoint +# and glibc-compatible libc (musl is fine for both authbridge-proxy +# (CGO_ENABLED=0 static) and spiffe-helper (statically linked Go)). +# No shell would also work via a Go supervisor, but bash matches the +# Dockerfile.envoy pattern and keeps both images consistent. +# +# Build context: ./authbridge (needs access to authlib/ and cmd/authbridge-proxy/) + +# Stage 1: Build authbridge-proxy binary +FROM golang:1.24-alpine@sha256:8bee1901f1e530bfb4a7850aa7a479d17ae3a18beb6e09064ed54cfd245b7191 AS builder + +RUN apk add --no-cache git + +WORKDIR /app + +COPY authlib/ authlib/ +COPY cmd/authbridge-proxy/ cmd/authbridge-proxy/ + +ENV GOWORK=off +RUN cd cmd/authbridge-proxy && CGO_ENABLED=0 GOOS=linux go build -o /authbridge-proxy . + +# Stage 2: Get spiffe-helper binary +FROM ghcr.io/spiffe/spiffe-helper:0.11.0 AS spiffe-source + +# Stage 3: Runtime — alpine (has bash, ~5 MB base) +FROM alpine:3.20@sha256:beefdbd8a1da6d2915566fde36db9db0b524eb737fc57cd1367effd16dc0d06d + +RUN apk add --no-cache bash ca-certificates + +COPY --from=spiffe-source --chmod=755 /spiffe-helper /usr/local/bin/spiffe-helper +COPY --from=builder --chmod=755 /authbridge-proxy /usr/local/bin/authbridge-proxy +COPY --chmod=755 cmd/authbridge/entrypoint-proxy.sh /usr/local/bin/entrypoint.sh + +USER 1001 + +EXPOSE 8080 8081 9091 9093 9094 + +ENTRYPOINT ["/usr/local/bin/entrypoint.sh"] diff --git a/authbridge/cmd/authbridge/entrypoint-envoy.sh b/authbridge/cmd/authbridge/entrypoint-envoy.sh new file mode 100644 index 000000000..f86b44b34 --- /dev/null +++ b/authbridge/cmd/authbridge/entrypoint-envoy.sh @@ -0,0 +1,58 @@ +#!/bin/bash +set -eu + +# AuthBridge envoy-sidecar combined entrypoint with process supervision. +# Manages: spiffe-helper (optional), authbridge (ext_proc), envoy. +# +# Startup order: +# 1. spiffe-helper (background, only when SPIRE_ENABLED=true) +# 2. authbridge (background) — gRPC ext_proc listener +# 3. envoy (background) — calls authbridge over ext_proc +# +# Process management: PID 1 (this shell) supervises every long-running +# critical process. If any critical process exits, the others are killed +# and the container exits non-zero so Kubernetes restarts it. SIGTERM / +# SIGINT are forwarded for graceful shutdown. + +CRITICAL_PIDS="" + +cleanup() { + echo "[entrypoint] Received signal, shutting down..." + # shellcheck disable=SC2086 + kill $CRITICAL_PIDS 2>/dev/null || true + wait + exit 0 +} +trap cleanup TERM INT + +# --- Phase 1: spiffe-helper (conditional) --- +if [ "${SPIRE_ENABLED:-}" = "true" ]; then + echo "[entrypoint] Starting spiffe-helper..." + /usr/local/bin/spiffe-helper -config=/etc/spiffe-helper/helper.conf run & + CRITICAL_PIDS="$CRITICAL_PIDS $!" +fi + +# --- Phase 2: authbridge (ext_proc gRPC server) --- +echo "[entrypoint] Starting authbridge..." +/usr/local/bin/authbridge "$@" & +CRITICAL_PIDS="$CRITICAL_PIDS $!" + +# Give authbridge a moment to bind the gRPC listener before Envoy connects +sleep 2 + +# --- Phase 3: Envoy --- +echo "[entrypoint] Starting Envoy..." +/usr/local/bin/envoy -c /etc/envoy/envoy.yaml \ + --service-cluster auth-proxy --service-node auth-proxy & +CRITICAL_PIDS="$CRITICAL_PIDS $!" + +# Block until any critical process exits, then terminate the container +# so Kubernetes restarts the pod. +# shellcheck disable=SC2086 +wait -n $CRITICAL_PIDS +EXIT_CODE=$? +echo "[entrypoint] A critical process exited unexpectedly (exit code $EXIT_CODE), terminating container" +# shellcheck disable=SC2086 +kill $CRITICAL_PIDS 2>/dev/null || true +wait +exit 1 diff --git a/authbridge/cmd/authbridge/entrypoint-proxy.sh b/authbridge/cmd/authbridge/entrypoint-proxy.sh new file mode 100644 index 000000000..8f3ea8ef6 --- /dev/null +++ b/authbridge/cmd/authbridge/entrypoint-proxy.sh @@ -0,0 +1,48 @@ +#!/bin/bash +set -eu + +# AuthBridge proxy-sidecar combined entrypoint with process supervision. +# Manages: spiffe-helper (optional), authbridge-proxy. +# +# Startup order: +# 1. spiffe-helper (background, only when SPIRE_ENABLED=true) +# 2. authbridge-proxy (background) — HTTP forward + reverse proxies +# +# Process management: PID 1 (this shell) supervises every long-running +# critical process. If any critical process exits, the others are killed +# and the container exits non-zero so Kubernetes restarts it. SIGTERM / +# SIGINT are forwarded for graceful shutdown. + +CRITICAL_PIDS="" + +cleanup() { + echo "[entrypoint] Received signal, shutting down..." + # shellcheck disable=SC2086 + kill $CRITICAL_PIDS 2>/dev/null || true + wait + exit 0 +} +trap cleanup TERM INT + +# --- Phase 1: spiffe-helper (conditional) --- +if [ "${SPIRE_ENABLED:-}" = "true" ]; then + echo "[entrypoint] Starting spiffe-helper..." + /usr/local/bin/spiffe-helper -config=/etc/spiffe-helper/helper.conf run & + CRITICAL_PIDS="$CRITICAL_PIDS $!" +fi + +# --- Phase 2: authbridge-proxy (HTTP forward + reverse proxies) --- +echo "[entrypoint] Starting authbridge-proxy..." +/usr/local/bin/authbridge-proxy "$@" & +CRITICAL_PIDS="$CRITICAL_PIDS $!" + +# Block until any critical process exits, then terminate the container +# so Kubernetes restarts the pod. +# shellcheck disable=SC2086 +wait -n $CRITICAL_PIDS +EXIT_CODE=$? +echo "[entrypoint] A critical process exited unexpectedly (exit code $EXIT_CODE), terminating container" +# shellcheck disable=SC2086 +kill $CRITICAL_PIDS 2>/dev/null || true +wait +exit 1 diff --git a/authbridge/cmd/authbridge/entrypoint.sh b/authbridge/cmd/authbridge/entrypoint.sh deleted file mode 100644 index e74fb6401..000000000 --- a/authbridge/cmd/authbridge/entrypoint.sh +++ /dev/null @@ -1,40 +0,0 @@ -#!/bin/bash -set -euo pipefail - -# Envoy + authbridge entrypoint with process supervision. -# Both processes run in the background; the shell stays as PID 1. -# If either process exits, the other is killed and the container exits -# non-zero so Kubernetes restarts it. -# -# authbridge args are passed through from the container command/args. -# Envoy config is expected at /etc/envoy/envoy.yaml. - -cleanup() { - echo "[entrypoint] Received signal, shutting down..." - kill "$AUTHBRIDGE_PID" "$ENVOY_PID" 2>/dev/null || true - wait - exit 0 -} -trap cleanup TERM INT - -# Start authbridge (ext_proc gRPC server) in the background -echo "[entrypoint] Starting authbridge..." -/usr/local/bin/authbridge "$@" & -AUTHBRIDGE_PID=$! - -# Give authbridge a moment to start the gRPC listener -sleep 2 - -# Start Envoy in the background -echo "[entrypoint] Starting Envoy..." -/usr/local/bin/envoy -c /etc/envoy/envoy.yaml \ - --service-cluster auth-proxy --service-node auth-proxy & -ENVOY_PID=$! - -# Wait for the first child to exit. If either dies, restart the container. -wait -n "$AUTHBRIDGE_PID" "$ENVOY_PID" -EXIT_CODE=$? -echo "[entrypoint] A process exited unexpectedly (exit code $EXIT_CODE), terminating container" -kill "$AUTHBRIDGE_PID" "$ENVOY_PID" 2>/dev/null || true -wait -exit 1 diff --git a/authbridge/spiffe-helper/Dockerfile b/authbridge/spiffe-helper/Dockerfile deleted file mode 100644 index ac2d72aa0..000000000 --- a/authbridge/spiffe-helper/Dockerfile +++ /dev/null @@ -1,27 +0,0 @@ -ARG SPIFFE_HELPER_VERSION=v0.11.0 - -FROM --platform=$BUILDPLATFORM golang:1.25-alpine AS source -ARG SPIFFE_HELPER_VERSION -WORKDIR /workspace -ARG SPIFFE_HELPER_SHA=1d0551d63787b528926b3e17fac949a376040bec -RUN apk add --no-cache git && \ - git clone --depth 1 --branch ${SPIFFE_HELPER_VERSION} \ - https://github.com/spiffe/spiffe-helper.git . && \ - test "$(git rev-parse HEAD)" = "${SPIFFE_HELPER_SHA}" - -FROM --platform=$BUILDPLATFORM source AS builder -ARG TARGETARCH -ENV CGO_ENABLED=0 GOARCH=${TARGETARCH} -RUN --mount=type=cache,target=/go/pkg/mod \ - --mount=type=cache,target=/root/.cache/go-build \ - go build -o bin/spiffe-helper cmd/spiffe-helper/main.go && \ - chmod 755 bin/spiffe-helper - -FROM registry.access.redhat.com/ubi9/ubi-micro@sha256:2173487b3b72b1a7b11edc908e9bbf1726f9df46a4f78fd6d19a2bab0a701f38 - -COPY --from=builder /workspace/bin/spiffe-helper /spiffe-helper - -USER 1001 - -ENTRYPOINT ["/spiffe-helper"] -CMD [] diff --git a/local-build-and-test.sh b/local-build-and-test.sh index ae4c395b3..f5d147c7a 100755 --- a/local-build-and-test.sh +++ b/local-build-and-test.sh @@ -66,27 +66,28 @@ load_image_to_kind ghcr.io/kagenti/kagenti/spiffe-idp-setup:local echo "✅ Built and loaded: spiffe-idp-setup:local" echo "" -# Build client-registration (CHANGED - UID 1000) +# Build authbridge (proxy-sidecar combined: authbridge-proxy + spiffe-helper) +# Default deployment shape — used when the workload's mode is proxy-sidecar. echo "==========================================" -echo "Building client-registration" +echo "Building authbridge (proxy-sidecar combined)" echo "==========================================" -cd "${SCRIPT_DIR}/authbridge/client-registration" -${CONTAINER_RUNTIME} build -t ghcr.io/kagenti/kagenti-extensions/client-registration:local . -load_image_to_kind ghcr.io/kagenti/kagenti-extensions/client-registration:local -echo "✅ Built and loaded: client-registration:local" +cd "${SCRIPT_DIR}/authbridge" +${CONTAINER_RUNTIME} build -f cmd/authbridge/Dockerfile.proxy -t ghcr.io/kagenti/kagenti-extensions/authbridge:local . +load_image_to_kind ghcr.io/kagenti/kagenti-extensions/authbridge:local +echo "✅ Built and loaded: authbridge:local" echo "" -# Build envoy-with-processor (Envoy runs as UID 1337) +# Build authbridge-envoy (envoy-sidecar combined: Envoy + ext_proc + spiffe-helper) echo "==========================================" -echo "Building envoy-with-processor" +echo "Building authbridge-envoy (envoy-sidecar combined)" echo "==========================================" -cd "${SCRIPT_DIR}/authbridge/authproxy" -${CONTAINER_RUNTIME} build -f Dockerfile.envoy -t ghcr.io/kagenti/kagenti-extensions/envoy-with-processor:local . -load_image_to_kind ghcr.io/kagenti/kagenti-extensions/envoy-with-processor:local -echo "✅ Built and loaded: envoy-with-processor:local" +cd "${SCRIPT_DIR}/authbridge" +${CONTAINER_RUNTIME} build -f cmd/authbridge/Dockerfile.envoy -t ghcr.io/kagenti/kagenti-extensions/authbridge-envoy:local . +load_image_to_kind ghcr.io/kagenti/kagenti-extensions/authbridge-envoy:local +echo "✅ Built and loaded: authbridge-envoy:local" echo "" -# Build proxy-init +# Build proxy-init (iptables init container, used by envoy-sidecar mode only) echo "==========================================" echo "Building proxy-init" echo "==========================================" @@ -102,8 +103,8 @@ echo "==========================================" echo "" echo "Images loaded into cluster '${CLUSTER_NAME}':" echo " - ghcr.io/kagenti/kagenti/spiffe-idp-setup:local" -echo " - ghcr.io/kagenti/kagenti-extensions/client-registration:local" -echo " - ghcr.io/kagenti/kagenti-extensions/envoy-with-processor:local" +echo " - ghcr.io/kagenti/kagenti-extensions/authbridge:local" +echo " - ghcr.io/kagenti/kagenti-extensions/authbridge-envoy:local" echo " - ghcr.io/kagenti/kagenti-extensions/proxy-init:local" echo "" echo "Next steps:" diff --git a/tests/test_client_registration.py b/tests/test_client_registration.py deleted file mode 100644 index 25600872e..000000000 --- a/tests/test_client_registration.py +++ /dev/null @@ -1,243 +0,0 @@ -"""Tests for authbridge/client-registration/client_registration.py. - -The module has top-level executable code, so we import individual functions -by loading the module source without executing it as __main__. -""" - -import importlib -import os -import sys -import types -from pathlib import Path -from unittest.mock import MagicMock, patch - -import pytest - -# --------------------------------------------------------------------------- -# Helper: load the module's functions without running top-level code -# --------------------------------------------------------------------------- - -MODULE_PATH = ( - Path(__file__).resolve().parents[1] - / "authbridge" - / "client-registration" - / "client_registration.py" -) - - -def _load_functions(): - """Import individual functions from client_registration.py. - - We read the source, compile it, and exec only the function definitions - so that the module-level side effects (env var reads, Keycloak calls) - are skipped. - """ - source = MODULE_PATH.read_text() - - # Build a module object with the required globals - mod = types.ModuleType("client_registration") - mod.__file__ = str(MODULE_PATH) - - # Provide the imports the functions need - import jwt - from keycloak import KeycloakAdmin, KeycloakPostError - - mod.os = os - mod.jwt = jwt - mod.KeycloakAdmin = KeycloakAdmin - mod.KeycloakPostError = KeycloakPostError - - # Compile and exec only function/class defs + imports - code = compile(source, str(MODULE_PATH), "exec") - # We exec the full code but in a controlled namespace; the module-level - # statements will fail because env vars are missing. We catch that and - # still get the function definitions. - try: - exec(code, mod.__dict__) - except (ValueError, SystemExit): - pass # Top-level code fails on missing env vars / exit(1) - - return mod - - -_mod = _load_functions() -get_env_var = _mod.get_env_var -write_client_secret = _mod.write_client_secret -register_client = _mod.register_client -get_or_create_audience_scope = _mod.get_or_create_audience_scope -add_scope_to_platform_clients = _mod.add_scope_to_platform_clients - - -# --------------------------------------------------------------------------- -# Tests: get_env_var -# --------------------------------------------------------------------------- - - -class TestGetEnvVar: - def test_returns_value_when_set(self, monkeypatch): - monkeypatch.setenv("TEST_VAR_XYZ", "hello") - assert get_env_var("TEST_VAR_XYZ") == "hello" - - def test_returns_default_when_missing(self): - assert get_env_var("NONEXISTENT_VAR_12345", "fallback") == "fallback" - - def test_raises_when_missing_no_default(self): - with pytest.raises(ValueError, match="Missing required environment variable"): - get_env_var("NONEXISTENT_VAR_12345") - - def test_empty_string_uses_default(self, monkeypatch): - monkeypatch.setenv("TEST_EMPTY_VAR", "") - assert get_env_var("TEST_EMPTY_VAR", "default") == "default" - - def test_empty_string_raises_without_default(self, monkeypatch): - monkeypatch.setenv("TEST_EMPTY_VAR", "") - with pytest.raises(ValueError): - get_env_var("TEST_EMPTY_VAR") - - -# --------------------------------------------------------------------------- -# Tests: write_client_secret -# --------------------------------------------------------------------------- - - -class TestWriteClientSecret: - def test_writes_secret_to_file(self, mock_keycloak_admin, tmp_secret_file): - mock_keycloak_admin.get_client_secrets.return_value = {"value": "s3cret"} - - write_client_secret(mock_keycloak_admin, "internal-id", "my-client", tmp_secret_file) - - assert Path(tmp_secret_file).read_text() == "s3cret" - mock_keycloak_admin.get_client_secrets.assert_called_once_with("internal-id") - - def test_handles_keycloak_error(self, mock_keycloak_admin, tmp_secret_file): - from keycloak import KeycloakPostError - - mock_keycloak_admin.get_client_secrets.side_effect = KeycloakPostError( - error_message="not found", response_code=404 - ) - - # Should not raise, just print error - write_client_secret(mock_keycloak_admin, "internal-id", "my-client", tmp_secret_file) - - # File should not be created - assert not Path(tmp_secret_file).exists() - - def test_handles_file_write_error(self, mock_keycloak_admin): - mock_keycloak_admin.get_client_secrets.return_value = {"value": "s3cret"} - - # Writing to a non-existent directory should fail gracefully - write_client_secret( - mock_keycloak_admin, "internal-id", "my-client", "/nonexistent/dir/secret.txt" - ) - - -# --------------------------------------------------------------------------- -# Tests: register_client -# --------------------------------------------------------------------------- - - -class TestRegisterClient: - def test_returns_existing_client(self, mock_keycloak_admin): - mock_keycloak_admin.get_client_id.return_value = "existing-uuid" - - result = register_client(mock_keycloak_admin, "my-client", {"clientId": "my-client"}) - - assert result == "existing-uuid" - mock_keycloak_admin.create_client.assert_not_called() - - def test_creates_new_client(self, mock_keycloak_admin): - mock_keycloak_admin.get_client_id.return_value = None - mock_keycloak_admin.create_client.return_value = "new-uuid" - - result = register_client(mock_keycloak_admin, "my-client", {"clientId": "my-client"}) - - assert result == "new-uuid" - mock_keycloak_admin.create_client.assert_called_once() - - def test_raises_on_create_failure(self, mock_keycloak_admin): - from keycloak import KeycloakPostError - - mock_keycloak_admin.get_client_id.return_value = None - mock_keycloak_admin.create_client.side_effect = KeycloakPostError( - error_message="conflict", response_code=409 - ) - - with pytest.raises(KeycloakPostError): - register_client(mock_keycloak_admin, "my-client", {"clientId": "my-client"}) - - -# --------------------------------------------------------------------------- -# Tests: get_or_create_audience_scope -# --------------------------------------------------------------------------- - - -class TestGetOrCreateAudienceScope: - def test_returns_existing_scope(self, mock_keycloak_admin): - mock_keycloak_admin.get_client_scopes.return_value = [ - {"name": "agent-test-aud", "id": "scope-123"} - ] - - result = get_or_create_audience_scope(mock_keycloak_admin, "agent-test-aud", "my-audience") - - assert result == "scope-123" - mock_keycloak_admin.create_client_scope.assert_not_called() - - def test_creates_new_scope(self, mock_keycloak_admin): - mock_keycloak_admin.get_client_scopes.return_value = [] - mock_keycloak_admin.create_client_scope.return_value = "new-scope-id" - - result = get_or_create_audience_scope(mock_keycloak_admin, "agent-test-aud", "my-audience") - - assert result == "new-scope-id" - mock_keycloak_admin.create_client_scope.assert_called_once() - mock_keycloak_admin.add_mapper_to_client_scope.assert_called_once() - - def test_returns_none_on_create_failure(self, mock_keycloak_admin): - from keycloak import KeycloakPostError - - mock_keycloak_admin.get_client_scopes.return_value = [] - mock_keycloak_admin.create_client_scope.side_effect = KeycloakPostError( - error_message="error", response_code=500 - ) - - result = get_or_create_audience_scope(mock_keycloak_admin, "agent-test-aud", "my-audience") - - assert result is None - - -# --------------------------------------------------------------------------- -# Tests: add_scope_to_platform_clients -# --------------------------------------------------------------------------- - - -class TestAddScopeToPlatformClients: - def test_adds_scope_to_existing_client(self, mock_keycloak_admin): - mock_keycloak_admin.get_client_id.return_value = "platform-uuid" - - add_scope_to_platform_clients( - mock_keycloak_admin, "scope-123", "agent-test-aud", ["kagenti"] - ) - - mock_keycloak_admin.add_client_default_client_scope.assert_called_once_with( - "platform-uuid", "scope-123", {} - ) - - def test_skips_missing_platform_client(self, mock_keycloak_admin): - mock_keycloak_admin.get_client_id.return_value = None - - add_scope_to_platform_clients( - mock_keycloak_admin, "scope-123", "agent-test-aud", ["nonexistent"] - ) - - mock_keycloak_admin.add_client_default_client_scope.assert_not_called() - - def test_handles_409_conflict_gracefully(self, mock_keycloak_admin): - mock_keycloak_admin.get_client_id.return_value = "platform-uuid" - mock_keycloak_admin.add_client_default_client_scope.side_effect = Exception( - "409 Conflict" - ) - - # Should not raise - add_scope_to_platform_clients( - mock_keycloak_admin, "scope-123", "agent-test-aud", ["kagenti"] - )