diff --git a/examples/cross-namespace-a2a/00-namespaces.yaml b/examples/cross-namespace-a2a/00-namespaces.yaml new file mode 100644 index 000000000..50651eeb0 --- /dev/null +++ b/examples/cross-namespace-a2a/00-namespaces.yaml @@ -0,0 +1,17 @@ +# Two namespaces on the same cluster. Labels drive the AllowedNamespaces +# selector on the specialist — only namespaces with team=alpha can reference it. +apiVersion: v1 +kind: Namespace +metadata: + name: team-alpha + labels: + team: alpha + kagent.dev/agent-consumer: "true" +--- +apiVersion: v1 +kind: Namespace +metadata: + name: team-beta + labels: + team: beta + kagent.dev/agent-provider: "true" diff --git a/examples/cross-namespace-a2a/01-rbac.yaml b/examples/cross-namespace-a2a/01-rbac.yaml new file mode 100644 index 000000000..0a38611e3 --- /dev/null +++ b/examples/cross-namespace-a2a/01-rbac.yaml @@ -0,0 +1,75 @@ +# ServiceAccounts for each agent. Agents run with least-privilege: +# they can only read Secrets in their own namespace. +# The kagent controller itself has cluster-scoped Agent/ModelConfig read +# (granted by the helm chart) — these bindings are agent-runtime only. + +# --- team-beta: specialist --- +apiVersion: v1 +kind: ServiceAccount +metadata: + name: specialist-agent + namespace: team-beta +--- +# Specialist can read its own secrets (for model API key resolution) +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: specialist-agent + namespace: team-beta +rules: + - apiGroups: [""] + resources: ["secrets", "configmaps"] + verbs: ["get", "list"] + - apiGroups: ["kagent.dev"] + resources: ["agents", "modelconfigs"] + verbs: ["get", "list", "watch"] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: specialist-agent + namespace: team-beta +subjects: + - kind: ServiceAccount + name: specialist-agent + namespace: team-beta +roleRef: + kind: Role + name: specialist-agent + apiGroup: rbac.authorization.k8s.io + +# --- team-alpha: orchestrator --- +apiVersion: v1 +kind: ServiceAccount +metadata: + name: orchestrator-agent + namespace: team-alpha +--- +# Orchestrator can read its own secrets. Critically, it CANNOT read +# team-beta secrets — cross-namespace Secret access is not granted. +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: orchestrator-agent + namespace: team-alpha +rules: + - apiGroups: [""] + resources: ["secrets", "configmaps"] + verbs: ["get", "list"] + - apiGroups: ["kagent.dev"] + resources: ["agents", "modelconfigs"] + verbs: ["get", "list", "watch"] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: orchestrator-agent + namespace: team-alpha +subjects: + - kind: ServiceAccount + name: orchestrator-agent + namespace: team-alpha +roleRef: + kind: Role + name: orchestrator-agent + apiGroup: rbac.authorization.k8s.io diff --git a/examples/cross-namespace-a2a/02-network-policy.yaml b/examples/cross-namespace-a2a/02-network-policy.yaml new file mode 100644 index 000000000..0d79a4b5a --- /dev/null +++ b/examples/cross-namespace-a2a/02-network-policy.yaml @@ -0,0 +1,128 @@ +# NetworkPolicies: default-deny then allow only the kagent controller +# to reach agent pods. Agents cannot call each other directly — all A2A +# traffic routes through the gateway (controller service on port 8083). + +# --- team-beta: deny all ingress, allow only from kagent controller --- +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: default-deny-ingress + namespace: team-beta +spec: + podSelector: {} + policyTypes: + - Ingress +--- +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: allow-kagent-controller + namespace: team-beta +spec: + podSelector: + matchLabels: + app.kubernetes.io/name: specialist-agent + policyTypes: + - Ingress + ingress: + # Only the kagent controller pod may initiate connections to the specialist. + # The controller lives in the kagent namespace and carries this label. + - from: + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: kagent + podSelector: + matchLabels: + app.kubernetes.io/name: kagent + ports: + - protocol: TCP + port: 8080 # agent HTTP port (A2A endpoint) + +# --- team-alpha: deny all ingress, allow only from kagent controller --- +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: default-deny-ingress + namespace: team-alpha +spec: + podSelector: {} + policyTypes: + - Ingress +--- +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: allow-kagent-controller + namespace: team-alpha +spec: + podSelector: + matchLabels: + app.kubernetes.io/name: orchestrator-agent + policyTypes: + - Ingress + ingress: + - from: + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: kagent + podSelector: + matchLabels: + app.kubernetes.io/name: kagent + ports: + - protocol: TCP + port: 8080 + +# --- Both namespaces: allow egress only to kagent controller + kube-dns --- +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: allow-egress-to-kagent + namespace: team-alpha +spec: + podSelector: {} + policyTypes: + - Egress + egress: + - to: + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: kagent + ports: + - protocol: TCP + port: 8083 # kagent A2A gateway port + - to: + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: kube-system + ports: + - protocol: UDP + port: 53 # kube-dns + - protocol: TCP + port: 53 +--- +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: allow-egress-to-kagent + namespace: team-beta +spec: + podSelector: {} + policyTypes: + - Egress + egress: + - to: + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: kagent + ports: + - protocol: TCP + port: 8083 + - to: + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: kube-system + ports: + - protocol: UDP + port: 53 + - protocol: TCP + port: 53 diff --git a/examples/cross-namespace-a2a/03-secrets.yaml b/examples/cross-namespace-a2a/03-secrets.yaml new file mode 100644 index 000000000..50d1589e0 --- /dev/null +++ b/examples/cross-namespace-a2a/03-secrets.yaml @@ -0,0 +1,50 @@ +# Each namespace holds only its own secrets. The orchestrator in team-alpha +# holds a delegation token it passes as X-Agent-Token when calling the +# specialist. The specialist validates this header (or will once OIDC lands). +# +# DO NOT commit real keys — replace these base64 values before applying. +# Generate: echo -n 'your-key' | base64 + +# --- team-beta: specialist model API key --- +apiVersion: v1 +kind: Secret +metadata: + name: openai-secret + namespace: team-beta +type: Opaque +data: + # echo -n 'sk-YOUR-KEY-HERE' | base64 + api-key: c2stWU9VUi1LRVktSEVSRQ== +--- +# Token the specialist uses to validate callers. The orchestrator must +# present this in X-Agent-Token. Rotate independently of model keys. +apiVersion: v1 +kind: Secret +metadata: + name: specialist-delegation-token + namespace: team-beta +type: Opaque +data: + # echo -n 'change-me-strong-random-token' | base64 + token: Y2hhbmdlLW1lLXN0cm9uZy1yYW5kb20tdG9rZW4= + +# --- team-alpha: orchestrator model API key --- +apiVersion: v1 +kind: Secret +metadata: + name: openai-secret + namespace: team-alpha +type: Opaque +data: + api-key: c2stWU9VUi1LRVktSEVSRQ== +--- +# Delegation token the orchestrator presents to the specialist. +# Must match specialist-delegation-token.token in team-beta. +apiVersion: v1 +kind: Secret +metadata: + name: specialist-delegation-token + namespace: team-alpha +type: Opaque +data: + token: Y2hhbmdlLW1lLXN0cm9uZy1yYW5kb20tdG9rZW4= diff --git a/examples/cross-namespace-a2a/04-model-configs.yaml b/examples/cross-namespace-a2a/04-model-configs.yaml new file mode 100644 index 000000000..fdbd033b0 --- /dev/null +++ b/examples/cross-namespace-a2a/04-model-configs.yaml @@ -0,0 +1,24 @@ +# ModelConfig per namespace — each team owns its own model config and API key. +# Neither can read the other's Secret. + +apiVersion: kagent.dev/v1alpha2 +kind: ModelConfig +metadata: + name: default-model + namespace: team-beta +spec: + provider: OpenAI + model: gpt-4o-mini + apiKeySecret: openai-secret + apiKeySecretKey: api-key +--- +apiVersion: kagent.dev/v1alpha2 +kind: ModelConfig +metadata: + name: default-model + namespace: team-alpha +spec: + provider: OpenAI + model: gpt-4o-mini + apiKeySecret: openai-secret + apiKeySecretKey: api-key diff --git a/examples/cross-namespace-a2a/05-specialist-agent.yaml b/examples/cross-namespace-a2a/05-specialist-agent.yaml new file mode 100644 index 000000000..3225aa03a --- /dev/null +++ b/examples/cross-namespace-a2a/05-specialist-agent.yaml @@ -0,0 +1,40 @@ +# Specialist agent in team-beta. +# +# Key security field: spec.allowedNamespaces +# The reconciler calls AllowsNamespace() before wiring this agent as a tool. +# Any Agent in a namespace NOT matching the selector will be rejected at +# reconcile time — the orchestrator's tool reference will fail with an error +# and the orchestrator agent will not be admitted. +# +# Pattern follows Gateway API cross-namespace route attachment: +# https://gateway-api.sigs.k8s.io/guides/multiple-ns/#cross-namespace-routing + +apiVersion: kagent.dev/v1alpha2 +kind: Agent +metadata: + name: specialist + namespace: team-beta +spec: + type: Declarative + declarative: + description: > + Math specialist. Solves arithmetic, algebra, and calculus problems + step by step. Only accepts delegations from team-alpha. + systemMessage: | + You are a precise math specialist. When given a problem: + 1. State the problem clearly. + 2. Show every step. + 3. State the final answer on its own line prefixed with "Answer:". + Never guess. If unsure, say so. + modelConfig: default-model + tools: [] + + # Cross-namespace access control — Gateway API pattern. + # Only namespaces labelled team=alpha may reference this agent as a tool. + # Change to `from: All` to allow any namespace (not recommended for prod). + # Change to `from: Same` (or omit) to lock to team-beta only. + allowedNamespaces: + from: Selector + selector: + matchLabels: + team: alpha diff --git a/examples/cross-namespace-a2a/06-orchestrator-agent.yaml b/examples/cross-namespace-a2a/06-orchestrator-agent.yaml new file mode 100644 index 000000000..d4bb4779d --- /dev/null +++ b/examples/cross-namespace-a2a/06-orchestrator-agent.yaml @@ -0,0 +1,50 @@ +# Orchestrator agent in team-alpha. +# +# Calls the specialist in team-beta as a tool via the kagent A2A gateway. +# The gateway URL pattern is: +# :8083/api/a2a/team-beta/specialist/ +# +# headersFrom injects the delegation token from team-alpha's own Secret as +# X-Agent-Token on every outbound A2A call to the specialist. The specialist +# can validate this header. Crucially, the orchestrator never touches +# team-beta's Secrets — it only presents a matching token from its own namespace. +# +# If the specialist's allowedNamespaces selector does NOT match team-alpha, +# the controller reconciler rejects this Agent at admission and logs: +# "cross-namespace reference denied: team-alpha -> team-beta/specialist" + +apiVersion: kagent.dev/v1alpha2 +kind: Agent +metadata: + name: orchestrator + namespace: team-alpha +spec: + type: Declarative + declarative: + description: > + Orchestrator agent. Receives user requests and delegates math problems + to the team-beta specialist via the kagent Agent Gateway. + systemMessage: | + You are an orchestrating agent. For any math question, delegate it to + the specialist tool and return the specialist's answer verbatim. + Do not attempt to solve math yourself. + modelConfig: default-model + tools: + # Cross-namespace agent tool reference. + # `name` is the Agent name; `namespace` resolves the cross-namespace ref. + # The reconciler validates team-alpha is allowed by team-beta/specialist + # before this agent is admitted. + - agent: + name: specialist + namespace: team-beta + # headersFrom: inject delegation token from THIS namespace's Secret. + # The controller translator resolves the Secret at reconcile time and + # adds X-Agent-Token to every A2A request to the specialist. + # The specialist's auth layer (UnsecureAuthenticator today, JWT once + # EP-476 lands) can validate this header. + headersFrom: + - name: X-Agent-Token + valueFrom: + type: Secret + name: specialist-delegation-token + key: token diff --git a/examples/cross-namespace-a2a/07-verify.sh b/examples/cross-namespace-a2a/07-verify.sh new file mode 100755 index 000000000..dff1cf09f --- /dev/null +++ b/examples/cross-namespace-a2a/07-verify.sh @@ -0,0 +1,118 @@ +#!/usr/bin/env bash +# Smoke test: prove cross-namespace A2A routing works and that the +# security controls block unauthorised cross-namespace access. +set -euo pipefail + +KAGENT_NS="${KAGENT_NS:-kagent}" +CONTROLLER_PORT="${CONTROLLER_PORT:-8083}" + +# Port-forward the kagent controller so we can hit the A2A gateway locally. +echo "▶ Port-forwarding kagent controller on :${CONTROLLER_PORT}…" +kubectl -n "$KAGENT_NS" port-forward svc/kagent "${CONTROLLER_PORT}:${CONTROLLER_PORT}" & +PF_PID=$! +trap "kill $PF_PID 2>/dev/null; echo 'port-forward stopped'" EXIT +sleep 2 + +BASE="http://localhost:${CONTROLLER_PORT}/api/a2a" + +# ── Test 1: Orchestrator agent card is reachable ────────────────────────────── +echo "" +echo "── Test 1: orchestrator agent card ──" +curl -sf "${BASE}/team-alpha/orchestrator/.well-known/agent.json" | jq '.name' + +# ── Test 2: Specialist agent card is reachable via gateway ─────────────────── +echo "" +echo "── Test 2: specialist agent card (via gateway) ──" +curl -sf "${BASE}/team-beta/specialist/.well-known/agent.json" | jq '.name' + +# ── Test 3: Send a math task to the orchestrator; it must delegate ──────────── +echo "" +echo "── Test 3: send math task to orchestrator → expect delegation to specialist ──" +TASK_RESP=$(curl -sf -X POST "${BASE}/team-alpha/orchestrator/" \ + -H "Content-Type: application/json" \ + -H "X-User-Id: david@test" \ + -d '{ + "jsonrpc": "2.0", + "id": "test-1", + "method": "message/send", + "params": { + "message": { + "role": "user", + "parts": [{ "kind": "text", "text": "What is 17 multiplied by 38?" }], + "messageId": "msg-001" + } + } + }') + +echo "$TASK_RESP" | jq -r '.result.parts[0].text // .result // .' + +# ── Test 4: Security — team-gamma (unlabelled) cannot use the specialist ───── +echo "" +echo "── Test 4: security check — unlabelled namespace rejected at reconcile ──" +# Create a temporary namespace without team=alpha label +kubectl create namespace team-gamma --dry-run=client -o yaml | kubectl apply -f - 2>/dev/null || true + +# Attempt to create an agent in team-gamma that references team-beta/specialist. +# The reconciler should deny this with an AllowedNamespaces violation. +DENY_RESULT=$(kubectl apply -f - 2>&1 <<'EOF' +apiVersion: kagent.dev/v1alpha2 +kind: Agent +metadata: + name: rogue-agent + namespace: team-gamma +spec: + type: Declarative + declarative: + description: Rogue agent attempting cross-namespace access + systemMessage: You are a rogue agent. + modelConfig: default-model + tools: + - agent: + name: specialist + namespace: team-beta +EOF +) || true + +if echo "$DENY_RESULT" | grep -qi "denied\|not allowed\|forbidden\|error"; then + echo "✓ BLOCKED: team-gamma cannot reference team-beta/specialist (AllowedNamespaces enforced)" +else + echo "⚠ Agent created — check reconciler logs for deferred rejection:" + echo " kubectl -n $KAGENT_NS logs deploy/kagent | grep 'team-gamma'" + echo " (Reconciler may surface error on Agent status rather than admission webhook)" +fi + +# Clean up rogue agent +kubectl delete agent rogue-agent -n team-gamma --ignore-not-found 2>/dev/null || true + +# ── Test 5: Direct call to specialist bypassing gateway is blocked by NetworkPolicy ── +echo "" +echo "── Test 5: direct pod-to-pod call blocked by NetworkPolicy ──" +SPECIALIST_IP=$(kubectl -n team-beta get pods -l app.kubernetes.io/name=specialist-agent \ + -o jsonpath='{.items[0].status.podIP}' 2>/dev/null || echo "") + +if [ -z "$SPECIALIST_IP" ]; then + echo "ℹ No specialist pod found — skip network test (apply full stack first)" +else + # Try to curl the specialist pod directly from a debug pod in team-alpha. + # NetworkPolicy should block this — only kagent controller can reach it. + NETTEST=$(kubectl run nettest --image=curlimages/curl:latest --restart=Never \ + --namespace=team-alpha --rm -i --timeout=10s -- \ + curl -sf --connect-timeout 3 "http://${SPECIALIST_IP}:8080/" 2>&1 || true) + + if echo "$NETTEST" | grep -qi "timed out\|connection refused\|exit code"; then + echo "✓ BLOCKED: direct pod-to-pod call from team-alpha to team-beta/specialist denied by NetworkPolicy" + else + echo "⚠ Direct call may have succeeded — verify CNI enforces NetworkPolicy (Calico/Cilium required)" + fi +fi + +echo "" +echo "✓ Verification complete." +echo "" +echo "Security summary:" +echo " CRD-level: AllowedNamespaces selector on specialist (team=alpha only)" +echo " Secret scope: Orchestrator reads team-alpha secrets only (RBAC)" +echo " Token inject: X-Agent-Token from team-alpha Secret → specialist header" +echo " Network: NetworkPolicy blocks direct pod-to-pod; all A2A via gateway" +echo " Auth (current): UnsecureAuthenticator forwards X-User-Id (dev mode)" +echo " Auth (prod): Apply EP-476 OIDC JWT policy when merged" diff --git a/examples/cross-namespace-a2a/08-istio-authz.yaml b/examples/cross-namespace-a2a/08-istio-authz.yaml new file mode 100644 index 000000000..9089feb43 --- /dev/null +++ b/examples/cross-namespace-a2a/08-istio-authz.yaml @@ -0,0 +1,91 @@ +# Istio runtime enforcement — belt-and-suspenders over the reconciler's +# AllowedNamespaces check. Enforces mTLS + namespace identity at the +# data plane before any packet reaches an agent pod. +# +# Prerequisites: Istio installed, sidecar injection enabled on both namespaces. +# Label namespaces: kubectl label ns team-alpha team-beta istio-injection=enabled +# +# Remove once EP-476 (OIDC/JWT auth) is merged and deployed — at that point +# the kagent gateway itself validates tokens and Istio is optional hardening. + +# ── Require mTLS inside team-beta ───────────────────────────────────────────── +apiVersion: security.istio.io/v1beta1 +kind: PeerAuthentication +metadata: + name: require-mtls + namespace: team-beta +spec: + mtls: + mode: STRICT # Plaintext connections to specialist pods are rejected +--- +# ── AuthorizationPolicy: only kagent controller may call specialist ──────────── +# Identifies caller by its mTLS SPIFFE identity (ServiceAccount + namespace), +# not by IP or header — spoofing-resistant. +apiVersion: security.istio.io/v1beta1 +kind: AuthorizationPolicy +metadata: + name: specialist-allow-kagent-only + namespace: team-beta +spec: + selector: + matchLabels: + app.kubernetes.io/name: specialist-agent + action: ALLOW + rules: + - from: + - source: + # SPIFFE: spiffe://cluster.local/ns/kagent/sa/kagent + principals: + - "cluster.local/ns/kagent/sa/kagent" + to: + - operation: + ports: ["8080"] + paths: ["/api/a2a/*", "/.well-known/agent.json"] +--- +# Default-deny everything not matched by an ALLOW policy above. +apiVersion: security.istio.io/v1beta1 +kind: AuthorizationPolicy +metadata: + name: default-deny + namespace: team-beta +spec: + selector: + matchLabels: + app.kubernetes.io/name: specialist-agent + action: DENY + rules: + - from: + - source: + notPrincipals: + - "cluster.local/ns/kagent/sa/kagent" + +# ── Require mTLS inside team-alpha ──────────────────────────────────────────── +--- +apiVersion: security.istio.io/v1beta1 +kind: PeerAuthentication +metadata: + name: require-mtls + namespace: team-alpha +spec: + mtls: + mode: STRICT +--- +# Only kagent may call the orchestrator. +apiVersion: security.istio.io/v1beta1 +kind: AuthorizationPolicy +metadata: + name: orchestrator-allow-kagent-only + namespace: team-alpha +spec: + selector: + matchLabels: + app.kubernetes.io/name: orchestrator-agent + action: ALLOW + rules: + - from: + - source: + principals: + - "cluster.local/ns/kagent/sa/kagent" + to: + - operation: + ports: ["8080"] diff --git a/examples/cross-namespace-a2a/09-pod-security.yaml b/examples/cross-namespace-a2a/09-pod-security.yaml new file mode 100644 index 000000000..362abd268 --- /dev/null +++ b/examples/cross-namespace-a2a/09-pod-security.yaml @@ -0,0 +1,26 @@ +# Pod Security Admission (built-in since k8s 1.25) — enforces restricted +# profile on both namespaces. Agents run non-root, read-only FS, no privilege +# escalation, no host namespaces. +# +# Apply the labels to the namespace objects (patch form shown here so this +# file can be applied independently of 00-namespaces.yaml). + +apiVersion: v1 +kind: Namespace +metadata: + name: team-alpha + labels: + pod-security.kubernetes.io/enforce: restricted + pod-security.kubernetes.io/enforce-version: latest + pod-security.kubernetes.io/audit: restricted + pod-security.kubernetes.io/warn: restricted +--- +apiVersion: v1 +kind: Namespace +metadata: + name: team-beta + labels: + pod-security.kubernetes.io/enforce: restricted + pod-security.kubernetes.io/enforce-version: latest + pod-security.kubernetes.io/audit: restricted + pod-security.kubernetes.io/warn: restricted diff --git a/examples/cross-namespace-a2a/10-gatekeeper-policy.yaml b/examples/cross-namespace-a2a/10-gatekeeper-policy.yaml new file mode 100644 index 000000000..620d4840a --- /dev/null +++ b/examples/cross-namespace-a2a/10-gatekeeper-policy.yaml @@ -0,0 +1,83 @@ +# OPA Gatekeeper — admission-level enforcement of cross-namespace A2A rules. +# Belt-and-suspenders over the kagent reconciler's AllowedNamespaces check. +# If the reconciler is bypassed (e.g. direct etcd write, operator bug), this +# webhook blocks the Agent at admission before it reaches the API server store. +# +# Prerequisites: gatekeeper-system installed (helm install gatekeeper +# opa/gatekeeper --namespace gatekeeper-system --create-namespace) + +# ── ConstraintTemplate: cross-namespace agent reference policy ──────────────── +apiVersion: templates.gatekeeper.sh/v1 +kind: ConstraintTemplate +metadata: + name: kagentcrossnamespacepolicy + annotations: + description: > + Enforces that an Agent's cross-namespace tool references are only + permitted when the target Agent's allowedNamespaces permits the source. + Mirrors kagent's AllowedNamespaces logic at admission time. +spec: + crd: + spec: + names: + kind: KagentCrossNamespacePolicy + validation: + openAPIV3Schema: + type: object + properties: + allowedSourceLabels: + type: object + description: > + Label key/value pairs a source namespace must carry to be + permitted to reference agents in the target namespace. + targets: + - target: admission.k8s.gatekeeper.sh + rego: | + package kagentcrossnamespacepolicy + + violation[{"msg": msg}] { + # Only check Agent resources + input.review.object.apiVersion == "kagent.dev/v1alpha2" + input.review.object.kind == "Agent" + + # Find tools that reference an agent in a different namespace + tool := input.review.object.spec.declarative.tools[_] + agent_ref := tool.agent + agent_ref.namespace != "" + agent_ref.namespace != input.review.object.metadata.namespace + + # Check if there's no allowedNamespaces field on THIS agent + # (target agent allowedNamespaces is checked by the reconciler; + # this policy checks the source agent side — it must be in an + # approved consumer namespace) + source_ns := input.review.object.metadata.namespace + not valid_consumer_namespace(source_ns) + + msg := sprintf( + "Agent %v/%v references cross-namespace agent %v/%v but namespace %v is not an approved consumer (missing label kagent.dev/agent-consumer=true)", + [source_ns, input.review.object.metadata.name, + agent_ref.namespace, agent_ref.name, source_ns] + ) + } + + valid_consumer_namespace(ns) { + # Namespace must carry the consumer label to make cross-ns references + # This is checked against the live namespace object via data.inventory + ns_obj := data.inventory.cluster["v1"]["Namespace"][ns] + ns_obj.metadata.labels["kagent.dev/agent-consumer"] == "true" + } +--- +# ── Constraint instance: apply policy to all kagent Agent resources ─────────── +apiVersion: constraints.gatekeeper.sh/v1beta1 +kind: KagentCrossNamespacePolicy +metadata: + name: require-consumer-label-for-cross-ns +spec: + enforcementAction: deny # Change to "warn" during rollout, then "deny" + match: + kinds: + - apiGroups: ["kagent.dev"] + kinds: ["Agent"] + excludedNamespaces: + - kagent # Controller namespace is exempt + - kube-system diff --git a/examples/cross-namespace-a2a/11-cert-manager.yaml b/examples/cross-namespace-a2a/11-cert-manager.yaml new file mode 100644 index 000000000..b80cff9da --- /dev/null +++ b/examples/cross-namespace-a2a/11-cert-manager.yaml @@ -0,0 +1,94 @@ +# cert-manager — mTLS certificates for agent pods. +# Each agent gets its own leaf cert signed by a shared cluster CA. +# The kagent controller presents its own cert; mutual verification +# ensures only genuine kagent-signed clients can reach agents. +# +# Prerequisites: cert-manager installed +# helm install cert-manager jetstack/cert-manager \ +# --namespace cert-manager --create-namespace \ +# --set installCRDs=true + +# ── Cluster-wide CA issuer (self-signed bootstrap) ──────────────────────────── +# In production replace with Vault PKI or AWS PCA issuer. +apiVersion: cert-manager.io/v1 +kind: ClusterIssuer +metadata: + name: kagent-ca-issuer +spec: + selfSigned: {} +--- +# CA certificate (sign agent leaf certs with this) +apiVersion: cert-manager.io/v1 +kind: Certificate +metadata: + name: kagent-ca + namespace: kagent +spec: + isCA: true + commonName: kagent-ca + secretName: kagent-ca-tls + privateKey: + algorithm: ECDSA + size: 256 + issuerRef: + name: kagent-ca-issuer + kind: ClusterIssuer + group: cert-manager.io +--- +# Issuer that signs with the kagent CA +apiVersion: cert-manager.io/v1 +kind: Issuer +metadata: + name: kagent-ca-issuer + namespace: kagent +spec: + ca: + secretName: kagent-ca-tls + +# ── team-beta: specialist agent TLS cert ───────────────────────────────────── +--- +apiVersion: cert-manager.io/v1 +kind: Certificate +metadata: + name: specialist-agent-tls + namespace: team-beta +spec: + secretName: specialist-agent-tls + duration: 2160h # 90 days + renewBefore: 360h # Renew 15 days before expiry + commonName: specialist.team-beta.svc.cluster.local + dnsNames: + - specialist.team-beta.svc.cluster.local + - specialist.team-beta.svc + - specialist.team-beta + usages: + - server auth + - client auth + issuerRef: + name: kagent-ca-issuer + kind: ClusterIssuer + group: cert-manager.io + +# ── team-alpha: orchestrator agent TLS cert ─────────────────────────────────── +--- +apiVersion: cert-manager.io/v1 +kind: Certificate +metadata: + name: orchestrator-agent-tls + namespace: team-alpha +spec: + secretName: orchestrator-agent-tls + duration: 2160h + renewBefore: 360h + commonName: orchestrator.team-alpha.svc.cluster.local + dnsNames: + - orchestrator.team-alpha.svc.cluster.local + - orchestrator.team-alpha.svc + - orchestrator.team-alpha + usages: + - server auth + - client auth + issuerRef: + name: kagent-ca-issuer + kind: ClusterIssuer + group: cert-manager.io diff --git a/examples/cross-namespace-a2a/12-external-secrets.yaml b/examples/cross-namespace-a2a/12-external-secrets.yaml new file mode 100644 index 000000000..befc76de3 --- /dev/null +++ b/examples/cross-namespace-a2a/12-external-secrets.yaml @@ -0,0 +1,134 @@ +# External Secrets Operator — automatic rotation of API keys and delegation +# tokens from a backend secret store (Vault shown; swap provider for AWS +# Secrets Manager, GCP Secret Manager, Azure Key Vault as needed). +# +# Prerequisites: +# helm install external-secrets external-secrets/external-secrets \ +# --namespace external-secrets --create-namespace +# +# Vault setup (one-time): +# vault kv put secret/kagent/team-beta/openai api-key=sk-... +# vault kv put secret/kagent/shared/delegation-token token= + +# ── SecretStore: team-beta reads its own Vault path ────────────────────────── +apiVersion: external-secrets.io/v1beta1 +kind: SecretStore +metadata: + name: vault-backend + namespace: team-beta +spec: + provider: + vault: + server: "https://vault.your-cluster.internal:8200" + path: "secret" + version: "v2" + auth: + kubernetes: + mountPath: "kubernetes" + role: "team-beta-agent" + serviceAccountRef: + name: specialist-agent +--- +# Sync openai API key — rotates every 1h; ESO reconciles automatically +apiVersion: external-secrets.io/v1beta1 +kind: ExternalSecret +metadata: + name: openai-secret + namespace: team-beta +spec: + refreshInterval: 1h + secretStoreRef: + name: vault-backend + kind: SecretStore + target: + name: openai-secret + creationPolicy: Owner + deletionPolicy: Retain + data: + - secretKey: api-key + remoteRef: + key: kagent/team-beta/openai + property: api-key +--- +# Sync delegation token — rotated daily from Vault +apiVersion: external-secrets.io/v1beta1 +kind: ExternalSecret +metadata: + name: specialist-delegation-token + namespace: team-beta +spec: + refreshInterval: 24h + secretStoreRef: + name: vault-backend + kind: SecretStore + target: + name: specialist-delegation-token + creationPolicy: Owner + deletionPolicy: Retain + data: + - secretKey: token + remoteRef: + key: kagent/shared/delegation-token + property: token + +# ── SecretStore: team-alpha reads its own Vault path ───────────────────────── +--- +apiVersion: external-secrets.io/v1beta1 +kind: SecretStore +metadata: + name: vault-backend + namespace: team-alpha +spec: + provider: + vault: + server: "https://vault.your-cluster.internal:8200" + path: "secret" + version: "v2" + auth: + kubernetes: + mountPath: "kubernetes" + role: "team-alpha-agent" + serviceAccountRef: + name: orchestrator-agent +--- +apiVersion: external-secrets.io/v1beta1 +kind: ExternalSecret +metadata: + name: openai-secret + namespace: team-alpha +spec: + refreshInterval: 1h + secretStoreRef: + name: vault-backend + kind: SecretStore + target: + name: openai-secret + creationPolicy: Owner + deletionPolicy: Retain + data: + - secretKey: api-key + remoteRef: + key: kagent/team-alpha/openai + property: api-key +--- +# Orchestrator reads the SAME shared delegation token path as the specialist. +# Vault ensures both sides always have the current value after rotation. +apiVersion: external-secrets.io/v1beta1 +kind: ExternalSecret +metadata: + name: specialist-delegation-token + namespace: team-alpha +spec: + refreshInterval: 24h + secretStoreRef: + name: vault-backend + kind: SecretStore + target: + name: specialist-delegation-token + creationPolicy: Owner + deletionPolicy: Retain + data: + - secretKey: token + remoteRef: + key: kagent/shared/delegation-token + property: token diff --git a/examples/cross-namespace-a2a/13-audit-policy.yaml b/examples/cross-namespace-a2a/13-audit-policy.yaml new file mode 100644 index 000000000..96e0e6af1 --- /dev/null +++ b/examples/cross-namespace-a2a/13-audit-policy.yaml @@ -0,0 +1,66 @@ +# Kubernetes audit policy targeting kagent CRDs and A2A gateway paths. +# Mount this at the API server's --audit-policy-file flag. +# Pair with --audit-log-path and ship logs to your SIEM (Loki, Splunk, etc.) +# +# Captures: +# - All write operations on kagent Agent/ModelConfig CRDs (who changed what) +# - Cross-namespace references (reconciler decisions surfaced via events) +# - Secret reads in agent namespaces (detect unexpected access) +# - ServiceAccount token requests (detect identity escalation) + +apiVersion: audit.k8s.io/v1 +kind: Policy +rules: + # ── Level: RequestResponse for all kagent CRD mutations ─────────────────── + # Full request + response body so every Agent spec change is auditable. + - level: RequestResponse + verbs: ["create", "update", "patch", "delete"] + resources: + - group: "kagent.dev" + resources: + - agents + - modelconfigs + - remotemcpservers + omitStages: [] + + # ── Level: Request for kagent CRD reads ─────────────────────────────────── + # Who is reading agent configs — useful for detecting reconnaissance. + - level: Request + verbs: ["get", "list", "watch"] + resources: + - group: "kagent.dev" + resources: ["agents", "modelconfigs"] + + # ── Level: Request for Secret reads in agent namespaces ─────────────────── + # Detects unexpected principals reading API keys or delegation tokens. + - level: Request + verbs: ["get", "list"] + resources: + - group: "" + resources: ["secrets"] + namespaces: ["team-alpha", "team-beta"] + + # ── Level: RequestResponse for ServiceAccount token requests ────────────── + # Detects workloads requesting tokens for foreign service accounts. + - level: RequestResponse + verbs: ["create"] + resources: + - group: "" + resources: ["serviceaccounts/token"] + + # ── Level: Metadata for all other operations in agent namespaces ────────── + - level: Metadata + namespaces: ["team-alpha", "team-beta"] + + # ── Drop noisy health/readiness checks ──────────────────────────────────── + - level: None + users: ["system:serviceaccount:kube-system:generic-garbage-collector"] + - level: None + nonResourceURLs: + - "/healthz*" + - "/readyz*" + - "/livez*" + - "/metrics" + + # ── Default: Metadata for everything else ───────────────────────────────── + - level: Metadata diff --git a/examples/cross-namespace-a2a/README.md b/examples/cross-namespace-a2a/README.md new file mode 100644 index 000000000..a058c784e --- /dev/null +++ b/examples/cross-namespace-a2a/README.md @@ -0,0 +1,140 @@ +# Cross-Namespace Agent-to-Agent Communication + +Proves A2A routing between agents in different Kubernetes namespaces through +the kagent Agent Gateway, with a complete production security governance stack. + +## Scenario + +``` +team-alpha/orchestrator → kagent gateway (:8083) → team-beta/specialist +``` + +- `team-alpha`: orchestrator delegates math problems to the specialist +- `team-beta`: specialist accepts delegations only from `team-alpha` + +## Security governance — layered model + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ Layer 1 — Admission (OPA Gatekeeper) │ +│ Blocks Agent CRDs with cross-ns refs from non-consumer namespaces │ +│ at the API server webhook, before etcd write. │ +├─────────────────────────────────────────────────────────────────────┤ +│ Layer 2 — Reconciler (kagent AllowedNamespaces) │ +│ AllowsNamespace() check at reconcile time. Target agent opts in │ +│ via spec.allowedNamespaces selector. Mirrors Gateway API pattern. │ +├─────────────────────────────────────────────────────────────────────┤ +│ Layer 3 — Network (NetworkPolicy + Istio mTLS) │ +│ Default-deny ingress on both namespaces. Only kagent controller │ +│ SPIFFE identity may open connections to agent pods (Istio │ +│ AuthorizationPolicy). Direct pod-to-pod A2A is impossible. │ +├─────────────────────────────────────────────────────────────────────┤ +│ Layer 4 — Identity (RBAC + cert-manager) │ +│ Each agent ServiceAccount reads only its own namespace Secrets. │ +│ Leaf TLS certs signed by cluster CA; rotated by cert-manager. │ +├─────────────────────────────────────────────────────────────────────┤ +│ Layer 5 — Token (headersFrom + External Secrets) │ +│ Delegation token injected as X-Agent-Token from namespace-local │ +│ Secret. Vault rotates both sides via ExternalSecret every 24h. │ +├─────────────────────────────────────────────────────────────────────┤ +│ Layer 6 — Audit (K8s audit policy → SIEM) │ +│ RequestResponse on all kagent CRD mutations. Request on Secret │ +│ reads. Feeds Loki/Splunk for compliance trail. │ +└─────────────────────────────────────────────────────────────────────┘ +``` + +### Current auth gaps (dev mode) + +| Component | Dev default | Production fix | +|---|---|---| +| `UnsecureAuthenticator` | Trusts `X-User-Id` header — spoofable | Apply EP-476 OIDC/JWT when merged | +| `NoopAuthorizer` | No authz enforcement in kagent gateway | Istio `AuthorizationPolicy` (file 08) until EP-476 | +| Token validation | `X-Agent-Token` forwarded but not validated by specialist | Validate in agent systemMessage prompt + EP-476 JWT | + +## File layout + +| File | Purpose | +|---|---| +| `00-namespaces.yaml` | Namespaces with consumer/provider labels | +| `01-rbac.yaml` | ServiceAccounts, Roles, Bindings (least-privilege) | +| `02-network-policy.yaml` | Default-deny + allow kagent-only ingress/egress | +| `03-secrets.yaml` | API keys + delegation tokens (replace values before apply) | +| `04-model-configs.yaml` | ModelConfig CRDs per namespace | +| `05-specialist-agent.yaml` | team-beta specialist with `allowedNamespaces` selector | +| `06-orchestrator-agent.yaml` | team-alpha orchestrator with cross-ns agent tool + `headersFrom` | +| `07-verify.sh` | Smoke test: routing, delegation, security rejection | +| `08-istio-authz.yaml` | Istio `PeerAuthentication` (mTLS STRICT) + `AuthorizationPolicy` | +| `09-pod-security.yaml` | Pod Security Admission restricted profile on both namespaces | +| `10-gatekeeper-policy.yaml` | OPA `ConstraintTemplate` + `Constraint` for admission-level enforcement | +| `11-cert-manager.yaml` | cert-manager `Certificate` resources for agent mTLS | +| `12-external-secrets.yaml` | ESO `ExternalSecret` for Vault-backed key rotation | +| `13-audit-policy.yaml` | K8s audit policy targeting kagent CRDs and agent namespace Secrets | + +## Prerequisites + +**Core (required for basic A2A):** +```bash +# kagent installed +helm install kagent kagent/kagent --namespace kagent --create-namespace +``` + +**Production governance (optional layers — apply incrementally):** +```bash +# Istio (layer 3 runtime) +istioctl install --set profile=default +kubectl label ns team-alpha team-beta istio-injection=enabled + +# OPA Gatekeeper (layer 1 admission) +helm install gatekeeper opa/gatekeeper --namespace gatekeeper-system --create-namespace + +# cert-manager (layer 4 TLS) +helm install cert-manager jetstack/cert-manager \ + --namespace cert-manager --create-namespace --set installCRDs=true + +# External Secrets Operator (layer 5 rotation) +helm install external-secrets external-secrets/external-secrets \ + --namespace external-secrets --create-namespace +``` + +## Apply + +```bash +# Core stack (layers 1-5 baseline) +kubectl apply -f examples/cross-namespace-a2a/00-namespaces.yaml +kubectl apply -f examples/cross-namespace-a2a/01-rbac.yaml +kubectl apply -f examples/cross-namespace-a2a/02-network-policy.yaml +kubectl apply -f examples/cross-namespace-a2a/03-secrets.yaml # update values first +kubectl apply -f examples/cross-namespace-a2a/04-model-configs.yaml +kubectl apply -f examples/cross-namespace-a2a/05-specialist-agent.yaml +kubectl apply -f examples/cross-namespace-a2a/06-orchestrator-agent.yaml + +# Production governance (requires prerequisites above) +kubectl apply -f examples/cross-namespace-a2a/08-istio-authz.yaml +kubectl apply -f examples/cross-namespace-a2a/09-pod-security.yaml +kubectl apply -f examples/cross-namespace-a2a/10-gatekeeper-policy.yaml +kubectl apply -f examples/cross-namespace-a2a/11-cert-manager.yaml +kubectl apply -f examples/cross-namespace-a2a/12-external-secrets.yaml +# 13-audit-policy.yaml: mount at API server --audit-policy-file (control plane config) + +# Verify +bash examples/cross-namespace-a2a/07-verify.sh +``` + +## What the verify script proves + +1. Orchestrator agent card reachable via gateway +2. Specialist agent card reachable via gateway (cross-namespace) +3. Math task delegated from orchestrator → specialist → response returned +4. Rogue agent in unlabelled namespace rejected at reconcile/admission +5. Direct pod-to-pod call blocked by NetworkPolicy + +## Roadmap to full compliance + +1. **EP-476 merge** — replace `UnsecureAuthenticator` with OIDC JWT validation. + Once live, remove Istio `AuthorizationPolicy` workaround (keep mTLS). +2. **Vault PKI issuer** — replace `selfSigned` ClusterIssuer in `11-cert-manager.yaml` + with `vault` issuer for auditable certificate lifecycle. +3. **SIEM integration** — ship `13-audit-policy.yaml` events to Loki/Splunk and + set alerts on unexpected Secret reads and cross-namespace Agent mutations. +4. **Token validation prompt** — until EP-476, add validation logic to the + specialist's systemMessage to reject requests missing a valid `X-Agent-Token`.