diff --git a/charts/langsmith-auth-proxy/Chart.yaml b/charts/langsmith-auth-proxy/Chart.yaml new file mode 100644 index 00000000..2aad963c --- /dev/null +++ b/charts/langsmith-auth-proxy/Chart.yaml @@ -0,0 +1,9 @@ +apiVersion: v2 +name: langsmith-auth-proxy +maintainers: + - name: Brian + email: brian@langchain.dev +description: Helm chart to deploy the langsmith auth-proxy application. +type: application +version: 0.0.1 +appVersion: "1.37.0" diff --git a/charts/langsmith-auth-proxy/README.md b/charts/langsmith-auth-proxy/README.md new file mode 100644 index 00000000..cef6cae3 --- /dev/null +++ b/charts/langsmith-auth-proxy/README.md @@ -0,0 +1,151 @@ +# langsmith-auth-proxy + +![Version: 0.0.1](https://img.shields.io/badge/Version-0.0.1-informational?style=flat-square) ![Type: application](https://img.shields.io/badge/Type-application-informational?style=flat-square) ![AppVersion: 1.37.0](https://img.shields.io/badge/AppVersion-1.37.0-informational?style=flat-square) + +Helm chart to deploy the langsmith auth-proxy application. + +## Request flow + +``` +Client -> Envoy(:10000) + -> Health check filter (/healthz bypasses auth) + -> JWT validation (RS256, configurable issuer + audiences) + -> [optional] ext_authz HTTP filter (e.g. inject provider API key) + -> Upstream LLM provider or gateway +``` + +## ext_authz integration + +This integration uses Envoy's [HTTP ext_authz filter](https://www.envoyproxy.io/docs/envoy/latest/configuration/http/http_filters/ext_authz_filter) (not gRPC). + +When `extAuthz.enabled: true`, Envoy calls the configured service at `/check` before forwarding upstream. The ext_authz service receives the `x-langsmith-llm-auth` header (containing the validated JWT) and can inject/override headers like `Authorization` that get forwarded upstream. + +### Interface + +This chart uses the **HTTP** `ext_authz` mode — HTTP request in, HTTP response out. The gRPC proto messages (`CheckRequest`, `OkHttpResponse`, etc.) do not apply. + +**Request** — Envoy sends an HTTP request to `{serviceUrl}/check{original_path}` with: +- Same HTTP method as the original request +- Headers matching `allowed_headers` patterns (`x-langsmith-llm-auth`, `x-*`) +- Request body only if `sendBody: true` + +**Response** — The service returns a plain HTTP response: +- `2xx` → allow: headers matching `allowed_upstream_headers` (`authorization`, `x-langsmith-llm-auth`, `x-forwarded-*`) are forwarded upstream +- Non-`2xx` → deny: status code + headers matching `allowed_client_headers` (`www-authenticate`, `x-*`) are sent back to the client + +## Values + +| Key | Type | Default | Description | +|-----|------|---------|-------------| +| authProxy.autoscaling.hpa.additionalMetrics | list | `[]` | | +| authProxy.autoscaling.hpa.enabled | bool | `false` | | +| authProxy.autoscaling.hpa.maxReplicas | int | `5` | | +| authProxy.autoscaling.hpa.minReplicas | int | `1` | | +| authProxy.autoscaling.hpa.targetCPUUtilizationPercentage | int | `50` | | +| authProxy.autoscaling.hpa.targetMemoryUtilizationPercentage | int | `80` | | +| authProxy.containerPort | int | `10000` | | +| authProxy.deployment.affinity | object | `{}` | | +| authProxy.deployment.annotations | object | `{}` | | +| authProxy.deployment.command[0] | string | `"envoy"` | | +| authProxy.deployment.command[1] | string | `"-c"` | | +| authProxy.deployment.command[2] | string | `"/etc/envoy/envoy.yaml"` | | +| authProxy.deployment.extraContainerConfig | object | `{}` | | +| authProxy.deployment.extraEnv | list | `[]` | | +| authProxy.deployment.initContainers | list | `[]` | | +| authProxy.deployment.labels | object | `{}` | | +| authProxy.deployment.livenessProbe.failureThreshold | int | `6` | | +| authProxy.deployment.livenessProbe.httpGet.path | string | `"/healthz"` | | +| authProxy.deployment.livenessProbe.httpGet.port | int | `10000` | | +| authProxy.deployment.livenessProbe.periodSeconds | int | `10` | | +| authProxy.deployment.livenessProbe.timeoutSeconds | int | `1` | | +| authProxy.deployment.nodeSelector | object | `{}` | | +| authProxy.deployment.podSecurityContext | object | `{}` | | +| authProxy.deployment.readinessProbe.failureThreshold | int | `6` | | +| authProxy.deployment.readinessProbe.httpGet.path | string | `"/healthz"` | | +| authProxy.deployment.readinessProbe.httpGet.port | int | `10000` | | +| authProxy.deployment.readinessProbe.periodSeconds | int | `10` | | +| authProxy.deployment.readinessProbe.timeoutSeconds | int | `1` | | +| authProxy.deployment.replicas | int | `1` | | +| authProxy.deployment.resources.limits.cpu | string | `"500m"` | | +| authProxy.deployment.resources.limits.memory | string | `"256Mi"` | | +| authProxy.deployment.resources.requests.cpu | string | `"100m"` | | +| authProxy.deployment.resources.requests.memory | string | `"128Mi"` | | +| authProxy.deployment.securityContext | object | `{}` | | +| authProxy.deployment.sidecars | list | `[]` | | +| authProxy.deployment.startupProbe.failureThreshold | int | `6` | | +| authProxy.deployment.startupProbe.httpGet.path | string | `"/healthz"` | | +| authProxy.deployment.startupProbe.httpGet.port | int | `10000` | | +| authProxy.deployment.startupProbe.periodSeconds | int | `10` | | +| authProxy.deployment.startupProbe.timeoutSeconds | int | `1` | | +| authProxy.deployment.terminationGracePeriodSeconds | int | `30` | | +| authProxy.deployment.tolerations | list | `[]` | | +| authProxy.deployment.topologySpreadConstraints | list | `[]` | | +| authProxy.deployment.volumeMounts | list | `[]` | | +| authProxy.deployment.volumes | list | `[]` | | +| authProxy.enabled | bool | `true` | | +| authProxy.extAuthz.allowedHeadersRegex | string | `".*"` | Regex controlling which client request headers are forwarded to the ext_authz service. Defaults to all headers. Maps to http_service.allowed_headers. Uses Google RE2 syntax: https://github.com/google/re2/wiki/Syntax. | +| authProxy.extAuthz.allowedUpstreamHeaders | list | `[{exact: "authorization"}, {prefix: "x-"}]` | Patterns controlling which ext_authz response headers are forwarded upstream (authorization_response.allowed_upstream_headers). Each entry is an object with one of these keys: `exact`, `prefix`, or `safe_regex`. | +| authProxy.extAuthz.disallowedHeadersRegex | string | `""` | Regex controlling which client request headers are NOT forwarded to the ext_authz service (higher precedence than allowedHeadersRegex). Maps to http_service.disallowed_headers. Uses Google RE2 syntax: https://github.com/google/re2/wiki/Syntax. | +| authProxy.extAuthz.enabled | bool | `false` | | +| authProxy.extAuthz.headersToAdd | list | `[]` | Static headers to add to every ext_authz check request (authorization_request.headers_to_add). Example: [{key: "x-auth-context", value: "langsmith"}] | +| authProxy.extAuthz.maxRequestBytes | int | `8192` | Maximum request body bytes to buffer for ext_authz | +| authProxy.extAuthz.sendBody | bool | `false` | Whether to send the request body to ext_authz | +| authProxy.extAuthz.serviceUrl | string | `""` | HTTP service URL for ext_authz (e.g. http://my-auth-service:8080) | +| authProxy.extAuthz.timeout | string | `"10s"` | Timeout for ext_authz requests | +| authProxy.jwksJson | string | `""` | JWKS JSON string containing the public keys for JWT validation. Generate with the LangSmith JWKS tooling and paste the full JSON here. | +| authProxy.jwtAudiences | list | `[]` | JWT audience claims to validate. Must match audiences in the signed JWT. | +| authProxy.jwtIssuer | string | `"langsmith"` | JWT issuer claim to validate | +| authProxy.name | string | `"auth-proxy"` | | +| authProxy.pdb.annotations | object | `{}` | | +| authProxy.pdb.enabled | bool | `false` | | +| authProxy.pdb.labels | object | `{}` | | +| authProxy.pdb.minAvailable | int | `1` | | +| authProxy.rollout | object | `{"enabled":false,"strategy":{"canary":{"steps":[{"setWeight":100}]}}}` | ArgoCD Rollouts configuration. If enabled, will create a Rollout resource instead of a Deployment. See https://argo-rollouts.readthedocs.io/ | +| authProxy.rollout.strategy | object | `{"canary":{"steps":[{"setWeight":100}]}}` | Rollout strategy configuration. See https://argo-rollouts.readthedocs.io/en/stable/features/specification/ | +| authProxy.service.annotations | object | `{}` | | +| authProxy.service.labels | object | `{}` | | +| authProxy.service.loadBalancerIP | string | `""` | | +| authProxy.service.loadBalancerSourceRanges | list | `[]` | | +| authProxy.service.port | int | `10000` | | +| authProxy.service.type | string | `"ClusterIP"` | | +| authProxy.serviceAccount.annotations | object | `{}` | | +| authProxy.serviceAccount.automountServiceAccountToken | bool | `true` | | +| authProxy.serviceAccount.create | bool | `true` | | +| authProxy.serviceAccount.labels | object | `{}` | | +| authProxy.serviceAccount.name | string | `""` | | +| authProxy.streamIdleTimeout | string | `"300s"` | Idle timeout for streaming responses (e.g. SSE from LLM providers) | +| authProxy.upstream | string | `""` | Upstream LLM provider URL (e.g. https://api.openai.com) | +| commonAnnotations | object | `{}` | Annotations that will be applied to all resources created by the chart | +| commonLabels | object | `{}` | Labels that will be applied to all resources created by the chart | +| commonPodAnnotations | object | `{}` | Annotations that will be applied to all pods created by the chart | +| commonPodSecurityContext | object | `{}` | Common pod security context applied to all pods. Component-specific podSecurityContext values will be merged on top of this (component values take precedence). | +| fullnameOverride | string | `""` | String to fully override `"langsmith.fullname"` | +| gateway | object | `{"annotations":{},"enabled":false,"hostnames":[],"labels":{},"name":"","namespace":"","sectionName":""}` | Gateway API HTTPRoute configuration | +| gateway.hostnames | list | `[]` | Hostnames to match on | +| gateway.name | string | `""` | Name of the Gateway resource to attach to | +| gateway.namespace | string | `""` | Namespace of the Gateway resource (if different from chart namespace) | +| gateway.sectionName | string | `""` | SectionName of the Gateway listener to attach to | +| images.authProxyImage.pullPolicy | string | `"IfNotPresent"` | | +| images.authProxyImage.repository | string | `"docker.io/envoyproxy/envoy"` | | +| images.authProxyImage.tag | string | `"v1.37-latest"` | | +| images.imagePullSecrets | list | `[]` | | +| images.registry | string | `""` | If supplied, all children .repository values will be prepended with this registry name + `/` | +| ingress | object | `{"annotations":{},"enabled":false,"hosts":[],"ingressClassName":"","labels":{},"tls":[]}` | Ingress configuration | +| ingress.annotations | object | `{}` | Annotations for streaming support. Defaults shown are for nginx ingress controller. | +| nameOverride | string | `""` | Provide a name in place of `langsmith-auth-proxy` | +| namespace | string | `""` | Namespace to install the chart into. If not set, will use the namespace of the current context. | + +## E2E tests + +See [e2e/README.md](e2e/README.md) for local end-to-end testing with kind. + +## Maintainers + +| Name | Email | Url | +| ---- | ------ | --- | +| Brian | | | + +---------------------------------------------- +Autogenerated from chart metadata using [helm-docs v1.14.2](https://github.com/norwoodj/helm-docs/releases/v1.14.2) +## Docs Generated by [helm-docs](https://github.com/norwoodj/helm-docs) +`helm-docs -t ./charts/langsmith-auth-proxy/README.md.gotmpl` diff --git a/charts/langsmith-auth-proxy/README.md.gotmpl b/charts/langsmith-auth-proxy/README.md.gotmpl new file mode 100644 index 00000000..d7fd3aa7 --- /dev/null +++ b/charts/langsmith-auth-proxy/README.md.gotmpl @@ -0,0 +1,52 @@ +{{ template "chart.header" . }} + +{{ template "chart.versionBadge" . }}{{ template "chart.typeBadge" . }}{{ template "chart.appVersionBadge" . }} + +{{ template "chart.description" . }} + +## Request flow + +``` +Client -> Envoy(:10000) + -> Health check filter (/healthz bypasses auth) + -> JWT validation (RS256, configurable issuer + audiences) + -> [optional] ext_authz HTTP filter (e.g. inject provider API key) + -> Upstream LLM provider or gateway +``` + +## ext_authz integration + +This integration uses Envoy's [HTTP ext_authz filter](https://www.envoyproxy.io/docs/envoy/latest/configuration/http/http_filters/ext_authz_filter) (not gRPC). + +When `extAuthz.enabled: true`, Envoy calls the configured service at `/check` before forwarding upstream. The ext_authz service receives the `x-langsmith-llm-auth` header (containing the validated JWT) and can inject/override headers like `Authorization` that get forwarded upstream. + +### Interface + +This chart uses the **HTTP** `ext_authz` mode — HTTP request in, HTTP response out. The gRPC proto messages (`CheckRequest`, `OkHttpResponse`, etc.) do not apply. + +**Request** — Envoy sends an HTTP request to `{serviceUrl}/check{original_path}` with: +- Same HTTP method as the original request +- Headers matching `allowed_headers` patterns (`x-langsmith-llm-auth`, `x-*`) +- Request body only if `sendBody: true` + +**Response** — The service returns a plain HTTP response: +- `2xx` → allow: headers matching `allowed_upstream_headers` (`authorization`, `x-langsmith-llm-auth`, `x-forwarded-*`) are forwarded upstream +- Non-`2xx` → deny: status code + headers matching `allowed_client_headers` (`www-authenticate`, `x-*`) are sent back to the client + +## Values + +| Key | Type | Default | Description | +|-----|------|---------|-------------| +{{- range .Values }} +| {{ .Key }} | {{ .Type }} | {{ if .Default }}{{ .Default }}{{ else }}{{ .AutoDefault }}{{ end }} | {{ if .Description }}{{ .Description }}{{ else }}{{ .AutoDescription }}{{ end }} | +{{- end }} + +## E2E tests + +See [e2e/README.md](e2e/README.md) for local end-to-end testing with kind. + +{{ template "chart.maintainersSection" . }} + +{{ template "helm-docs.versionFooter" . }} +## Docs Generated by [helm-docs](https://github.com/norwoodj/helm-docs) +`helm-docs -t ./charts/langsmith-auth-proxy/README.md.gotmpl` diff --git a/charts/langsmith-auth-proxy/ci/auth-proxy-values.yaml b/charts/langsmith-auth-proxy/ci/auth-proxy-values.yaml new file mode 100644 index 00000000..0b35ba97 --- /dev/null +++ b/charts/langsmith-auth-proxy/ci/auth-proxy-values.yaml @@ -0,0 +1,17 @@ +# CI values for testing auth proxy chart. + +authProxy: + enabled: true + upstream: "https://api.openai.com" + jwtIssuer: "langsmith" + jwtAudiences: + - "test-audience" + jwksJson: '{"keys": [{"kty": "RSA","use": "sig","alg": "RS256","kid": "f98e5ea5-2ee6-4141-b1fd-9a3ecb6648fd","d": "Ntcd3fjgYh1ytShRgfgEScbc1t_9H6mNZ5nkyjUJ9WpMUmBk9MltimV0qMDRWs85695c30YD-Uf5VMvgYszSQZZo3iNWX8bfKEffqYboN2zNyhvomB1dboyUXz4I3B4-7Zrxgdamd1adOPg7Rxedck8a3oJwE9FzpypCg67-mQjTnZ8RTTtu5ekvoXYsrR30qI_lWUGiA9aL6pCbTEQOjBombLNkOlwl2Hh7FORSvM3ViEMop7rMDvMAWRPcBcpJgwHhQTBhBx1QMi01DmdX7kXnnsTgrU4bxX9zgIXtBV7Fhlk1bvIVqOTT7M3JMbQG_MXLXjRbvj7bAHta1FRE9Q","n": "uqHU2bRgvKIBe88_ikr3MLdTa4W55gv3DjVFuB6hZxaJIbOzGXE3-FRf7cfqg0Exysow5uuXUUTtq_zaE3AZLvEOt3CmQ3su_OxHPsytTHwLcc74NCL7hozv1uAQTMWAof4_KvyYIYOX5_wRgwoahQJPDSvbQpZvjdxUR7muVps65idF6lZrvoRYQiyuyMzozyFYAqiOI9VIud3Z9S2gSsHRhExPf8UD-HKiTKPUOlWLCwiU9FWWRgYse0jPwzU6j2lXu38aJjJd43ROH7OrcWp4fdLY-pjLQb6rz-RshTgXPkvZxTmfLVSqUkHr0xkM-Rb4T1CmvV4DuXNDBkfkgw","e": "AQAB"}]}' + streamIdleTimeout: "300s" + extAuthz: + enabled: true + serviceUrl: "http://auth-service:8080" + timeout: "10s" + +ingress: + enabled: false diff --git a/charts/langsmith-auth-proxy/e2e/README.md b/charts/langsmith-auth-proxy/e2e/README.md new file mode 100644 index 00000000..a06c5a31 --- /dev/null +++ b/charts/langsmith-auth-proxy/e2e/README.md @@ -0,0 +1,48 @@ +# E2E Tests + +Self-contained end-to-end tests for the `langsmith-auth-proxy` chart. Spins up a kind cluster, generates fresh RSA keys + JWT, deploys a fake gateway (echo server) as upstream, runs a Python ext_authz sidecar, and validates the full request flow through Envoy. + +## Prerequisites + +`kind`, `helm`, `kubectl`, `step`, `curl`, `jq` + +## Usage + +```bash +# Run with default fake claims +./test.sh + +# Run with custom JWT claims +./test.sh path/to/claims.json +``` + +## What it tests + +1. `GET /healthz` returns 200 (health check bypasses auth) +2. Request without JWT returns 401 +3. Request with valid JWT returns 200, ext_authz injects `Authorization: Bearer fake-upstream-key` +4. Request with garbage JWT returns 401 + +## Request flow + +``` +curl -H "X-LangSmith-LLM-Auth: " -> Envoy(:10000) + -> JWT filter (validate sig, iss, aud) + -> ext_authz filter -> localhost:10002 (sidecar) + <- 200 + Authorization: Bearer fake-upstream-key + -> fake-gateway:10001 + <- 200 + JSON with all received headers +``` + +## Files + +| File | Purpose | +|------|---------| +| `test.sh` | Orchestration script | +| `e2e-values.yaml` | Helm values override | +| `fake-gateway.yaml` | Echo server Deployment+Service (upstream) | +| `ext-authz-mock.py` | Python ext_authz sidecar mock | + +## Cleanup + +The script deletes the kind cluster on exit via `trap`. To keep it for debugging, comment out the `trap cleanup EXIT` line. diff --git a/charts/langsmith-auth-proxy/e2e/e2e-values.yaml b/charts/langsmith-auth-proxy/e2e/e2e-values.yaml new file mode 100644 index 00000000..5aaa08a8 --- /dev/null +++ b/charts/langsmith-auth-proxy/e2e/e2e-values.yaml @@ -0,0 +1,32 @@ +authProxy: + enabled: true + upstream: "http://fake-gateway:10001" + jwtIssuer: "langsmith" + jwtAudiences: + - "test-audience" + # jwksJson injected via --set at install time + streamIdleTimeout: "300s" + extAuthz: + enabled: true + serviceUrl: "http://localhost:10002" + timeout: "10s" + sendBody: false + deployment: + replicas: 1 + sidecars: + - name: ext-authz-mock + image: python:3.12-slim + command: ["python", "/scripts/ext-authz-mock.py"] + ports: + - containerPort: 10002 + volumeMounts: + - name: ext-authz-script + mountPath: /scripts + readOnly: true + volumes: + - name: ext-authz-script + configMap: + name: ext-authz-script + +ingress: + enabled: false diff --git a/charts/langsmith-auth-proxy/e2e/ext-authz-mock.py b/charts/langsmith-auth-proxy/e2e/ext-authz-mock.py new file mode 100644 index 00000000..1ec79a03 --- /dev/null +++ b/charts/langsmith-auth-proxy/e2e/ext-authz-mock.py @@ -0,0 +1,32 @@ +"""Minimal ext_authz HTTP mock for e2e testing. + +Listens on :10002, logs received headers, and returns 200 with: +- Authorization header injected (forwarded upstream) +- X-Custom-Added header injected (forwarded upstream) +- x-envoy-auth-headers-to-remove to strip X-Remove-Me from the request +""" + +from http.server import HTTPServer, BaseHTTPRequestHandler +import sys + + +class Handler(BaseHTTPRequestHandler): + def do_any(self): + print(f"ext_authz check: {self.command} {self.path}", flush=True) + print(f" body: {self.rfile.read(int(self.headers['content-length']))}", flush=True) + for k, v in self.headers.items(): + print(f" {k}: {v}", flush=True) + self.send_response(200) + self.send_header("Authorization", "Bearer fake-upstream-key") + self.send_header("X-Custom-Added", "from-ext-authz") + self.send_header("x-envoy-auth-headers-to-remove", "x-remove-me") + self.end_headers() + + # Handle every HTTP method the same way + do_GET = do_POST = do_PUT = do_DELETE = do_PATCH = do_HEAD = do_OPTIONS = do_any + + +if __name__ == "__main__": + server = HTTPServer(("0.0.0.0", 10002), Handler) + print("ext_authz mock listening on :10002", flush=True) + server.serve_forever() diff --git a/charts/langsmith-auth-proxy/e2e/fake-gateway.yaml b/charts/langsmith-auth-proxy/e2e/fake-gateway.yaml new file mode 100644 index 00000000..7fdf39f1 --- /dev/null +++ b/charts/langsmith-auth-proxy/e2e/fake-gateway.yaml @@ -0,0 +1,38 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: fake-gateway + labels: + app: fake-gateway +spec: + replicas: 1 + selector: + matchLabels: + app: fake-gateway + template: + metadata: + labels: + app: fake-gateway + spec: + containers: + - name: echo + image: mendhak/http-https-echo:35 + ports: + - containerPort: 8080 + env: + - name: HTTP_PORT + value: "10001" +--- +apiVersion: v1 +kind: Service +metadata: + name: fake-gateway + labels: + app: fake-gateway +spec: + selector: + app: fake-gateway + ports: + - port: 10001 + targetPort: 10001 + protocol: TCP diff --git a/charts/langsmith-auth-proxy/e2e/test.sh b/charts/langsmith-auth-proxy/e2e/test.sh new file mode 100755 index 00000000..ecd1bb48 --- /dev/null +++ b/charts/langsmith-auth-proxy/e2e/test.sh @@ -0,0 +1,228 @@ +#!/usr/bin/env bash +# End-to-end test for langsmith-auth-proxy chart. +# Spins up a kind cluster, deploys an fake gateway + the chart with a +# Python ext_authz sidecar, and runs curl-based tests through the proxy. +set -euo pipefail + +CLAIMS_FILE="${1:-}" + +CLUSTER_NAME="auth-proxy-e2e" +RELEASE_NAME="auth-proxy-e2e" +NAMESPACE="default" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +CHART_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" +LOCAL_PORT=10000 # local port-forward target + +PASS=0 +FAIL=0 + +# ── Cleanup ────────────────────────────────────────────────────────── +cleanup() { + echo "" + echo "=== Cleanup ===" + # Kill any background port-forward + if [[ -n "${PF_PID:-}" ]] && kill -0 "$PF_PID" 2>/dev/null; then + kill "$PF_PID" 2>/dev/null || true + wait "$PF_PID" 2>/dev/null || true + fi + kind delete cluster --name "$CLUSTER_NAME" 2>/dev/null || true + echo "Done." +} +trap cleanup EXIT + +# ── Helpers ────────────────────────────────────────────────────────── +log() { echo "--- $*"; } +pass() { echo "PASS: $1"; PASS=$((PASS + 1)); } +fail() { echo "FAIL: $1"; FAIL=$((FAIL + 1)); } + +assert_status() { + local desc="$1" expected="$2" actual="$3" + if [[ "$actual" == "$expected" ]]; then + pass "$desc (HTTP $actual)" + else + fail "$desc — expected $expected, got $actual" + fi +} + +# ── 1. Prerequisites ──────────────────────────────────────────────── +log "Checking prerequisites" +for cmd in kind helm kubectl step curl jq; do + if ! command -v "$cmd" &>/dev/null; then + echo "ERROR: '$cmd' is required but not found in PATH" >&2 + exit 1 + fi +done +echo "All prerequisites found." + +# ── 2. Kind cluster ───────────────────────────────────────────────── +log "Creating kind cluster '$CLUSTER_NAME'" +if kind get clusters 2>/dev/null | grep -qx "$CLUSTER_NAME"; then + echo "Cluster already exists, reusing." +else + kind create cluster --name "$CLUSTER_NAME" --wait 60s +fi +kubectl cluster-info --context "kind-$CLUSTER_NAME" >/dev/null + +# ── 3. Generate RSA keys + JWT ────────────────────────────────────── +log "Generating RSA key pair and test JWT" +TMPDIR_KEYS="$(mktemp -d)" + +# Generate RSA key pair +step crypto keypair "$TMPDIR_KEYS/pub.pem" "$TMPDIR_KEYS/priv.pem" \ + --kty RSA --size 2048 --no-password --insecure + +# Convert public key to JWK and build JWKS +PUB_JWK=$(step crypto key format --jwk < "$TMPDIR_KEYS/pub.pem") +JWKS_JSON=$(echo "$PUB_JWK" | jq -c '{keys: [. + {use: "sig", alg: "RS256"}]}') +echo "JWKS: $JWKS_JSON" + +# Mint a valid JWT with claims matching LangSmith's token structure. +# Standard claims are set via flags; custom claims are piped as JSON via stdin. +# Pass a .json file as $1 to override the default custom claims. +NOW=$(date +%s) +EXP=$(( NOW + 3600 )) +JTI=$(uuidgen | tr '[:upper:]' '[:lower:]') + +if [[ -n "$CLAIMS_FILE" ]]; then + echo "Using custom claims from: $CLAIMS_FILE" + CUSTOM_CLAIMS=$(jq -c '.' "$CLAIMS_FILE") +else + echo "Using default fake claims" + CUSTOM_CLAIMS=$(jq -nc \ + --arg jti "$JTI" \ + --arg req "$JTI" \ + '{ + jti: $jti, + ls_user_id: "e2e-ls-user-id", + organization_id: "e2e-org-id", + workspace_id: "e2e-workspace-id", + model_provider: "fake-provider", + model_name: "fake-model", + streaming: false, + request_id: $req, + actor_type: "user" + }') +fi +echo "Custom claims: $CUSTOM_CLAIMS" + +JWT=$(echo "$CUSTOM_CLAIMS" | step crypto jwt sign \ + --key "$TMPDIR_KEYS/priv.pem" \ + --iss "langsmith" \ + --aud "test-audience" \ + --sub "e2e-test-user-id" \ + --nbf "$NOW" \ + --exp "$EXP") +echo "JWT: ${JWT:0:40}..." + +rm -rf "$TMPDIR_KEYS" + +# ── 4. Deploy fake gateway ───────────────────────────────────────── +log "Deploying fake gateway" +kubectl apply --context "kind-$CLUSTER_NAME" -f "$SCRIPT_DIR/fake-gateway.yaml" +kubectl rollout status deployment/fake-gateway --context "kind-$CLUSTER_NAME" --timeout=90s + +# ── 5. Deploy chart ───────────────────────────────────────────────── +log "Creating ext-authz-script ConfigMap" +kubectl create configmap ext-authz-script \ + --context "kind-$CLUSTER_NAME" \ + --from-file="ext-authz-mock.py=$SCRIPT_DIR/ext-authz-mock.py" \ + --dry-run=client -o yaml | kubectl apply --context "kind-$CLUSTER_NAME" -f - + +log "Installing chart with helm" +# Write JWKS to a temp values file — --set cannot handle nested JSON braces +TMPDIR_VALS="$(mktemp -d)" +cat > "$TMPDIR_VALS/jwks-values.yaml" </dev/null; then + echo "ERROR: port-forward died" >&2 + exit 1 +fi + +# ── 7. Tests ───────────────────────────────────────────────────────── +BASE="http://localhost:$LOCAL_PORT" + +log "Test 1: GET /healthz → 200 (bypasses auth)" +STATUS=$(curl -s -o /dev/null -w '%{http_code}' "$BASE/healthz") +assert_status "/healthz returns 200" "200" "$STATUS" + +log "Test 2: POST /v1/chat/completions without JWT → 401" +STATUS=$(curl -s -o /dev/null -w '%{http_code}' -X POST "$BASE/v1/chat/completions") +assert_status "No JWT returns 401" "401" "$STATUS" + +log "Test 3: POST /v1/chat/completions with valid JWT → 200 + header add/remove" +RESP=$(curl -s -w '\n%{http_code}' -X POST "$BASE/v1/chat/completions" \ + -H "X-LangSmith-LLM-Auth: $JWT" \ + -H "X-Remove-Me: should-be-removed" \ + -H "Content-Type: application/json" \ + -d '{"model":"test"}') +BODY=$(echo "$RESP" | sed '$d') +STATUS=$(echo "$RESP" | tail -1) +assert_status "Valid JWT returns 200" "200" "$STATUS" + +# The echo server returns JSON with all received headers +# Verify ext_authz injected the Authorization header +AUTH_HEADER=$(echo "$BODY" | jq -r '.headers.authorization // empty') +if [[ "$AUTH_HEADER" == "Bearer fake-upstream-key" ]]; then + pass "ext_authz injected Authorization header" +else + fail "Expected Authorization='Bearer fake-upstream-key', got '$AUTH_HEADER'" +fi + +# Verify ext_authz added X-Custom-Added header +CUSTOM_HEADER=$(echo "$BODY" | jq -r '.headers["x-custom-added"] // empty') +if [[ "$CUSTOM_HEADER" == "from-ext-authz" ]]; then + pass "ext_authz added X-Custom-Added header" +else + fail "Expected X-Custom-Added='from-ext-authz', got '$CUSTOM_HEADER'" +fi + +# Verify ext_authz removed X-Remove-Me header +REMOVED_HEADER=$(echo "$BODY" | jq -r '.headers["x-remove-me"] // empty') +if [[ -z "$REMOVED_HEADER" ]]; then + pass "ext_authz removed X-Remove-Me header" +else + fail "Expected X-Remove-Me to be removed, but got '$REMOVED_HEADER'" +fi + +log "Test 4: POST /v1/chat/completions with garbage JWT → 401" +STATUS=$(curl -s -o /dev/null -w '%{http_code}' -X POST "$BASE/v1/chat/completions" \ + -H "X-LangSmith-LLM-Auth: garbage.jwt.token") +assert_status "Garbage JWT returns 401" "401" "$STATUS" + +# ── 8. Logs ────────────────────────────────────────────────────────── +log "Echo upstream logs" +kubectl logs --context "kind-$CLUSTER_NAME" -l app=fake-gateway --tail=100 + +log "ext_authz sidecar logs" +kubectl logs --context "kind-$CLUSTER_NAME" "$AUTH_POD" -c ext-authz-mock --tail=100 + +# ── Summary ────────────────────────────────────────────────────────── +echo "" +echo "=== Results: $PASS passed, $FAIL failed ===" +if [[ "$FAIL" -gt 0 ]]; then + exit 1 +fi diff --git a/charts/langsmith-auth-proxy/templates/_helpers.tpl b/charts/langsmith-auth-proxy/templates/_helpers.tpl new file mode 100644 index 00000000..2183366a --- /dev/null +++ b/charts/langsmith-auth-proxy/templates/_helpers.tpl @@ -0,0 +1,131 @@ +{{/* +Expand the name of the chart. +*/}} +{{- define "authProxy.name" -}} +{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Create a default fully qualified app name. +We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec). +If release name contains chart name it will be used as a full name. +*/}} +{{- define "authProxy.fullname" -}} +{{- if .Values.fullnameOverride }} +{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- $name := default .Chart.Name .Values.nameOverride }} +{{- if contains $name .Release.Name }} +{{- .Release.Name | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }} +{{- end }} +{{- end }} +{{- end }} + +{{/* +Create chart name and version as used by the chart label. +*/}} +{{- define "authProxy.chart" -}} +{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Common labels +*/}} +{{- define "authProxy.labels" -}} +{{- if .Values.commonLabels }} +{{ toYaml .Values.commonLabels }} +{{- end }} +helm.sh/chart: {{ include "authProxy.chart" . }} +{{ include "authProxy.selectorLabels" . }} +{{- if .Chart.AppVersion }} +app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} +{{- end }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +{{- end }} + +{{/* +Common annotations +*/}} +{{- define "authProxy.annotations" -}} +{{- if .Values.commonAnnotations }} +{{ toYaml .Values.commonAnnotations }} +{{- end }} +helm.sh/chart: {{ include "authProxy.chart" . }} +{{ include "authProxy.selectorLabels" . }} +{{- if .Chart.AppVersion }} +app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} +{{- end }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +{{- end }} + +{{/* +Common pod annotations +*/}} +{{- define "authProxy.commonPodAnnotations" -}} +{{- if .Values.commonPodAnnotations }} +{{ toYaml .Values.commonPodAnnotations }} +{{- end }} +{{- end }} + +{{/* +Selector labels +*/}} +{{- define "authProxy.selectorLabels" -}} +app.kubernetes.io/name: {{ include "authProxy.name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +{{- end }} + +{{/* +Template for merging commonPodSecurityContext with component-specific podSecurityContext. +Component-specific values take precedence over common values. +*/}} +{{- define "authProxy.podSecurityContext" -}} +{{- $merged := merge .componentSecurityContext .Values.commonPodSecurityContext -}} +{{- toYaml $merged -}} +{{- end -}} + +{{/* +Creates the image reference used for deployments. If registry is specified, concatenate it, along with a '/'. +*/}} +{{- define "authProxy.image" -}} +{{- $imageConfig := index .Values.images .component -}} +{{- if .Values.images.registry -}} +{{ .Values.images.registry }}/{{ $imageConfig.repository }}:{{ $imageConfig.tag | default .Chart.AppVersion }} +{{- else -}} +{{ $imageConfig.repository }}:{{ $imageConfig.tag | default .Chart.AppVersion }} +{{- end -}} +{{- end -}} + +{{/* +Extract hostname from a URL string. +Usage: include "authProxy.urlHostname" "http://example.com:8080" +*/}} +{{- define "authProxy.urlHostname" -}} +{{- $url := urlParse . -}} +{{- $parts := splitList ":" $url.host -}} +{{- index $parts 0 -}} +{{- end -}} + +{{/* +Extract port from a URL string. Defaults to 443 for https, 80 for http. +Usage: include "authProxy.urlPort" "http://example.com:8080" +*/}} +{{- define "authProxy.urlPort" -}} +{{- $url := urlParse . -}} +{{- $parts := splitList ":" $url.host -}} +{{- if gt (len $parts) 1 -}} +{{- index $parts 1 -}} +{{- else -}} +{{- if eq $url.scheme "https" -}}443{{- else -}}80{{- end -}} +{{- end -}} +{{- end -}} + +{{- define "authProxy.serviceAccountName" -}} +{{- if .Values.authProxy.serviceAccount.create -}} + {{ default (printf "%s-%s" (include "authProxy.fullname" .) .Values.authProxy.name) .Values.authProxy.serviceAccount.name | trunc 63 | trimSuffix "-" }} +{{- else -}} + {{ default "default" .Values.authProxy.serviceAccount.name }} +{{- end -}} +{{- end -}} diff --git a/charts/langsmith-auth-proxy/templates/auth-proxy/config-map.yaml b/charts/langsmith-auth-proxy/templates/auth-proxy/config-map.yaml new file mode 100644 index 00000000..6d11af05 --- /dev/null +++ b/charts/langsmith-auth-proxy/templates/auth-proxy/config-map.yaml @@ -0,0 +1,170 @@ +{{- if .Values.authProxy.enabled }} +{{- $upstreamScheme := (urlParse .Values.authProxy.upstream).scheme -}} +{{- $upstreamHostname := include "authProxy.urlHostname" .Values.authProxy.upstream -}} +{{- $upstreamPort := include "authProxy.urlPort" .Values.authProxy.upstream -}} +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ include "authProxy.fullname" . }}-{{ .Values.authProxy.name }} + namespace: {{ .Values.namespace | default .Release.Namespace }} + labels: + {{- include "authProxy.labels" . | nindent 4 }} + annotations: + {{- include "authProxy.annotations" . | nindent 4 }} +data: + envoy.yaml: | + static_resources: + listeners: + - name: listener_0 + address: + socket_address: + address: 0.0.0.0 + port_value: 10000 + filter_chains: + - filters: + - name: envoy.filters.network.http_connection_manager + typed_config: + "@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager + stat_prefix: ingress_http + route_config: + name: local_route + virtual_hosts: + - name: upstream + domains: ["*"] + routes: + - match: + prefix: "/" + route: + cluster: upstream_provider + host_rewrite_literal: {{ $upstreamHostname }} + timeout: 0s + idle_timeout: {{ .Values.authProxy.streamIdleTimeout }} + http_filters: + - name: envoy.filters.http.health_check + typed_config: + "@type": type.googleapis.com/envoy.extensions.filters.http.health_check.v3.HealthCheck + pass_through_mode: false + headers: + - name: ":path" + string_match: + exact: "/healthz" + - name: envoy.filters.http.jwt_authn + typed_config: + "@type": type.googleapis.com/envoy.extensions.filters.http.jwt_authn.v3.JwtAuthentication + providers: + langsmith_jwt: + issuer: {{ .Values.authProxy.jwtIssuer }} + {{- if .Values.authProxy.jwtAudiences }} + audiences: + {{- range .Values.authProxy.jwtAudiences }} + - {{ . | quote }} + {{- end }} + {{- end }} + local_jwks: + inline_string: '{{ .Values.authProxy.jwksJson }}' + from_headers: + - name: X-LangSmith-LLM-Auth + forward: true + rules: + - match: + prefix: "/" + requires: + provider_name: langsmith_jwt + {{- if .Values.authProxy.extAuthz.enabled }} + - name: envoy.filters.http.ext_authz + typed_config: + "@type": type.googleapis.com/envoy.extensions.filters.http.ext_authz.v3.ExtAuthz + failure_mode_allow: false + allowed_headers: + patterns: + - safe_regex: + regex: {{ .Values.authProxy.extAuthz.allowedHeadersRegex | quote }} + {{- if .Values.authProxy.extAuthz.disallowedHeadersRegex }} + disallowed_headers: + patterns: + - safe_regex: + regex: {{ .Values.authProxy.extAuthz.disallowedHeadersRegex | quote }} + {{- end }} + http_service: + server_uri: + uri: {{ .Values.authProxy.extAuthz.serviceUrl }} + cluster: ext_authz_service + timeout: {{ .Values.authProxy.extAuthz.timeout }} + path_prefix: "/check" + {{- if .Values.authProxy.extAuthz.headersToAdd }} + authorization_request: + headers_to_add: + {{- range .Values.authProxy.extAuthz.headersToAdd }} + - key: {{ .key | quote }} + value: {{ .value | quote }} + {{- end }} + {{- end }} + authorization_response: + allowed_upstream_headers: + patterns: + {{- if .Values.authProxy.extAuthz.allowedUpstreamHeaders }} + {{- range .Values.authProxy.extAuthz.allowedUpstreamHeaders }} + {{- if .exact }} + - exact: {{ .exact | quote }} + {{- else if .prefix }} + - prefix: {{ .prefix | quote }} + {{- else if .safe_regex }} + - safe_regex: + regex: {{ .safe_regex | quote }} + {{- end }} + {{- end }} + {{- else }} + - safe_regex: + regex: ".*" + {{- end }} + allowed_client_headers: + patterns: + - exact: "www-authenticate" + - prefix: "x-" + {{- if .Values.authProxy.extAuthz.sendBody }} + with_request_body: + max_request_bytes: {{ .Values.authProxy.extAuthz.maxRequestBytes }} + allow_partial_message: false + {{- end }} + {{- end }} + - name: envoy.filters.http.router + typed_config: + "@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router + + clusters: + - name: upstream_provider + connect_timeout: 5s + type: LOGICAL_DNS + dns_lookup_family: V4_ONLY + load_assignment: + cluster_name: upstream_provider + endpoints: + - lb_endpoints: + - endpoint: + address: + socket_address: + address: {{ $upstreamHostname }} + port_value: {{ $upstreamPort }} + {{- if eq $upstreamScheme "https" }} + transport_socket: + name: envoy.transport_sockets.tls + typed_config: + "@type": type.googleapis.com/envoy.extensions.transport_sockets.tls.v3.UpstreamTlsContext + sni: {{ $upstreamHostname }} + {{- end }} + {{- if .Values.authProxy.extAuthz.enabled }} + - name: ext_authz_service + connect_timeout: 5s + type: STRICT_DNS + dns_lookup_family: V4_ONLY + load_assignment: + cluster_name: ext_authz_service + endpoints: + - lb_endpoints: + - endpoint: + address: + socket_address: + address: {{ include "authProxy.urlHostname" .Values.authProxy.extAuthz.serviceUrl }} + port_value: {{ include "authProxy.urlPort" .Values.authProxy.extAuthz.serviceUrl }} + {{- end }} +{{- end }} diff --git a/charts/langsmith-auth-proxy/templates/auth-proxy/deployment.yaml b/charts/langsmith-auth-proxy/templates/auth-proxy/deployment.yaml new file mode 100644 index 00000000..6a822cc0 --- /dev/null +++ b/charts/langsmith-auth-proxy/templates/auth-proxy/deployment.yaml @@ -0,0 +1,134 @@ +{{- if .Values.authProxy.enabled }} +{{- $volumes := .Values.authProxy.deployment.volumes -}} +{{- $volumeMounts := .Values.authProxy.deployment.volumeMounts -}} +{{- if .Values.authProxy.rollout.enabled }} +apiVersion: argoproj.io/v1alpha1 +kind: Rollout +{{- else }} +apiVersion: apps/v1 +kind: Deployment +{{- end }} +metadata: + name: {{ include "authProxy.fullname" . }}-{{ .Values.authProxy.name }} + namespace: {{ .Values.namespace | default .Release.Namespace }} + labels: + {{- include "authProxy.labels" . | nindent 4 }} + {{- with.Values.authProxy.deployment.labels }} + {{- toYaml . | nindent 4 }} + {{- end }} + annotations: + {{- include "authProxy.annotations" . | nindent 4 }} + {{- with.Values.authProxy.deployment.annotations }} + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + {{- if not .Values.authProxy.autoscaling.hpa.enabled }} + replicas: {{ .Values.authProxy.deployment.replicas }} + {{- end }} + selector: + matchLabels: + {{- include "authProxy.selectorLabels" . | nindent 6 }} + app.kubernetes.io/component: {{ include "authProxy.fullname" . }}-{{ .Values.authProxy.name }} + template: + metadata: + annotations: + {{- include "authProxy.commonPodAnnotations" . | nindent 8 }} + {{- with .Values.authProxy.deployment.annotations }} + {{- toYaml . | nindent 8 }} + {{- end }} + labels: + {{- with.Values.authProxy.deployment.labels }} + {{- toYaml . | nindent 8 }} + {{- end }} + {{- include "authProxy.labels" . | nindent 8 }} + app.kubernetes.io/component: {{ include "authProxy.fullname" . }}-{{ .Values.authProxy.name }} + spec: + terminationGracePeriodSeconds: {{ .Values.authProxy.deployment.terminationGracePeriodSeconds }} + {{- with .Values.images.imagePullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 8 }} + {{- end }} + securityContext: + {{- include "authProxy.podSecurityContext" (dict "Values" .Values "componentSecurityContext" .Values.authProxy.deployment.podSecurityContext) | nindent 8 }} + serviceAccountName: {{ include "authProxy.serviceAccountName" . }} + {{- with .Values.authProxy.deployment.initContainers }} + initContainers: + {{- toYaml . | nindent 8 }} + {{- end }} + containers: + - name: {{ .Values.authProxy.name }} + {{- with.Values.authProxy.deployment.command }} + command: + {{- toYaml . | nindent 12 }} + {{- end }} + {{- with .Values.authProxy.deployment.extraEnv }} + env: + {{ toYaml . | nindent 12 }} + {{- end }} + image: {{ include "authProxy.image" (dict "Values" .Values "Chart" .Chart "component" "authProxyImage") | quote }} + imagePullPolicy: {{ .Values.images.authProxyImage.pullPolicy }} + ports: + - name: envoy + containerPort: {{ .Values.authProxy.containerPort }} + protocol: TCP + {{- with .Values.authProxy.deployment.startupProbe }} + startupProbe: + {{- toYaml . | nindent 12 }} + {{- end }} + {{- with .Values.authProxy.deployment.livenessProbe }} + livenessProbe: + {{- toYaml . | nindent 12 }} + {{- end }} + {{- with .Values.authProxy.deployment.readinessProbe }} + readinessProbe: + {{- toYaml . | nindent 12 }} + {{- end }} + resources: + {{- toYaml .Values.authProxy.deployment.resources | nindent 12 }} + securityContext: + {{- toYaml .Values.authProxy.deployment.securityContext | nindent 12 }} + volumeMounts: + {{- with $volumeMounts }} + {{- toYaml . | nindent 12 }} + {{- end }} + - mountPath: /etc/envoy/envoy.yaml + name: envoy-conf + subPath: envoy.yaml + readOnly: true + {{- with .Values.authProxy.deployment.extraContainerConfig }} + {{- toYaml . | nindent 10 }} + {{- end }} + {{- with .Values.authProxy.deployment.sidecars }} + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.authProxy.deployment.nodeSelector }} + nodeSelector: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.authProxy.deployment.affinity }} + affinity: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.authProxy.deployment.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.authProxy.deployment.topologySpreadConstraints }} + topologySpreadConstraints: + {{- toYaml . | nindent 8 }} + {{- end }} + volumes: + {{- with $volumes }} + {{- toYaml . | nindent 8 }} + {{- end }} + - name: envoy-conf + configMap: + name: {{ include "authProxy.fullname" . }}-{{ .Values.authProxy.name }} + items: + - key: envoy.yaml + path: envoy.yaml +{{- if .Values.authProxy.rollout.enabled }} + strategy: + {{- toYaml .Values.authProxy.rollout.strategy | nindent 4 }} +{{- end }} +{{- end }} diff --git a/charts/langsmith-auth-proxy/templates/auth-proxy/hpa.yaml b/charts/langsmith-auth-proxy/templates/auth-proxy/hpa.yaml new file mode 100644 index 00000000..adf152a4 --- /dev/null +++ b/charts/langsmith-auth-proxy/templates/auth-proxy/hpa.yaml @@ -0,0 +1,41 @@ +{{- if and .Values.authProxy.enabled .Values.authProxy.autoscaling.hpa.enabled }} +apiVersion: autoscaling/v2 +kind: HorizontalPodAutoscaler +metadata: + name: {{ include "authProxy.fullname" . }}-{{ .Values.authProxy.name }} + namespace: {{ .Values.namespace | default .Release.Namespace }} + labels: + {{- include "authProxy.labels" . | nindent 4 }} +spec: + scaleTargetRef: + {{- if .Values.authProxy.rollout.enabled }} + apiVersion: argoproj.io/v1alpha1 + kind: Rollout + {{- else }} + apiVersion: apps/v1 + kind: Deployment + {{- end }} + name: {{ include "authProxy.fullname" . }}-{{ .Values.authProxy.name }} + minReplicas: {{ .Values.authProxy.autoscaling.hpa.minReplicas }} + maxReplicas: {{ .Values.authProxy.autoscaling.hpa.maxReplicas }} + metrics: + {{- if .Values.authProxy.autoscaling.hpa.targetCPUUtilizationPercentage }} + - type: Resource + resource: + name: cpu + target: + type: Utilization + averageUtilization: {{ .Values.authProxy.autoscaling.hpa.targetCPUUtilizationPercentage }} + {{- end }} + {{- if .Values.authProxy.autoscaling.hpa.targetMemoryUtilizationPercentage }} + - type: Resource + resource: + name: memory + target: + type: Utilization + averageUtilization: {{ .Values.authProxy.autoscaling.hpa.targetMemoryUtilizationPercentage }} + {{- end }} + {{- if .Values.authProxy.autoscaling.hpa.additionalMetrics }} + {{- toYaml .Values.authProxy.autoscaling.hpa.additionalMetrics | nindent 4 }} + {{- end }} +{{- end }} diff --git a/charts/langsmith-auth-proxy/templates/auth-proxy/pdb.yaml b/charts/langsmith-auth-proxy/templates/auth-proxy/pdb.yaml new file mode 100644 index 00000000..181d128b --- /dev/null +++ b/charts/langsmith-auth-proxy/templates/auth-proxy/pdb.yaml @@ -0,0 +1,28 @@ +{{- if and .Values.authProxy.enabled .Values.authProxy.pdb.enabled }} +apiVersion: policy/v1 +kind: PodDisruptionBudget +metadata: + name: {{ include "authProxy.fullname" . }}-{{ .Values.authProxy.name }} + namespace: {{ .Values.namespace | default .Release.Namespace }} + labels: + {{- include "authProxy.labels" . | nindent 4 }} + {{- with .Values.authProxy.pdb.labels }} + {{- toYaml . | nindent 4 }} + {{- end }} + annotations: + {{- include "authProxy.annotations" . | nindent 4 }} + {{- with .Values.authProxy.pdb.annotations }} + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + selector: + matchLabels: + {{- include "authProxy.selectorLabels" . | nindent 6 }} + app.kubernetes.io/component: {{ include "authProxy.fullname" . }}-{{ .Values.authProxy.name }} + {{- if .Values.authProxy.pdb.minAvailable }} + minAvailable: {{ .Values.authProxy.pdb.minAvailable }} + {{- end }} + {{- if .Values.authProxy.pdb.maxUnavailable }} + maxUnavailable: {{ .Values.authProxy.pdb.maxUnavailable }} + {{- end }} +{{- end }} diff --git a/charts/langsmith-auth-proxy/templates/auth-proxy/service-account.yaml b/charts/langsmith-auth-proxy/templates/auth-proxy/service-account.yaml new file mode 100644 index 00000000..7f4ef549 --- /dev/null +++ b/charts/langsmith-auth-proxy/templates/auth-proxy/service-account.yaml @@ -0,0 +1,18 @@ +{{- if and .Values.authProxy.enabled .Values.authProxy.serviceAccount.create -}} +apiVersion: v1 +kind: ServiceAccount +metadata: + name: {{ include "authProxy.serviceAccountName" . }} + namespace: {{ .Values.namespace | default .Release.Namespace }} + labels: + {{- include "authProxy.labels" . | nindent 4 }} + {{- with.Values.authProxy.deployment.labels }} + {{- toYaml . | nindent 4 }} + {{- end }} + annotations: + {{- include "authProxy.annotations" . | nindent 4 }} + {{- with.Values.authProxy.serviceAccount.annotations }} + {{- toYaml . | nindent 4 }} + {{- end }} +automountServiceAccountToken: {{ .Values.authProxy.serviceAccount.automountServiceAccountToken | default true }} +{{- end }} diff --git a/charts/langsmith-auth-proxy/templates/auth-proxy/service.yaml b/charts/langsmith-auth-proxy/templates/auth-proxy/service.yaml new file mode 100644 index 00000000..7acf79b6 --- /dev/null +++ b/charts/langsmith-auth-proxy/templates/auth-proxy/service.yaml @@ -0,0 +1,33 @@ +{{- if .Values.authProxy.enabled }} +apiVersion: v1 +kind: Service +metadata: + name: {{ include "authProxy.fullname" . }}-{{ .Values.authProxy.name }} + namespace: {{ .Values.namespace | default .Release.Namespace }} + labels: + {{- include "authProxy.labels" . | nindent 4 }} + {{- with.Values.authProxy.service.labels }} + {{- toYaml . | nindent 4 }} + {{- end }} + app.kubernetes.io/component: {{ include "authProxy.fullname" . }}-{{ .Values.authProxy.name }} + annotations: + {{- include "authProxy.annotations" . | nindent 4 }} + {{- with.Values.authProxy.service.annotations }} + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + type: {{ .Values.authProxy.service.type }} + {{- with .Values.authProxy.service.loadBalancerSourceRanges }} + loadBalancerSourceRanges: + {{- toYaml . | nindent 4 }} + {{- end }} + loadBalancerIP: {{ .Values.authProxy.service.loadBalancerIP }} + ports: + - name: envoy + port: {{ .Values.authProxy.service.port }} + targetPort: envoy + protocol: TCP + selector: + {{- include "authProxy.selectorLabels" . | nindent 4 }} + app.kubernetes.io/component: {{ include "authProxy.fullname" . }}-{{ .Values.authProxy.name }} +{{- end }} diff --git a/charts/langsmith-auth-proxy/templates/http_route.yaml b/charts/langsmith-auth-proxy/templates/http_route.yaml new file mode 100644 index 00000000..34c47008 --- /dev/null +++ b/charts/langsmith-auth-proxy/templates/http_route.yaml @@ -0,0 +1,38 @@ +{{- if .Values.gateway.enabled }} +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: {{ include "authProxy.fullname" . }} + namespace: {{ .Values.namespace | default .Release.Namespace }} + annotations: + {{- include "authProxy.annotations" . | nindent 4 }} + {{- with .Values.gateway.annotations }} + {{- toYaml . | nindent 4 }} + {{- end }} + labels: + {{- include "authProxy.labels" . | nindent 4 }} + {{- with .Values.gateway.labels }} + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + parentRefs: + - name: {{ .Values.gateway.name }} + {{- if .Values.gateway.namespace }} + namespace: {{ .Values.gateway.namespace }} + {{- end }} + {{- if .Values.gateway.sectionName }} + sectionName: {{ .Values.gateway.sectionName }} + {{- end }} + {{- with .Values.gateway.hostnames }} + hostnames: + {{- toYaml . | nindent 2 }} + {{- end }} + rules: + - matches: + - path: + type: PathPrefix + value: / + backendRefs: + - name: {{ include "authProxy.fullname" . }}-{{ .Values.authProxy.name }} + port: {{ .Values.authProxy.service.port }} +{{- end }} diff --git a/charts/langsmith-auth-proxy/templates/ingress.yaml b/charts/langsmith-auth-proxy/templates/ingress.yaml new file mode 100644 index 00000000..1d7523e2 --- /dev/null +++ b/charts/langsmith-auth-proxy/templates/ingress.yaml @@ -0,0 +1,40 @@ +{{- if .Values.ingress.enabled }} +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: {{ include "authProxy.fullname" . }}-ingress + namespace: {{ .Values.namespace | default .Release.Namespace }} + annotations: + {{- include "authProxy.annotations" . | nindent 4 }} + {{- with .Values.ingress.annotations }} + {{- toYaml . | nindent 4 }} + {{- end }} + labels: + {{- include "authProxy.labels" . | nindent 4 }} + {{- with .Values.ingress.labels }} + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + {{- with .Values.ingress.ingressClassName }} + ingressClassName: {{ . }} + {{- end }} + {{- with .Values.ingress.tls }} + tls: + {{- toYaml . | nindent 4 }} + {{- end }} + rules: + {{- range .Values.ingress.hosts }} + - host: {{ .host }} + http: + paths: + {{- range .paths }} + - path: {{ .path | default "/" }} + pathType: {{ .pathType | default "Prefix" }} + backend: + service: + name: {{ include "authProxy.fullname" $ }}-{{ $.Values.authProxy.name }} + port: + number: {{ $.Values.authProxy.service.port }} + {{- end }} + {{- end }} +{{- end }} diff --git a/charts/langsmith-auth-proxy/values.yaml b/charts/langsmith-auth-proxy/values.yaml new file mode 100644 index 00000000..6e69b110 --- /dev/null +++ b/charts/langsmith-auth-proxy/values.yaml @@ -0,0 +1,186 @@ +# -- Provide a name in place of `langsmith-auth-proxy` +nameOverride: "" +# -- String to fully override `"langsmith.fullname"` +fullnameOverride: "" +# -- Namespace to install the chart into. If not set, will use the namespace of the current context. +namespace: "" +# -- Annotations that will be applied to all resources created by the chart +commonAnnotations: {} +# -- Annotations that will be applied to all pods created by the chart +commonPodAnnotations: {} +# -- Labels that will be applied to all resources created by the chart +commonLabels: {} +# -- Common pod security context applied to all pods. Component-specific podSecurityContext values will be merged on top of this (component values take precedence). +commonPodSecurityContext: {} + +images: + # -- If supplied, all children .repository values will be prepended with this registry name + `/` + registry: "" + imagePullSecrets: [] + authProxyImage: + repository: "docker.io/envoyproxy/envoy" + pullPolicy: IfNotPresent + tag: "v1.37-latest" + +# Auth Proxy - Envoy-based proxy for validating LangSmith-signed JWTs +# and optionally calling an external auth service before forwarding to an upstream LLM provider or gateway. +# Separate ingress from the main LangSmith app — different hostname, streaming-optimized. +authProxy: + enabled: true + name: "auth-proxy" + containerPort: 10000 + # -- Upstream LLM provider URL (e.g. https://api.openai.com) + upstream: "" + # -- JWT issuer claim to validate + jwtIssuer: "langsmith" + # -- JWT audience claims to validate. Must match audiences in the signed JWT. + jwtAudiences: [] + # -- JWKS JSON string containing the public keys for JWT validation. + # Generate with the LangSmith JWKS tooling and paste the full JSON here. + jwksJson: "" + # -- Idle timeout for streaming responses (e.g. SSE from LLM providers) + streamIdleTimeout: "300s" + # External authorization service configuration (for injecting LLM provider auth headers). + # See https://www.envoyproxy.io/docs/envoy/latest/api-v3/extensions/filters/http/ext_authz/v3/ext_authz.proto for details. + extAuthz: + enabled: false + # -- HTTP service URL for ext_authz (e.g. http://my-auth-service:8080) + serviceUrl: "" + # -- Timeout for ext_authz requests + timeout: "10s" + # -- Regex controlling which client request headers are forwarded to the ext_authz service. Defaults to all headers. + # Maps to http_service.allowed_headers. Uses Google RE2 syntax: https://github.com/google/re2/wiki/Syntax. + allowedHeadersRegex: ".*" + # -- Regex controlling which client request headers are NOT forwarded to the ext_authz service (higher precedence than allowedHeadersRegex). + # Maps to http_service.disallowed_headers. Uses Google RE2 syntax: https://github.com/google/re2/wiki/Syntax. + disallowedHeadersRegex: "" + # -- Static headers to add to every ext_authz check request (authorization_request.headers_to_add). + # Example: [{key: "x-auth-context", value: "langsmith"}] + headersToAdd: [] + # -- Patterns controlling which ext_authz response headers are forwarded upstream (authorization_response.allowed_upstream_headers). + # Each entry is an object with one of these keys: `exact`, `prefix`, or `safe_regex`. + # @default -- `[{exact: "authorization"}, {prefix: "x-"}]` + allowedUpstreamHeaders: [] + # -- Whether to send the request body to ext_authz + sendBody: false + # -- Maximum request body bytes to buffer for ext_authz + maxRequestBytes: 8192 + # -- ArgoCD Rollouts configuration. If enabled, will create a Rollout resource instead of a Deployment. See https://argo-rollouts.readthedocs.io/ + rollout: + enabled: false + # -- Rollout strategy configuration. See https://argo-rollouts.readthedocs.io/en/stable/features/specification/ + strategy: + canary: + steps: + - setWeight: 100 + deployment: + replicas: 1 + labels: {} + annotations: {} + podSecurityContext: {} + securityContext: {} + resources: + limits: + cpu: 500m + memory: 256Mi + requests: + cpu: 100m + memory: 128Mi + command: + - "envoy" + - "-c" + - "/etc/envoy/envoy.yaml" + startupProbe: + httpGet: + path: /healthz + port: 10000 + failureThreshold: 6 + periodSeconds: 10 + timeoutSeconds: 1 + livenessProbe: + httpGet: + path: /healthz + port: 10000 + failureThreshold: 6 + periodSeconds: 10 + timeoutSeconds: 1 + readinessProbe: + httpGet: + path: /healthz + port: 10000 + failureThreshold: 6 + periodSeconds: 10 + timeoutSeconds: 1 + extraContainerConfig: {} + extraEnv: [] + sidecars: [] + initContainers: [] + nodeSelector: {} + tolerations: [] + topologySpreadConstraints: [] + affinity: {} + volumes: [] + volumeMounts: [] + terminationGracePeriodSeconds: 30 + # Autoscaling configuration. + autoscaling: + # HPA-specific configuration + hpa: + enabled: false + minReplicas: 1 + maxReplicas: 5 + targetCPUUtilizationPercentage: 50 + targetMemoryUtilizationPercentage: 80 + additionalMetrics: [] + pdb: + enabled: false + minAvailable: 1 + labels: {} + annotations: {} + service: + type: ClusterIP + port: 10000 + labels: {} + annotations: {} + loadBalancerSourceRanges: [] + loadBalancerIP: "" + serviceAccount: + create: true + name: "" + labels: {} + annotations: {} + automountServiceAccountToken: true + +# -- Ingress configuration +ingress: + enabled: false + ingressClassName: "" + labels: {} + # -- Annotations for streaming support. Defaults shown are for nginx ingress controller. + annotations: {} + # nginx.ingress.kubernetes.io/proxy-buffering: "off" + # nginx.ingress.kubernetes.io/proxy-read-timeout: "3600" + # nginx.ingress.kubernetes.io/proxy-send-timeout: "3600" + hosts: [] + # - host: llm-proxy.example.com + # paths: + # - path: / + # pathType: Prefix + tls: [] + # - secretName: llm-proxy-tls + # hosts: + # - llm-proxy.example.com + +# -- Gateway API HTTPRoute configuration +gateway: + enabled: false + # -- Name of the Gateway resource to attach to + name: "" + # -- Namespace of the Gateway resource (if different from chart namespace) + namespace: "" + # -- SectionName of the Gateway listener to attach to + sectionName: "" + labels: {} + annotations: {} + # -- Hostnames to match on + hostnames: []