From dd31fa4c1ef39e52a9dbd3ffd808789d201b7dc4 Mon Sep 17 00:00:00 2001 From: Anatoly Koyfman Date: Mon, 3 Aug 2026 08:44:52 +0000 Subject: [PATCH 01/42] opa enabale and diable scripts fix Signed-off-by: Anatoly Koyfman --- scripts/opa-kind-enable.sh | 3 ++- scripts/opa-kind-restore.sh | 20 +++++++++++++------- 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/scripts/opa-kind-enable.sh b/scripts/opa-kind-enable.sh index c713ca19..3da6e25f 100755 --- a/scripts/opa-kind-enable.sh +++ b/scripts/opa-kind-enable.sh @@ -76,7 +76,7 @@ OVERLAY_FILE="$(mktemp /tmp/opa-kind-enable-overlay.XXXXXX.yaml)" trap 'rm -f "$OVERLAY_FILE"' EXIT echo "==> Step 1/5: deploying bundle-service (${OPERATOR_DIR})" -( cd "$OPERATOR_DIR" && ./hack/bundle-service-kind.sh "$CLUSTER_NAME" "$RELEASE_NAMESPACE" ) +( cd "$OPERATOR_DIR" && ./operator/hack/bundle-service-kind.sh "$CLUSTER_NAME" "$RELEASE_NAMESPACE" ) kubectl get pods -n "$RELEASE_NAMESPACE" -l app=bundle-service echo "==> Step 2/5: building + loading authbridge-proxy (${IMAGE_TAG}) via ${CONTAINER_RUNTIME}" @@ -123,6 +123,7 @@ pipeline: | YAML echo "==> Step 4/5: helm upgrade (base values.yaml + overlay — base file not modified)" +( cd "$CHART_DIR" && helm dependency build ) helm upgrade "$RELEASE_NAME" "$CHART_DIR" -n "$RELEASE_NAMESPACE" \ -f "$VALUES_FILE" \ -f "$OVERLAY_FILE" \ diff --git a/scripts/opa-kind-restore.sh b/scripts/opa-kind-restore.sh index f28ea8f3..6f9b1c3e 100755 --- a/scripts/opa-kind-restore.sh +++ b/scripts/opa-kind-restore.sh @@ -8,18 +8,19 @@ # never wrote to values.yaml, "restoring" it is just re-running helm upgrade # against that same file with no overlay on top. # -# Note: like the runbook's documented rollback, this omits the --set flags -# used by the enable step (openshift, featureFlags.agentSandbox, the local -# image override). Without --reuse-values, Helm falls back to the chart's -# own defaults for anything not passed here — if your cluster relies on -# those --set values for reasons unrelated to OPA, re-add them or pass -# --reuse-values instead. +# Drops the pipeline overlay (-f OVERLAY_FILE) but keeps the same +# cluster-shape --set flags as the enable step (openshift, featureFlags. +# agentSandbox, the local image override) — those describe the Kind +# cluster/local-image setup, not the OPA overlay, and the chart's own +# defaults assume OpenShift (openshift: true), so dropping them breaks +# the upgrade on Kind (see mcp-gateway.yaml's openshiftDomain check). # # Env vars: # ROSSOCTL_DIR path to the rossoctl/rossoctl repo clone (the chart) # RELEASE_NAME helm release name (default: rossoctl) # RELEASE_NAMESPACE namespace the chart is installed in (default: rossoctl-system) # AGENT_NAMESPACE namespace to restart agent pods in (default: team1) +# IMAGE_TAG local authbridge-proxy image tag (default: localhost/authbridge:local) set -euo pipefail @@ -30,6 +31,7 @@ ROSSOCTL_DIR="${ROSSOCTL_DIR:-$(cd "$CORTEX_DIR/../rossoctl" 2>/dev/null && pwd RELEASE_NAME="${RELEASE_NAME:-rossoctl}" RELEASE_NAMESPACE="${RELEASE_NAMESPACE:-rossoctl-system}" AGENT_NAMESPACE="${AGENT_NAMESPACE:-team1}" +IMAGE_TAG="${IMAGE_TAG:-localhost/authbridge:local}" if [ -z "$ROSSOCTL_DIR" ] || [ ! -d "$ROSSOCTL_DIR" ]; then echo "ERROR: Set ROSSOCTL_DIR to point to your rossoctl/rossoctl repo clone" >&2 @@ -44,8 +46,12 @@ if [ ! -f "$VALUES_FILE" ]; then fi echo "==> Restoring original pipeline from ${VALUES_FILE} (no OPA/parser overlay)" +( cd "$CHART_DIR" && helm dependency build ) helm upgrade "$RELEASE_NAME" "$CHART_DIR" -n "$RELEASE_NAMESPACE" \ - -f "$VALUES_FILE" + -f "$VALUES_FILE" \ + --set openshift=false \ + --set featureFlags.agentSandbox=true \ + --set operator-chart.defaults.images.authbridge="$IMAGE_TAG" echo "==> Restarting authbridge pods in ${AGENT_NAMESPACE}" kubectl delete pods -n "$AGENT_NAMESPACE" -l rossoctl.io/type=agent From 90410721948c505452660d6f4cc7f8b573d05176 Mon Sep 17 00:00:00 2001 From: Oleg Blinder Date: Mon, 3 Aug 2026 16:43:03 +0300 Subject: [PATCH 02/42] docs: Add Policy Guardrails Agent component to AIAC spec Define a new ninth component in the Policy/Domain Knowledge RAG Pod: a pre-flight, fail-closed verification gate between the RAG Ingest Service and ChromaDB. Its concrete check set is left TBD; this change fixes the component's architectural placement and its interoperability contract with the RAG Ingest Service and ChromaDB (pod-local only, one call per document, all-or-nothing rejection, no Event Broker interaction). Signed-off-by: Oleg Blinder --- aiac/docs/specs/PRD.md | 65 +++++++++++++------ .../components/policy-guardrails-agent.md | 59 +++++++++++++++++ .../specs/components/rag-ingest-service.md | 17 ++++- .../specs/components/rag-knowledge-base.md | 5 +- 4 files changed, 122 insertions(+), 24 deletions(-) create mode 100644 aiac/docs/specs/components/policy-guardrails-agent.md diff --git a/aiac/docs/specs/PRD.md b/aiac/docs/specs/PRD.md index 339ee2a5..10f9ee2a 100644 --- a/aiac/docs/specs/PRD.md +++ b/aiac/docs/specs/PRD.md @@ -102,7 +102,7 @@ Manually granted entitlements are flagged as policy-agnostic and surfaced during ## 5. Architecture Overview -Eight components across five Kubernetes Pods plus a Python library layer, all implemented in Python 3.12. External dependencies: Keycloak Admin API, an LLM API, and an embedding API. The Keycloak SPI listener is defined in a separate PRD. +Nine components across five Kubernetes Pods plus a Python library layer, all implemented in Python 3.12. External dependencies: Keycloak Admin API, an LLM API, and an embedding API. The Keycloak SPI listener is defined in a separate PRD. ### Component Summary @@ -113,9 +113,10 @@ Eight components across five Kubernetes Pods plus a Python library layer, all im | 3 | **Policy Store** | REST service that owns an in-memory `PolicyModel` cache backed by SQLite as the authoritative structured policy store. Enables the Policy Computation Engine to read current `AgentPolicyModel` state for additive merging. Deployed as a dedicated single-replica StatefulSet (`aiac-policy-store`) at `:7074`. Python library: `aiac.policy.store.library`. | | 4 | **Policy Computation Engine** | Pure Python library module (`aiac.policy.computation`). No service, no Kubernetes deployment. Receives `list[PolicyRule]` from AIAC Agent sub-agents, queries IdP to resolve owning services, additively merges rules into `AgentPolicyModel` objects in the Policy Store, and pushes the updated `PolicyModel` to the PDP Policy Writer. Single entry point: `compute_and_apply(rules)`. | | 5 | **Policy and Domain Knowledge RAG** | ChromaDB vector store holding the access control policy and domain knowledge in persistent, queryable form, populated via a co-located RAG Ingest Service. | -| 6 | **Event Broker** | NATS JetStream pod that decouples event producers (Keycloak SPI listener, RAG Ingest Service) from the AIAC Agent. Provides durable, at-least-once delivery with automatic replay on Agent pod restart. Competing consumer model ensures each event is processed exactly once. | -| 7 | **AIAC Agent** | LangGraph-based AI agent triggered by Event Broker subscriptions (`aiac.apply.>` subjects) and directly by the operator (`rebuild` only). Retrieves the current policy from the RAG store, interprets it against live PDP state, and applies the required policy changes immediately. | -| 8 | **Python library** | Python API library provides typed access to IdP and policy services via `aiac.idp.configuration`, `aiac.policy.model`, `aiac.policy.store.library`, `aiac.pdp.policy.library`, and `aiac.policy.computation` modules backed by generic Pydantic models. | +| 6 | **Policy Guardrails Agent** | Verification gate co-located with ChromaDB and the RAG Ingest Service in the RAG Pod. Every document is checked before the RAG Ingest Service writes it to ChromaDB. Reachable only on the RAG Pod's loopback network — not exposed on the RAG Pod's ClusterIP Service. Check set TBD. | +| 7 | **Event Broker** | NATS JetStream pod that decouples event producers (Keycloak SPI listener, RAG Ingest Service) from the AIAC Agent. Provides durable, at-least-once delivery with automatic replay on Agent pod restart. Competing consumer model ensures each event is processed exactly once. | +| 8 | **AIAC Agent** | LangGraph-based AI agent triggered by Event Broker subscriptions (`aiac.apply.>` subjects) and directly by the operator (`rebuild` only). Retrieves the current policy from the RAG store, interprets it against live PDP state, and applies the required policy changes immediately. | +| 9 | **Python library** | Python API library provides typed access to IdP and policy services via `aiac.idp.configuration`, `aiac.policy.model`, `aiac.policy.store.library`, `aiac.pdp.policy.library`, and `aiac.policy.computation` modules backed by generic Pydantic models. | ### High-level architecture @@ -162,7 +163,12 @@ Eight components across five Kubernetes Pods plus a Python library layer, all im │ ▼ │ │ ┌─────────────────────┐ ┌─────────────────────────┐ │ │ │ RAG Ingest Service │──►│ ChromaDB (vector store) │ │ -│ └─────────────────────┘ └─────────────────────────┘ │ +│ └──────────┬──────────┘ └─────────────▲───────────┘ │ +│ │ (verify) │ (context) │ +│ ▼ │ │ +│ ┌─────────────────────────┐ │ │ +│ │ Policy Guardrails Agent │─────────────┘ │ +│ └─────────────────────────┘ │ └─────────────────────────────────────────────────────────┘ ``` @@ -228,21 +234,22 @@ All inter-pod traffic is Kubernetes ClusterIP. External access is exclusively vi │ 1. POST /ingest/policy/{text|file|url} ▼ RAG Ingest Service - │ 2. upsert documents ──► ChromaDB - │ 3. publish aiac.apply.policy.build + │ 2. verify document (pre-flight, all-or-nothing) ──► Policy Guardrails Agent + │ 3. upsert documents ──► ChromaDB + │ 4. publish aiac.apply.policy.build ▼ NATS JetStream - │ 4. deliver event + │ 5. deliver event ▼ AIAC Agent - │ 5. GET /roles, /services, /assignments ──► IdP Configuration Service ──► Keycloak Admin REST - │ 6. retrieve full policy context ──► ChromaDB - │ 7. [LLM] compute list[PolicyRule] delta against current OPA state - │ 8. compute_and_apply(rules) ──► Policy Computation Engine + │ 6. GET /roles, /services, /assignments ──► IdP Configuration Service ──► Keycloak Admin REST + │ 7. retrieve full policy context ──► ChromaDB + │ 8. [LLM] compute list[PolicyRule] delta against current OPA state + │ 9. compute_and_apply(rules) ──► Policy Computation Engine │ ├── get_services_by_role / get_services_by_scope ──► IdP Configuration Service │ ├── get_agent_policy / apply_agent_policy ──► Policy Store │ └── apply_policy ──► PDP Policy Writer ──► AuthorizationPolicy CR - │ 9. ACK message + │ 10. ACK message ▼ NATS JetStream (message removed from pending) ``` @@ -280,8 +287,9 @@ All inter-pod traffic is Kubernetes ClusterIP. External access is exclusively vi | `aiac.policy.model` | `aiac.pdp.policy.library`, `aiac.policy.store.library`, `aiac.policy.computation`, AIAC Agent | — | Pydantic model definitions for policy entities (PolicyRule, AgentPolicyModel, PolicyModel) | | `aiac.pdp.policy.library` | `aiac.policy.computation` | PDP Policy Writer — OPA (HTTP) | None (writes Rego policy rules to AuthorizationPolicy CR) | | `aiac.policy.store.library` | `aiac.policy.computation` | Policy Store (HTTP) | `AgentPolicyModel` / `PolicyModel` on read; None on write/delete | -| ChromaDB | RAG Ingest Service (writes), AIAC Agent (reads) | — | Policy and domain knowledge vectors | -| RAG Ingest Service | Developer (via `kubectl port-forward`) | ChromaDB, Embedding API, Event Broker | — | +| ChromaDB | RAG Ingest Service (writes), Policy Guardrails Agent (reads, context), AIAC Agent (reads) | — | Policy and domain knowledge vectors | +| RAG Ingest Service | Developer (via `kubectl port-forward`) | ChromaDB, Policy Guardrails Agent, Embedding API, Event Broker | — | +| Policy Guardrails Agent (in RAG Pod) | RAG Ingest Service | ChromaDB (context reads) | Verdict per document — contract TBD | | Event Broker (NATS JetStream) | Keycloak SPI listener, RAG Ingest Service (publishers); NATS JetStream (DLQ routing) | — | Durable event delivery to AIAC Agent; DLQ on max retries | | AIAC Agent | Event Broker (NATS consumer), operator (`/apply/policy/rebuild` HTTP direct) | Service Onboarding / Policy Update / Role Update orchestrators → `aiac.idp.configuration.api`, `aiac.policy.computation`, ChromaDB, LLM API, Kubernetes API | Rego policy written to AuthorizationPolicy CR; structured policy written to Policy Store (SQLite); provisioned service permissions/scopes (onboarding) | @@ -299,7 +307,10 @@ All inter-pod traffic is Kubernetes ClusterIP. External access is exclusively vi - **PDP services bind to `0.0.0.0`.** Exposed as Kubernetes ClusterIP Services so that the Agent Pod can reach them over the cluster network. - **RBAC via OPA Rego rules.** AIAC manages role → service permission mappings by writing `AgentPolicyModel` instances to the `AuthorizationPolicy` CR. Each agent pod's OPA plugin fetches its packages from the CR at startup. - **RAG Pod is a StatefulSet with persistent ChromaDB storage.** ChromaDB data is stored on a 1 Gi `ReadWriteOnce` PersistentVolumeClaim mounted at `/chroma/chroma` (ChromaDB default). On pod recreation, the StatefulSet rebinds the same PVC and ChromaDB resumes from persisted state without re-ingestion. The pod runs a single replica. -- **RAG Pod runs ChromaDB and RAG Ingest Service together.** Exposed as `aiac-rag-service` on ports 8000 (ChromaDB default) and 7073 (RAG Ingest Service). +- **RAG Pod runs ChromaDB, RAG Ingest Service, and the Policy Guardrails Agent together.** Exposed as `aiac-rag-service` on ports 8000 (ChromaDB default) and 7073 (RAG Ingest Service). +- **The Policy Guardrails Agent is not exposed on the RAG Pod's ClusterIP Service.** It is reachable only on the pod's loopback network (`localhost:7075`), making the RAG Ingest Service structurally the only caller. +- **Guardrails verification is a synchronous, per-document, pre-flight, fail-closed gate.** The RAG Ingest Service calls the Policy Guardrails Agent once per document before making any ChromaDB mutation; any rejection fails the whole request with nothing written, and an unreachable or erroring agent is treated the same as a rejection unless verification is explicitly disabled via `AIAC_GUARDRAILS_ENABLED`. +- **The Policy Guardrails Agent has no Event Broker involvement.** It neither publishes nor consumes NATS subjects; the RAG Ingest Service's existing `aiac.apply.policy.build` publish is unchanged. - **AIAC Agent is stateless.** Changes are applied immediately on trigger — no pending session or human confirmation step. - **Event Broker decouples all automated triggers from the Agent.** The Keycloak SPI listener and RAG Ingest Service publish to NATS subjects; the Agent subscribes as a durable competing consumer. This removes all direct dependencies between trigger sources and the Agent. - **`rebuild` bypasses the Event Broker.** It is an operator-only command issued directly via HTTP (`kubectl port-forward`). It is never published to NATS and has no NATS listener. @@ -430,7 +441,15 @@ FastAPI service (`0.0.0.0:7073`) co-located with ChromaDB. Thirteen collection-p --- -### 7.10 Keycloak SPI Listener +### 7.10 Policy Guardrails Agent + +FastAPI service (`0.0.0.0:7075`) co-located with ChromaDB and the RAG Ingest Service in the **RAG Pod**. Verifies each document before the RAG Ingest Service writes it to ChromaDB — one verification call per document, pre-flight (before any ChromaDB mutation), all-or-nothing (any rejection fails the whole ingest request, nothing is written), fail-closed (an unreachable or erroring agent is treated as a rejection unless verification is disabled via `AIAC_GUARDRAILS_ENABLED`). Reachable only on the RAG Pod's loopback network — not exposed on `aiac-rag-service`, so the RAG Ingest Service is structurally the only caller. May read ChromaDB for evaluation context. Neither publishes nor consumes Event Broker subjects. The concrete check set is **TBD**. + +**Full spec:** [components/policy-guardrails-agent.md](components/policy-guardrails-agent.md) + +--- + +### 7.11 Keycloak SPI Listener A custom Keycloak Event Listener SPI (Java) that listens to Keycloak's internal event bus and translates entity-scoped events into NATS publish calls to the Event Broker. The AIAC Agent subject schema is authoritative; the SPI PRD references it. @@ -456,7 +475,7 @@ Four separate manifest files: | `aiac/k8s/policy-store-statefulset.yaml` | `aiac-policy-store` StatefulSet (Policy Store container) + `volumeClaimTemplate` (1 Gi, `ReadWriteOnce`, mounted at `/data`) + headless Service + `aiac-policy-store-service:7074` ClusterIP Service | | `aiac/k8s/agent-deployment.yaml` | Agent Pod Deployment (AIAC Agent container) + ClusterIP Service _(Phase 1; `aiac-init` init container added in Phase 2, issue 4.21)_ | | `aiac/k8s/event-broker-deployment.yaml` _(pending)_ | Event Broker Pod Deployment (NATS JetStream) + ClusterIP Service | -| `aiac/k8s/rag-statefulset.yaml` _(pending)_ | RAG StatefulSet (ChromaDB + RAG Ingest Service containers) + 1 Gi PVC template + ClusterIP Service | +| `aiac/k8s/rag-statefulset.yaml` _(pending)_ | RAG StatefulSet (ChromaDB + RAG Ingest Service + Policy Guardrails Agent containers) + 1 Gi PVC template + ClusterIP Service (ChromaDB + RAG Ingest Service ports only — the Policy Guardrails Agent is pod-local, not on the ClusterIP Service) | Both Interface Pod containers mount `aiac-pdp-config` (KEYCLOAK_URL, KEYCLOAK_REALM, KEYCLOAK_ADMIN_REALM) as env vars; only the IdP Configuration Service container also mounts `keycloak-admin-secret` (KEYCLOAK_ADMIN_USERNAME, KEYCLOAK_ADMIN_PASSWORD) and uses `KEYCLOAK_ADMIN_REALM` (ignoring `KEYCLOAK_REALM`). The PDP Policy Writer (`aiac-pdp-policy-opa`, the Phase 1 rego-file mock) needs no Keycloak credentials — it writes `.rego` files to `REGO_OUTPUT_DIR` (default `/rego`, an `emptyDir` volume). The Policy Store container mounts `aiac-policy-store-config` for `AGENTPOLICY_DB_PATH` (default `/data/state.db`) — no Kubernetes API access or RBAC required. @@ -482,6 +501,9 @@ docker build -f aiac/src/aiac/agent/controller/Dockerfile -t aiac-agent:latest a # Build RAG Ingest Service docker build -t aiac-rag-ingest:latest aiac/rag-ingest/ + +# Build Policy Guardrails Agent +docker build -t aiac-policy-guardrails:latest aiac/policy-guardrails/ ``` The Event Broker uses the official `nats` Docker image with JetStream enabled (`-js` flag). No custom build required. @@ -507,7 +529,7 @@ data: AIAC_CHROMADB_URL: "http://aiac-rag-service:8000" ``` -`AGENTPOLICY_DB_PATH` is absent — it belongs to `aiac-policy-store-config` (defined in `policy-store-statefulset.yaml`), not to the shared ConfigMap. +`AGENTPOLICY_DB_PATH` is absent — it belongs to `aiac-policy-store-config` (defined in `policy-store-statefulset.yaml`), not to the shared ConfigMap. Likewise, `AIAC_GUARDRAILS_URL` and `AIAC_GUARDRAILS_ENABLED` (RAG Ingest Service → Policy Guardrails Agent, both pod-local) belong to the RAG Pod's own ConfigMap, not the shared `aiac-pdp-config` — no component outside the RAG Pod calls the Policy Guardrails Agent. ### `aiac-policy-store-config` ConfigMap template @@ -543,6 +565,7 @@ Tests live in `aiac/test/`. | Event Broker NATS consumer | NATS message delivery (mock `nats-py` subscription) | Correct handler dispatched per subject; ack issued on success; no ack on handler exception | | Event Broker DLQ | NATS max redelivery exceeded | Message routed to `aiac.apply.dlq` after 5 failures | | Init container health-check | HTTP 4xx then 200 sequence; NATS TCP refused then connected | Exits 0 only after all four dependencies healthy; `add_stream` called with correct config | +| Policy Guardrails Agent endpoints | ChromaDB (context reads) | TBD — pending the verification endpoint and verdict contract | | AIAC Agent | TBD | TBD | ### Integration tests @@ -585,8 +608,8 @@ Tracking issues: the live-Keycloak pytest integration tests in `testing/5.1-inte - Base Docker image: `python:3.12-slim` - Linting: ruff (line length 120, target py312 per root `pyproject.toml`) - Commits: DCO sign-off required (`git commit -s`); use `Assisted-By` not `Co-Authored-By` -- No auth on IdP Configuration Service, PDP Policy Writer, RAG Ingest Service, or Event Broker — network isolation (ClusterIP + `kubectl port-forward`) is the access control mechanism -- IdP Configuration Service, PDP Policy Writer, Agent, RAG Ingest Service, and Event Broker are not registered in the repo's `build.yaml` CI matrix; they have independent build processes +- No auth on IdP Configuration Service, PDP Policy Writer, RAG Ingest Service, Policy Guardrails Agent, or Event Broker — network isolation (ClusterIP + `kubectl port-forward`; the Policy Guardrails Agent additionally has no ClusterIP exposure at all) is the access control mechanism +- IdP Configuration Service, PDP Policy Writer, Agent, RAG Ingest Service, Policy Guardrails Agent, and Event Broker are not registered in the repo's `build.yaml` CI matrix; they have independent build processes - `aiac/__init__.py` exists and is empty — `aiac` is a regular package, not a namespace package - NATS consumer must **await** handler completion before issuing ack — fire-and-forget (`asyncio.create_task`) is prohibited; premature ack breaks at-least-once delivery guarantees - AIAC provisioning marker: every role and client scope AIAC provisions carries the Keycloak attribute `aiac.managed` = `true`, distinguishing AIAC-provisioned entities from Keycloak's built-ins (default client scopes, `default-roles-`). Realm-role attribute values are lists (`["true"]`), client-scope values are plain strings (`"true"`). The IdP Configuration Service stamps it on create and returns full role representations so it survives reads; the Policy Computation Engine filters on it (`Role.aiac_managed` / `Scope.aiac_managed`) when embedding each agent's own roles/scopes (P2) diff --git a/aiac/docs/specs/components/policy-guardrails-agent.md b/aiac/docs/specs/components/policy-guardrails-agent.md new file mode 100644 index 00000000..6cd2ea87 --- /dev/null +++ b/aiac/docs/specs/components/policy-guardrails-agent.md @@ -0,0 +1,59 @@ +# Component PRD: Policy Guardrails Agent + +## Description + +A FastAPI verification service co-located with ChromaDB and the RAG Ingest Service in the RAG Pod. It sits between the RAG Ingest Service and ChromaDB: before any document from an ingest request is written, the RAG Ingest Service calls the Policy Guardrails Agent to obtain a verdict on that document. It is reachable only on the RAG Pod's loopback network (`localhost:7075`) — it is not exposed on the `aiac-rag-service` ClusterIP Service, so the RAG Ingest Service is structurally the only caller. + +The agent evaluates one document per call. It may query the co-located ChromaDB instance for evaluation context, including the current (pre-update) version of the same `doc_id` when one exists. + +The concrete checks the agent performs are **not yet defined** — see [Open decisions (TBD)](#open-decisions-tbd). This spec fixes the agent's place in the architecture and its contract with the RAG Ingest Service so that scope can be filled in without changing wiring. + +## Endpoints + +**TBD.** The verification route's path, request schema, and verdict/findings response model are unresolved — see [Open decisions (TBD)](#open-decisions-tbd). A `/health` endpoint is expected, following the convention used by every other AIAC service. + +## Verification contract + +These behaviors are fixed regardless of what the endpoint surface ends up looking like: + +- Invoked by the RAG Ingest Service on all 12 write endpoints (`replace` and `update`, each across `text`/`file`/`url`) for every collection slug in `AIAC_RAG_COLLECTIONS`. `DELETE /ingest/{collection}/{doc_id}` is exempt — removal introduces no new text to verify. +- Called once per document. A multi-document request (a multi-doc `replace` body, or a multipart `/file` upload with several files) results in one call per document. +- **Pre-flight**: every document in a request is verified before the RAG Ingest Service makes any ChromaDB mutation for that request. +- **All-or-nothing**: if any document in a request is rejected, the whole ingest request fails and nothing is written — the collection is left exactly as it was. This preserves the RAG Ingest Service's existing collection-level atomicity guarantee for `replace`. +- **Fail-closed**: if the agent is unreachable, times out, or errors, the RAG Ingest Service treats this as a failure and writes nothing. `AIAC_GUARDRAILS_ENABLED` (on the RAG Ingest Service) is the explicit operator off-switch — with it set to `false` the RAG Ingest Service skips verification entirely. This keeps "guardrails disabled" distinguishable from "guardrails enabled but broken." +- No Event Broker interaction. The agent neither publishes nor consumes NATS subjects. The RAG Ingest Service's existing `aiac.apply.policy.build` publish behavior is unchanged: a fail-closed rejection means the ingest request never succeeds, so the build event is simply never published for that request. + +## Configuration + +| Variable | Default | Source | +|----------|---------|--------| +| `CHROMA_URL` | `http://localhost:8000` | ConfigMap | + +Corresponding variables on the **RAG Ingest Service** side (documented in [rag-ingest-service.md](rag-ingest-service.md)): `AIAC_GUARDRAILS_URL`, `AIAC_GUARDRAILS_ENABLED`, `AIAC_GUARDRAILS_TIMEOUT_SECONDS`. + +LLM API and Secret wiring (base URL, model, API key) are deferred until the check set is defined — see [Open decisions (TBD)](#open-decisions-tbd). + +## Runtime + +- Framework: FastAPI with uvicorn +- Bind: `0.0.0.0:7075` +- Base image: `python:3.12-slim` + +## Dependencies (`requirements.txt`) + +``` +fastapi +uvicorn[standard] +httpx +chromadb +``` + +(LLM / agent framework client TBD — depends on the chosen check implementation) + +## Open decisions (TBD) + +1. **Responsibilities and check set** — what the agent actually verifies (e.g. prompt-injection / adversarial content, policy hygiene, contradiction against the existing corpus). Not yet defined. +2. **Endpoint surface and verdict model** — the verification route's path, request body shape, and how a verdict (and any findings) is represented in the response. +3. **LLM wiring** — whether checks are LLM-backed, and if so which `LLM_BASE_URL` / `LLM_MODEL` / `LLM_API_KEY`-equivalent configuration and Secret references are required. +4. **Findings persistence** — whether rejection findings are only returned synchronously in the response, or also persisted somewhere for audit. +5. **Operator override** — whether an operator can force-accept a rejected document, and if so through what path. diff --git a/aiac/docs/specs/components/rag-ingest-service.md b/aiac/docs/specs/components/rag-ingest-service.md index 5c869269..b8dee827 100644 --- a/aiac/docs/specs/components/rag-ingest-service.md +++ b/aiac/docs/specs/components/rag-ingest-service.md @@ -1,7 +1,9 @@ # Component PRD: RAG Ingest Service ## Description -A FastAPI REST service co-located with ChromaDB in the RAG Pod. Accepts knowledge documents for any configured collection, chunks and embeds them, and writes the resulting vectors into the ChromaDB instance in the same Pod. Supports both access control policies (`aiac-policies`) and org/business domain context (`aiac-domain-knowledge`) through a single collection-parameterized API surface. Developer-driven ingestion is performed via `kubectl port-forward`. +A FastAPI REST service co-located with ChromaDB and the Policy Guardrails Agent in the RAG Pod. Accepts knowledge documents for any configured collection, chunks and embeds them, and writes the resulting vectors into the ChromaDB instance in the same Pod. Supports both access control policies (`aiac-policies`) and org/business domain context (`aiac-domain-knowledge`) through a single collection-parameterized API surface. Developer-driven ingestion is performed via `kubectl port-forward`. + +Before any document is written to ChromaDB, the service calls the co-located **Policy Guardrails Agent** to verify it. See [Guardrails pre-flight verification](#guardrails-pre-flight-verification) below. After every successful ingest operation the service publishes a trigger event to the **Event Broker** (NATS JetStream) on the `aiac.apply.policy.build` subject. This causes the AIAC Agent to recompute and apply the updated policy against the live PDP state. All three ingest semantics (replace, update, delete) publish `build`; `rebuild` is an explicit operator-only command issued directly to the Agent and is never triggered by the ingest service. @@ -37,6 +39,16 @@ The `{collection}` path segment must be a slug from `AIAC_RAG_COLLECTIONS` (defa **Delete** is the only path that removes content from a collection. `/update/*` endpoints never delete as a side effect. +## Guardrails pre-flight verification + +Before writing any document to ChromaDB, the service sends it to the co-located **Policy Guardrails Agent** (`localhost:7075`) for verification. Full contract: [policy-guardrails-agent.md](policy-guardrails-agent.md). + +- Applies to all 12 write endpoints above (`replace` and `update`, each across `text`/`file`/`url`), for every collection slug. `DELETE` is exempt. +- One verification call per document. All documents in a request are verified before any ChromaDB mutation for that request occurs. +- **All-or-nothing:** if any document is rejected, the whole request fails and nothing is written to ChromaDB — the collection is left exactly as it was before the request. +- **Fail-closed:** if the Policy Guardrails Agent is unreachable, times out, or errors, the request fails and nothing is written. Set `AIAC_GUARDRAILS_ENABLED=false` to disable verification entirely (e.g. for local development or initial corpus bootstrap) — this is a deliberate operator choice, distinct from an unavailable agent. +- This gate does not change the `aiac.apply.policy.build` publish behavior below: it only happens on a successful ingest, so a fail-closed rejection means the event is simply never published for that request. + ## Post-ingest Event Broker notification After every successful ingest operation (replace, update, or delete), the service publishes `{"id": ""}` to `aiac.apply.policy.build` on the Event Broker (`NATS_URL`). The publish is non-blocking: ingest success is reported to the caller before the NATS publish completes. Publish failures are logged but do not cause the ingest endpoint to return an error. This preserves ingest availability even when the Event Broker is temporarily unavailable. @@ -67,6 +79,9 @@ The AIAC Agent's durable consumer receives the event and acknowledges it after s | `EMBEDDING_BASE_URL` | — | ConfigMap | | `EMBEDDING_MODEL` | — | ConfigMap | | `EMBEDDING_API_KEY` | — | Kubernetes Secret | +| `AIAC_GUARDRAILS_URL` | `http://localhost:7075` | ConfigMap | +| `AIAC_GUARDRAILS_ENABLED` | `true` | ConfigMap | +| `AIAC_GUARDRAILS_TIMEOUT_SECONDS` | — | ConfigMap | Adding a third collection is a configuration-only change: add a new slug to `AIAC_RAG_COLLECTIONS` and a corresponding entry in the slug→name map. No code modification required. diff --git a/aiac/docs/specs/components/rag-knowledge-base.md b/aiac/docs/specs/components/rag-knowledge-base.md index 1b4efe80..953ae9ba 100644 --- a/aiac/docs/specs/components/rag-knowledge-base.md +++ b/aiac/docs/specs/components/rag-knowledge-base.md @@ -1,7 +1,7 @@ # Component PRD: RAG Knowledge Base ## Description -A ChromaDB vector store that holds two named collections in a single instance: AIAC access control policies (`aiac-policies`) and org/business domain context (`aiac-domain-knowledge`). Deployed in a dedicated Kubernetes Pod alongside the RAG Ingest Service. The AIAC Agent retrieves relevant chunks from both collections at runtime via similarity search. +A ChromaDB vector store that holds two named collections in a single instance: AIAC access control policies (`aiac-policies`) and org/business domain context (`aiac-domain-knowledge`). Deployed in a dedicated Kubernetes Pod alongside the RAG Ingest Service and the Policy Guardrails Agent. The AIAC Agent retrieves relevant chunks from both collections at runtime via similarity search. The Policy Guardrails Agent also reads from ChromaDB, for evaluation context, as part of its pre-flight verification of documents the RAG Ingest Service is about to write (see [policy-guardrails-agent.md](policy-guardrails-agent.md)). ## Technology ChromaDB @@ -16,7 +16,7 @@ ChromaDB The legal collection set is an open extension point governed by `AIAC_RAG_COLLECTIONS` on the RAG Ingest Service. Adding a new collection is a configuration-only change (new slug + ChromaDB name in the slug→name map) with no code modification required. ## Deployment -Kubernetes **StatefulSet** in the RAG Pod, co-located with the RAG Ingest Service container. Exposed via the `aiac-rag-service` ClusterIP Service on port 8000 (ChromaDB default). Manifest: `rag-statefulset.yaml`. +Kubernetes **StatefulSet** in the RAG Pod, co-located with the RAG Ingest Service and Policy Guardrails Agent containers. Exposed via the `aiac-rag-service` ClusterIP Service on port 8000 (ChromaDB default). Manifest: `rag-statefulset.yaml`. ChromaDB runs with `IS_PERSISTENT=TRUE` and `PERSIST_DIRECTORY=/chroma/chroma`. Data is stored on a 1 Gi `ReadWriteOnce` PersistentVolumeClaim mounted at `/chroma/chroma`. On pod recreation the StatefulSet rebinds the same PVC; ChromaDB resumes from persisted state without re-ingestion. The RAG Pod runs as a single replica. @@ -25,6 +25,7 @@ ChromaDB runs with `IS_PERSISTENT=TRUE` and `PERSIST_DIRECTORY=/chroma/chroma`. | Consumer | Operation | Collection | |----------|-----------|------------| | RAG Ingest Service | Write (replace / upsert / delete) | Either collection, selected by `{collection}` slug in the request URL | +| Policy Guardrails Agent | Read (evaluation context, e.g. the pre-update version of a `doc_id`) | Either collection, matching the document under verification | | AIAC Agent `fetch_policy` | Read (similarity search, top-N chunks) | `aiac-policies` | | AIAC Agent `fetch_domain_knowledge` | Read (similarity search, top-N chunks) | `aiac-domain-knowledge` | From ad90b0a1ce7217d5bd5f608dd0358d6fee73a179 Mon Sep 17 00:00:00 2001 From: Oleg Blinder Date: Mon, 3 Aug 2026 23:22:11 +0300 Subject: [PATCH 03/42] Docs: Point AIAC issue tracking at GitHub and add engineering-skills config - CLAUDE.md: issue-tracking section describes the GitHub issues/AIAC Project layout (no migration history); adds an '## Agent skills' block wiring the Matt Pocock engineering skills. - .gitignore: drop obsolete docs/issues/ and docs/gh-issues/ entries. - docs/agents/: issue-tracker, triage-labels, and domain config the skills read from. Assisted-By: Claude (Anthropic AI) Signed-off-by: Oleg Blinder --- aiac/.gitignore | 2 -- aiac/CLAUDE.md | 36 +++++++++++++++++--- aiac/docs/agents/domain.md | 56 +++++++++++++++++++++++++++++++ aiac/docs/agents/issue-tracker.md | 44 ++++++++++++++++++++++++ aiac/docs/agents/triage-labels.md | 32 ++++++++++++++++++ 5 files changed, 164 insertions(+), 6 deletions(-) create mode 100644 aiac/docs/agents/domain.md create mode 100644 aiac/docs/agents/issue-tracker.md create mode 100644 aiac/docs/agents/triage-labels.md diff --git a/aiac/.gitignore b/aiac/.gitignore index 8e5127a3..727f0b23 100644 --- a/aiac/.gitignore +++ b/aiac/.gitignore @@ -1,7 +1,5 @@ # AIAC working artefacts (regenerated; not source of truth) -docs/issues/ docs/handoffs/ -docs/gh-issues/ # PDP policy writer integration-test scratch output (test/pdp/policy/generate_rego.py) test/pdp/policy/rego_out/ diff --git a/aiac/CLAUDE.md b/aiac/CLAUDE.md index 2ee40d87..cb04463b 100644 --- a/aiac/CLAUDE.md +++ b/aiac/CLAUDE.md @@ -15,11 +15,25 @@ When a document under `docs/specs/` contains a markdown link to another file, us ## Issue tracking -Issues are tracked as local markdown files under `docs/issues/`, not on GitHub. -Never use `gh` commands to create, update, or list issues — always read/write the local files directly. +Issues are tracked as **GitHub issues** on the `s-and-p-team/cortex` repo, +organized in the org-level **AIAC** Project (Projects v2): +. Use `gh` to read and manage +them. -`docs/issues/implementation-plan.md` — overall implementation plan. -For current issue list, `ls` the subdirectories under `docs/issues/`. +Hierarchy: the Project groups **Feature**-typed container issues — one per +component area, nested via GitHub **native sub-issues** to form the tree — over +**Task**-typed leaf issues. Every issue carries the `aiac` label plus cumulative +`area:` labels; open issues also carry a `status:` label, and the +Project's **AIAC Status** field records the issue's status. + +```bash +# list / view (filter to the AIAC set) +gh issue list -R s-and-p-team/cortex --label aiac --state all +gh issue view -R s-and-p-team/cortex +``` + +Filtered web list: + ## Issue tracking — codebase inspection policy @@ -178,3 +192,17 @@ demo workloads, which don't) and CPU/memory requests + limits. ## External references - [Kagenti Developer Guide](https://github.com/kagenti/kagenti/blob/main/docs/dev-guide.md) — upstream Kagenti dev guide: per-persona workflows (agent, tool, extensions developers, MCP gateway operators), Git/PR process, pre-commit hooks, feature flags, local Kagenti UI v2 development (React frontend + FastAPI backend, building/deploying images to Kubernetes), and HyperShift-based testing on ephemeral OpenShift clusters (cluster lifecycle, cost management, troubleshooting). + +## Agent skills + +### Issue tracker + +GitHub issues on `s-and-p-team/cortex`, filtered by the `aiac` label, tracked in the org-level AIAC Project. See `docs/agents/issue-tracker.md`. + +### Triage labels + +Five canonical roles map to `aiac-status:` labels (distinct from the board's separate `AIAC Status` Project field). See `docs/agents/triage-labels.md`. + +### Domain docs + +Single-context, scoped to `aiac/` (`CONTEXT.md` + `docs/adr/` at the `aiac/` root). See `docs/agents/domain.md`. diff --git a/aiac/docs/agents/domain.md b/aiac/docs/agents/domain.md new file mode 100644 index 00000000..83dcff9d --- /dev/null +++ b/aiac/docs/agents/domain.md @@ -0,0 +1,56 @@ +# Domain Docs + +How the engineering skills should consume this repo's domain documentation +when exploring the codebase. This is scoped to `aiac/` — treated as its own +single context, separate from other components in the `cortex` monorepo (e.g. +`authbridge/`). + +## Before exploring, read these + +- **`CONTEXT.md`** at the `aiac/` root (this directory). +- **`docs/adr/`** (under `aiac/`) — read ADRs that touch the area you're about + to work in. + +If any of these files don't exist, **proceed silently**. Don't flag their +absence; don't suggest creating them upfront. The producer skill +(`/grill-with-docs`) creates them lazily when terms or decisions actually get +resolved. + +## File structure + +``` +aiac/ +├── CONTEXT.md +├── docs/ +│ ├── adr/ +│ │ ├── 0001-....md +│ │ └── 0002-....md +│ ├── specs/ ← PRD.md + components/ (see CLAUDE.md) +│ └── agents/ ← this file and its siblings +└── src/aiac/ +``` + +## Use the glossary's vocabulary + +When your output names a domain concept (in an issue title, a refactor +proposal, a hypothesis, a test name), use the term as defined in `CONTEXT.md`. +Don't drift to synonyms the glossary explicitly avoids. + +If the concept you need isn't in the glossary yet, that's a signal — either +you're inventing language the project doesn't use (reconsider) or there's a +real gap (note it for `/grill-with-docs`). + +## Flag ADR conflicts + +If your output contradicts an existing ADR, surface it explicitly rather than +silently overriding: + +> _Contradicts ADR-0007 (event-sourced orders) — but worth reopening because…_ + +## Relationship to `docs/specs/` + +`aiac/CLAUDE.md` already documents `docs/specs/PRD.md` and +`docs/specs/components/` as the requirements source, with a link-following +policy for cross-references. `CONTEXT.md` and `docs/adr/` are a different +layer — domain vocabulary and past architectural decisions, not requirements — +and don't replace the `docs/specs/` link-following policy. diff --git a/aiac/docs/agents/issue-tracker.md b/aiac/docs/agents/issue-tracker.md new file mode 100644 index 00000000..33935ff4 --- /dev/null +++ b/aiac/docs/agents/issue-tracker.md @@ -0,0 +1,44 @@ +# Issue tracker: GitHub (AIAC convention) + +Issues live as GitHub issues on **`s-and-p-team/cortex`**, organized in the +org-level **AIAC** Project (Projects v2): +. Use the `gh` CLI for all +operations, always scoped with `-R s-and-p-team/cortex` (this repo's `origin` +remote points here, but explicit `-R` avoids ambiguity since `upstream` also +exists). + +This is the same convention already documented in `CLAUDE.md` under "Issue +tracking" — this file exists so the engineering skills (`to-issues`, `triage`, +`to-prd`, `qa`) have a single place to read it from. + +## Conventions + +- **Create an issue**: `gh issue create -R s-and-p-team/cortex --title "..." --body "..." --label aiac`. + Use a heredoc for multi-line bodies. Always include the `aiac` label plus the + relevant cumulative `area:` label(s) for the component being touched. +- **Read an issue**: `gh issue view -R s-and-p-team/cortex --comments`. +- **List issues**: `gh issue list -R s-and-p-team/cortex --label aiac --state all`, + narrowing with additional `--label area:` or `--label aiac-status:` + filters as needed. +- **Comment on an issue**: `gh issue comment -R s-and-p-team/cortex --body "..."`. +- **Apply / remove labels**: `gh issue edit -R s-and-p-team/cortex --add-label "..."` / `--remove-label "..."`. +- **Close**: `gh issue close -R s-and-p-team/cortex --comment "..."`. + +## Hierarchy + +The Project groups **Feature**-typed container issues (one per component area, +nested via GitHub **native sub-issues**) over **Task**-typed leaf issues. Every +issue carries the `aiac` label plus cumulative `area:` labels. See +`docs/agents/triage-labels.md` for how triage state is represented. + +## When a skill says "publish to the issue tracker" + +Create a GitHub issue on `s-and-p-team/cortex` with the `aiac` label and the +appropriate `area:` label(s). + +## When a skill says "fetch the relevant ticket" + +Run `gh issue view -R s-and-p-team/cortex --comments`. + +Filtered web list: + diff --git a/aiac/docs/agents/triage-labels.md b/aiac/docs/agents/triage-labels.md new file mode 100644 index 00000000..1dc4a282 --- /dev/null +++ b/aiac/docs/agents/triage-labels.md @@ -0,0 +1,32 @@ +# Triage Labels + +The skills speak in terms of five canonical triage roles. This file maps those +roles to the actual label strings used in this repo's issue tracker. + +| Label in mattpocock/skills | Label in our tracker | Meaning | +| --------------------------- | ------------------------------ | ----------------------------------------- | +| `needs-triage` | `aiac-status:needs-triage` | Maintainer needs to evaluate this issue | +| `needs-info` | `aiac-status:needs-info` | Waiting on reporter for more information | +| `ready-for-agent` | `aiac-status:ready-for-agent` | Fully specified, ready for an AFK agent | +| `ready-for-human` | `aiac-status:ready-for-human` | Requires human implementation | +| `wontfix` | `aiac-status:wontfix` | Will not be actioned | + +When a skill mentions a role (e.g. "apply the AFK-ready triage label"), apply +the corresponding `aiac-status:` label from the right-hand column via +`gh issue edit -R s-and-p-team/cortex --add-label "aiac-status:..." --remove-label "aiac-status:"`. + +## Two separate things named "status" + +Don't confuse these: + +- **`aiac-status:` label** — the source of truth this table maps to. + Set it directly with `gh issue edit --add-label` / `--remove-label`. +- **`AIAC Status` Project field** — a separate field on the org-level AIAC + Project (Projects v2) board. It's a display/board-column field, not a label, + and `gh issue edit` does not touch it. If a skill needs to keep the board in + sync, that requires `gh project item-edit` against the AIAC Status field — + don't assume setting the label alone moves the card. + +Every open issue also carries the `aiac` label plus cumulative `area:` +labels (see `docs/agents/issue-tracker.md`) — those are unrelated to triage +state and should not be touched by triage operations. From eb85ee3bbba2cb203b02836a3ff3014a606d8242 Mon Sep 17 00:00:00 2001 From: Oleg Blinder Date: Mon, 3 Aug 2026 23:22:11 +0300 Subject: [PATCH 04/42] Docs: Point AIAC issue tracking at GitHub and add engineering-skills config - CLAUDE.md: issue-tracking section describes the GitHub issues/AIAC Project layout (no migration history); adds an '## Agent skills' block wiring the Matt Pocock engineering skills. - .gitignore: drop obsolete docs/issues/ and docs/gh-issues/ entries. - docs/agents/: issue-tracker, triage-labels, and domain config the skills read from. Assisted-By: Claude (Anthropic AI) Signed-off-by: Oleg Blinder --- aiac/.gitignore | 2 -- aiac/CLAUDE.md | 36 +++++++++++++++++--- aiac/docs/agents/domain.md | 56 +++++++++++++++++++++++++++++++ aiac/docs/agents/issue-tracker.md | 44 ++++++++++++++++++++++++ aiac/docs/agents/triage-labels.md | 32 ++++++++++++++++++ 5 files changed, 164 insertions(+), 6 deletions(-) create mode 100644 aiac/docs/agents/domain.md create mode 100644 aiac/docs/agents/issue-tracker.md create mode 100644 aiac/docs/agents/triage-labels.md diff --git a/aiac/.gitignore b/aiac/.gitignore index 8e5127a3..727f0b23 100644 --- a/aiac/.gitignore +++ b/aiac/.gitignore @@ -1,7 +1,5 @@ # AIAC working artefacts (regenerated; not source of truth) -docs/issues/ docs/handoffs/ -docs/gh-issues/ # PDP policy writer integration-test scratch output (test/pdp/policy/generate_rego.py) test/pdp/policy/rego_out/ diff --git a/aiac/CLAUDE.md b/aiac/CLAUDE.md index 2ee40d87..cb04463b 100644 --- a/aiac/CLAUDE.md +++ b/aiac/CLAUDE.md @@ -15,11 +15,25 @@ When a document under `docs/specs/` contains a markdown link to another file, us ## Issue tracking -Issues are tracked as local markdown files under `docs/issues/`, not on GitHub. -Never use `gh` commands to create, update, or list issues — always read/write the local files directly. +Issues are tracked as **GitHub issues** on the `s-and-p-team/cortex` repo, +organized in the org-level **AIAC** Project (Projects v2): +. Use `gh` to read and manage +them. -`docs/issues/implementation-plan.md` — overall implementation plan. -For current issue list, `ls` the subdirectories under `docs/issues/`. +Hierarchy: the Project groups **Feature**-typed container issues — one per +component area, nested via GitHub **native sub-issues** to form the tree — over +**Task**-typed leaf issues. Every issue carries the `aiac` label plus cumulative +`area:` labels; open issues also carry a `status:` label, and the +Project's **AIAC Status** field records the issue's status. + +```bash +# list / view (filter to the AIAC set) +gh issue list -R s-and-p-team/cortex --label aiac --state all +gh issue view -R s-and-p-team/cortex +``` + +Filtered web list: + ## Issue tracking — codebase inspection policy @@ -178,3 +192,17 @@ demo workloads, which don't) and CPU/memory requests + limits. ## External references - [Kagenti Developer Guide](https://github.com/kagenti/kagenti/blob/main/docs/dev-guide.md) — upstream Kagenti dev guide: per-persona workflows (agent, tool, extensions developers, MCP gateway operators), Git/PR process, pre-commit hooks, feature flags, local Kagenti UI v2 development (React frontend + FastAPI backend, building/deploying images to Kubernetes), and HyperShift-based testing on ephemeral OpenShift clusters (cluster lifecycle, cost management, troubleshooting). + +## Agent skills + +### Issue tracker + +GitHub issues on `s-and-p-team/cortex`, filtered by the `aiac` label, tracked in the org-level AIAC Project. See `docs/agents/issue-tracker.md`. + +### Triage labels + +Five canonical roles map to `aiac-status:` labels (distinct from the board's separate `AIAC Status` Project field). See `docs/agents/triage-labels.md`. + +### Domain docs + +Single-context, scoped to `aiac/` (`CONTEXT.md` + `docs/adr/` at the `aiac/` root). See `docs/agents/domain.md`. diff --git a/aiac/docs/agents/domain.md b/aiac/docs/agents/domain.md new file mode 100644 index 00000000..83dcff9d --- /dev/null +++ b/aiac/docs/agents/domain.md @@ -0,0 +1,56 @@ +# Domain Docs + +How the engineering skills should consume this repo's domain documentation +when exploring the codebase. This is scoped to `aiac/` — treated as its own +single context, separate from other components in the `cortex` monorepo (e.g. +`authbridge/`). + +## Before exploring, read these + +- **`CONTEXT.md`** at the `aiac/` root (this directory). +- **`docs/adr/`** (under `aiac/`) — read ADRs that touch the area you're about + to work in. + +If any of these files don't exist, **proceed silently**. Don't flag their +absence; don't suggest creating them upfront. The producer skill +(`/grill-with-docs`) creates them lazily when terms or decisions actually get +resolved. + +## File structure + +``` +aiac/ +├── CONTEXT.md +├── docs/ +│ ├── adr/ +│ │ ├── 0001-....md +│ │ └── 0002-....md +│ ├── specs/ ← PRD.md + components/ (see CLAUDE.md) +│ └── agents/ ← this file and its siblings +└── src/aiac/ +``` + +## Use the glossary's vocabulary + +When your output names a domain concept (in an issue title, a refactor +proposal, a hypothesis, a test name), use the term as defined in `CONTEXT.md`. +Don't drift to synonyms the glossary explicitly avoids. + +If the concept you need isn't in the glossary yet, that's a signal — either +you're inventing language the project doesn't use (reconsider) or there's a +real gap (note it for `/grill-with-docs`). + +## Flag ADR conflicts + +If your output contradicts an existing ADR, surface it explicitly rather than +silently overriding: + +> _Contradicts ADR-0007 (event-sourced orders) — but worth reopening because…_ + +## Relationship to `docs/specs/` + +`aiac/CLAUDE.md` already documents `docs/specs/PRD.md` and +`docs/specs/components/` as the requirements source, with a link-following +policy for cross-references. `CONTEXT.md` and `docs/adr/` are a different +layer — domain vocabulary and past architectural decisions, not requirements — +and don't replace the `docs/specs/` link-following policy. diff --git a/aiac/docs/agents/issue-tracker.md b/aiac/docs/agents/issue-tracker.md new file mode 100644 index 00000000..33935ff4 --- /dev/null +++ b/aiac/docs/agents/issue-tracker.md @@ -0,0 +1,44 @@ +# Issue tracker: GitHub (AIAC convention) + +Issues live as GitHub issues on **`s-and-p-team/cortex`**, organized in the +org-level **AIAC** Project (Projects v2): +. Use the `gh` CLI for all +operations, always scoped with `-R s-and-p-team/cortex` (this repo's `origin` +remote points here, but explicit `-R` avoids ambiguity since `upstream` also +exists). + +This is the same convention already documented in `CLAUDE.md` under "Issue +tracking" — this file exists so the engineering skills (`to-issues`, `triage`, +`to-prd`, `qa`) have a single place to read it from. + +## Conventions + +- **Create an issue**: `gh issue create -R s-and-p-team/cortex --title "..." --body "..." --label aiac`. + Use a heredoc for multi-line bodies. Always include the `aiac` label plus the + relevant cumulative `area:` label(s) for the component being touched. +- **Read an issue**: `gh issue view -R s-and-p-team/cortex --comments`. +- **List issues**: `gh issue list -R s-and-p-team/cortex --label aiac --state all`, + narrowing with additional `--label area:` or `--label aiac-status:` + filters as needed. +- **Comment on an issue**: `gh issue comment -R s-and-p-team/cortex --body "..."`. +- **Apply / remove labels**: `gh issue edit -R s-and-p-team/cortex --add-label "..."` / `--remove-label "..."`. +- **Close**: `gh issue close -R s-and-p-team/cortex --comment "..."`. + +## Hierarchy + +The Project groups **Feature**-typed container issues (one per component area, +nested via GitHub **native sub-issues**) over **Task**-typed leaf issues. Every +issue carries the `aiac` label plus cumulative `area:` labels. See +`docs/agents/triage-labels.md` for how triage state is represented. + +## When a skill says "publish to the issue tracker" + +Create a GitHub issue on `s-and-p-team/cortex` with the `aiac` label and the +appropriate `area:` label(s). + +## When a skill says "fetch the relevant ticket" + +Run `gh issue view -R s-and-p-team/cortex --comments`. + +Filtered web list: + diff --git a/aiac/docs/agents/triage-labels.md b/aiac/docs/agents/triage-labels.md new file mode 100644 index 00000000..1dc4a282 --- /dev/null +++ b/aiac/docs/agents/triage-labels.md @@ -0,0 +1,32 @@ +# Triage Labels + +The skills speak in terms of five canonical triage roles. This file maps those +roles to the actual label strings used in this repo's issue tracker. + +| Label in mattpocock/skills | Label in our tracker | Meaning | +| --------------------------- | ------------------------------ | ----------------------------------------- | +| `needs-triage` | `aiac-status:needs-triage` | Maintainer needs to evaluate this issue | +| `needs-info` | `aiac-status:needs-info` | Waiting on reporter for more information | +| `ready-for-agent` | `aiac-status:ready-for-agent` | Fully specified, ready for an AFK agent | +| `ready-for-human` | `aiac-status:ready-for-human` | Requires human implementation | +| `wontfix` | `aiac-status:wontfix` | Will not be actioned | + +When a skill mentions a role (e.g. "apply the AFK-ready triage label"), apply +the corresponding `aiac-status:` label from the right-hand column via +`gh issue edit -R s-and-p-team/cortex --add-label "aiac-status:..." --remove-label "aiac-status:"`. + +## Two separate things named "status" + +Don't confuse these: + +- **`aiac-status:` label** — the source of truth this table maps to. + Set it directly with `gh issue edit --add-label` / `--remove-label`. +- **`AIAC Status` Project field** — a separate field on the org-level AIAC + Project (Projects v2) board. It's a display/board-column field, not a label, + and `gh issue edit` does not touch it. If a skill needs to keep the board in + sync, that requires `gh project item-edit` against the AIAC Status field — + don't assume setting the label alone moves the card. + +Every open issue also carries the `aiac` label plus cumulative `area:` +labels (see `docs/agents/issue-tracker.md`) — those are unrelated to triage +state and should not be touched by triage operations. From 371ac4075bfe5049e6380a8ed1a82137d21da47e Mon Sep 17 00:00:00 2001 From: Anatoly Koyfman Date: Tue, 4 Aug 2026 15:33:13 +0000 Subject: [PATCH 05/42] plugin modifications to allow for outbound rego rules Signed-off-by: Anatoly Koyfman --- aiac/docs/examples/opa-team1-policy.yaml | 136 +++++ aiac/docs/opa-kind-runbook.md | 499 ++++++++++++++++++ authbridge/authlib/plugins/opa/README.md | 133 ++++- authbridge/authlib/plugins/opa/plugin.go | 86 ++- authbridge/authlib/plugins/opa/plugin_test.go | 206 +++++++- .../plugins/tokenexchange/delegation_test.go | 86 ++- .../authlib/plugins/tokenexchange/plugin.go | 39 +- scripts/opa-kind-enable.sh | 82 ++- 8 files changed, 1213 insertions(+), 54 deletions(-) create mode 100644 aiac/docs/examples/opa-team1-policy.yaml create mode 100644 aiac/docs/opa-kind-runbook.md diff --git a/aiac/docs/examples/opa-team1-policy.yaml b/aiac/docs/examples/opa-team1-policy.yaml new file mode 100644 index 00000000..755ae8f1 --- /dev/null +++ b/aiac/docs/examples/opa-team1-policy.yaml @@ -0,0 +1,136 @@ +# Example Rossoctl OPA AuthorizationPolicy (client-scoped, github-agent) +# +# A single, client-scoped policy CR that enforces one rule for exactly one +# workload in the `team1` namespace — the github-agent. +# +# INBOUND — deny user "alice" from reaching the github-agent. Every other +# authenticated user (e.g. "dev-user") stays allowed. +# +# OUTBOUND — gate the agent's token-exchanged call to a tool on both the +# delegated user's role and the exchange target audience. +# +# It carries a rego file per tier (inbound/request + outbound/request). The +# bundle-service composes each with the shipped global policy: for a tier the +# global combiner allows only when `ns_ok AND client_ok`, and `client_ok` +# comes from THIS package's `allow`. (A tier left undefined instead falls back +# to allow via the combiner's `client_ok if not data.authbridge.client.` +# rule — that is what happens when only one tier is present.) +# +# NOTE — outbound gating fields: the outbound rego below keys on +# `input.identity.subject`, `input.identity.service_id`, and +# `input.mcp.params.name` — all of which the live AuthBridge OPA plugin +# populates on the outbound leg (see opa-kind-runbook.md Part B.5). +# `input.identity.service_id` is the downstream service the exchanged token was +# minted for (the last delegation hop's target audience — here the github-tool +# SPIFFE ID). `input.mcp.params.name` is the specific tool being invoked, so the +# rule gates PER TOOL: it allows only when the delegated user's role AND the +# target service both admit the invoked tool. The `subject_role_scopes` / +# `target_scopes` maps below are keyed by the actual MCP tool names exposed by +# the deployed github-tool (aiac/demo/assets/tools/github_tool): `source-read`, +# `source-write`, `issues-read`, `issues-write`. Note that MCP methods which do +# not invoke a specific tool (e.g. a `tools/list` discovery request) carry no +# `params.name`, so they never match and are denied. +# +# IMPORTANT — how client-scope targeting actually works: bundle-service looks +# up a client-scope CR by **metadata.name + metadata.namespace**, matched +# against the ServiceAccount segment of the caller's SPIFFE ID (`spiffe:// +# /ns//sa/` -> namespace=, +# name=) — see operator/internal/bundleservice/identity/identity.go and +# watcher.go's `GetPolicy(name, namespace)`. `spec.clientID` is NOT consulted +# by that lookup (it's print-column metadata only); this CR is named +# `github-agent` — matching the `sa/github-agent` segment of +# `spiffe://localtest.me/ns/team1/sa/github-agent` — because that's what +# actually scopes it to this one workload. `clientID` below is set to the +# same short name purely for documentation/`kubectl get` display, and must +# satisfy the CRD's DNS-label-style validation regex (no `spiffe://`, no +# `/`). +# +# Identity note: on the INBOUND leg the OPA plugin exposes +# input.identity.{subject, client_id, scopes}, plus audience when the validated +# JWT carries it. On the OUTBOUND leg there is no validated JWT, so the plugin +# synthesizes input.identity from the token-exchange delegation hop: subject +# (delegated caller), client_id (this agent), scopes, and service_id (the +# downstream service the exchanged token targets — the last hop's audience). +# `subject` is the JWT `sub` claim. In this cluster the +# rossoctl-realm tokens carry the username in `sub` (via a username->sub +# protocol mapper on the `rossoctl` client — see A.1 in opa-kind-runbook.md, +# already applied cluster-wide). +apiVersion: agent.rossoctl.dev/v1alpha1 +kind: AuthorizationPolicy +metadata: + name: github-agent + namespace: team1 +spec: + scope: client + clientID: "github-agent" + policies: + - path: "inbound/request.rego" + content: | + package authbridge.client.inbound.request + import rego.v1 + + agent_scopes := ["github-agent.issue_operations", "github-agent.source_operations"] + + subject_roles := { + "dev-user": ["developer"], + "test-user": ["tester"], + } + + source_roles := {} + + role_scopes := { + "developer": ["github-agent.issue_operations", "github-agent.source_operations"], + "tester": ["github-agent.issue_operations"], + } + + subject_ok if { + some role in subject_roles[input.identity.subject] + some scope in role_scopes[role] + scope in agent_scopes + } + + source_ok if { not input.identity.client_id } + source_ok if { + some role in source_roles[input.identity.client_id] + some scope in role_scopes[role] + scope in agent_scopes + } + + default allow := false + allow if { subject_ok; source_ok } + + - path: "outbound/request.rego" + content: | + package authbridge.client.outbound.request + import rego.v1 + + agent_roles := ["github-agent.issue_operations", "github-agent.source_operations"] + subject_roles := { + "dev-user": ["developer"], + "test-user": ["tester"] + } + # The deployed github-tool (aiac/demo/assets/tools/github_tool) exposes + # exactly four MCP tools — source-read, source-write, issues-read, + # issues-write — one per skill. These names ARE the values that arrive in + # input.mcp.params.name when a specific tool is invoked, so the maps + # below key on them. + subject_role_scopes := { + "developer": ["issues-read", "source-write", "source-read"], + "tester": ["issues-read", "issues-write"], + } + agent_role_scopes := { + "github-agent.issue_operations": ["issues-read", "issues-write"], + "github-agent.source_operations": ["source-write", "source-read"], + } + target_scopes := { + "spiffe://localtest.me/ns/team1/sa/github-tool": ["source-read", "source-write", "issues-read", "issues-write"], + } + subject_ok if { + some role in subject_roles[input.identity.subject] + input.mcp.params.name in subject_role_scopes[role] + } + target_ok if { + input.mcp.params.name in target_scopes[input.identity.service_id] + } + default allow := false + allow if { subject_ok; target_ok } \ No newline at end of file diff --git a/aiac/docs/opa-kind-runbook.md b/aiac/docs/opa-kind-runbook.md new file mode 100644 index 00000000..b70dd34d --- /dev/null +++ b/aiac/docs/opa-kind-runbook.md @@ -0,0 +1,499 @@ +# OPA Kind Cluster Runbook — AIAC github-agent (inbound + outbound) + +> **Pre-release:** This document shows how OPA can be experimented with prior +> to its release as part of the Rossoctl system. On release, this document +> should be updated accordingly. + +This is the AIAC-scoped companion to +[`authbridge/docs/opa-kind-runbook.md`](../../authbridge/docs/opa-kind-runbook.md). +The underlying mechanism — OPA as an AuthBridge pipeline plugin, policy +distributed via `bundle-service`, enforcement via the `AuthorizationPolicy` +CRD — is identical. This document gives the **exact, copy-paste** steps to run +the AIAC scenario end-to-end on a local Kind cluster, using the two helper +scripts that wire OPA in and out: + +- [`scripts/opa-kind-enable.sh`](../../scripts/opa-kind-enable.sh) — rebuilds + the `authbridge-proxy` image from the current tree, loads it into Kind, and + wires the `opa` plugin (plus the parser set) into **both** the inbound and + outbound pipeline of every `team1` agent. +- [`scripts/opa-kind-restore.sh`](../../scripts/opa-kind-restore.sh) — reverts + the pipeline to its shipped state (no OPA overlay) and restarts the agents. + +The scenario itself uses one agent (`github-agent` in namespace `team1`) and +its downstream tool (`github-tool`): + +- **`dev-user` is the allowed user, `alice` is the blocked user.** `dev-user` + is the canonical scenario username from + [`docs/specs/integration-test/policy-pipeline.md`](specs/integration-test/policy-pipeline.md). +- **Inbound** authorization is enforced by a **client-scoped** + `AuthorizationPolicy` targeting `github-agent` alone. +- **Outbound** shows the token-exchange → OPA leg: the agent's call to + `github-tool` is exchanged for a `github-tool`-audience token, and OPA sees a + delegation chain plus a synthesized `input.identity`. + +## Architecture + +``` +Inbound: caller ─► jwt-validation ─► OPA ─► github-agent app +Outbound: github-agent app ─► token-exchange ─► OPA ─► github-tool +``` + +Policies are distributed via the bundle service used by every AuthBridge +workload: `http://bundle-service.rossoctl-system.svc.cluster.local:8080`. +On the outbound leg OPA is placed **after** `token-exchange` so policies can +read the delegation chain (see [Part B](#part-b--outbound-token-exchange--opa)). + +--- + +## Prerequisites + +- A Kind cluster named `rossoctl` with the `rossoctl` platform installed and + `github-agent` + `github-tool` deployed in namespace `team1`. +- The two sibling repo clones the enable/restore scripts need: + - `OPERATOR_DIR` → `rossoctl/operator` clone (default: `../operator`) + - `ROSSOCTL_DIR` → `rossoctl/rossoctl` clone, i.e. the Helm chart + (default: `../rossoctl`) +- `kubectl`, `helm`, `kind`, and `docker` (or `podman`) on `PATH`. +- The `rossoctl` Keycloak realm has `dev-user` and `alice` users with + **password == username**, and the `rossoctl` client has Direct Access Grants + enabled plus a `username → sub` protocol mapper. In this cluster this is + already done cluster-wide — it is a one-time Keycloak change, not per-agent. + +All commands below are run from the repo root (`cortex/`). + +--- + +## Step 1 — Enable OPA in both legs + +```bash +OPERATOR_DIR=../operator ROSSOCTL_DIR=../rossoctl ./scripts/opa-kind-enable.sh +``` + +This rebuilds `localhost/authbridge:local` from the current tree, loads it into +the `rossoctl` Kind cluster, and `helm upgrade`s the chart with a temporary +overlay that inserts `opa` (after `token-exchange` on the outbound leg) and the +parser set into every `team1` agent's pipeline. It does **not** modify +`charts/rossoctl/values.yaml` on disk. + +Confirm OPA is wired into **both** legs (expect **2**): + +```bash +kubectl get configmap authbridge-runtime-config -n team1 \ + -o jsonpath='{.data.config\.yaml}' | grep -c 'name: opa' +# 2 +``` + +--- + +## Step 2 — Verify the starting point + +```bash +# github-agent is 2/2 (app + authbridge-proxy sidecar) +kubectl get pods -n team1 -l app.kubernetes.io/name=github-agent + +# bundle-service is up and serving the shipped global policy +kubectl get pods -n rossoctl-system -l app=bundle-service # 1/1 Running +kubectl get authorizationpolicy -n rossoctl-system # 'default', scope global + +# github-agent's SPIFFE ID — this is what the client-scoped policy targets +kubectl exec -n team1 deploy/github-agent -c authbridge-proxy -- cat /shared/client-id.txt +# spiffe://localtest.me/ns/team1/sa/github-agent +``` + +--- + +# Part A — Inbound authorization + +Proves inbound OPA authorization for `github-agent` using a **client-scoped** +policy (`spec.scope: client`) so the rule affects only this one agent. + +> **How client-scope targeting works.** `bundle-service` looks up a +> client-scope CR by **`metadata.name` + `metadata.namespace`**, matched +> against the ServiceAccount segment of the caller's SPIFFE ID +> (`spiffe:///ns//sa/`). `spec.clientID` is +> **not** consulted by that lookup — it's a print-column convenience field. So +> the example CR is named `github-agent` (matching `sa/github-agent`), and +> `clientID` is the short name `"github-agent"` (the CRD validates it against a +> DNS-label regex that rejects `spiffe://` and `/`). + +## A.1 — Verify the dev-user token carries the right `sub` + +```bash +curl -s -X POST "http://keycloak.localtest.me:8080/realms/rossoctl/protocol/openid-connect/token" \ + -d client_id=rossoctl -d username=dev-user -d password=dev-user -d grant_type=password -d scope=openid \ + | python3 -c 'import sys,json,base64;t=json.load(sys.stdin)["access_token"].split(".")[1];t+="="*(-len(t)%4);print("sub =",json.loads(base64.urlsafe_b64decode(t)).get("sub"))' +# sub = dev-user +``` + +## A.2 — Probe helper + +`github-agent` is only reachable in-cluster, so probe from a throwaway pod. The +helper mints a user token and posts a JSON-RPC method the agent doesn't +implement — enough to reach the app and get a fast response without triggering +the CrewAI/tool flow: + +```bash +probe_as() { # usage: probe_as dev-user | probe_as alice + local user="$1" + local KC=http://keycloak.localtest.me:8080 + local TOK + TOK=$(curl -s -X POST "$KC/realms/rossoctl/protocol/openid-connect/token" \ + -d client_id=rossoctl -d "username=$user" -d "password=$user" \ + -d grant_type=password -d scope=openid \ + | python3 -c 'import sys,json;print(json.load(sys.stdin)["access_token"])') + kubectl run "probe-$user-$RANDOM" --rm -i --restart=Never --image=curlimages/curl:8.10.1 \ + -n team1 --env="TOK=$TOK" -- sh -c \ + 'curl -s -m 15 -w "\nHTTP_CODE:%{http_code}\n" \ + -X POST http://github-agent.team1.svc.cluster.local:8080/ \ + -H "Content-Type: application/json" -H "Authorization: Bearer $TOK" \ + -d "{\"jsonrpc\":\"2.0\",\"id\":\"1\",\"method\":\"ping/nonexistent\",\"params\":{}}"' +} +``` + +Baseline — before any client policy, both users reach the app: + +```bash +probe_as dev-user +# {"error":{"code":-32601,"message":"Method not found"},"id":"1","jsonrpc":"2.0"} +# HTTP_CODE:200 + +probe_as alice +# {"error":{"code":-32601,"message":"Method not found"},"id":"1","jsonrpc":"2.0"} +# HTTP_CODE:200 +``` + +`HTTP_CODE:200` with a JSON-RPC `-32601` body means the request passed +`jwt-validation` and OPA and reached the app — the app rejected the unknown +method, which is expected and irrelevant to authorization. + +> **Don't test with `/.well-known/agent-card.json`** — it matches +> `jwt-validation`'s bypass list (`/.well-known/*`, `/healthz`, `/readyz`, +> `/livez`, `/metrics`) and returns `200` with **no token**, never reaching +> OPA. **Don't test with a real `message/send` task** either — it drives the +> CrewAI flow and can hang for minutes if `github-tool` is unhealthy. The +> `ping/nonexistent` probe above reaches OPA and returns instantly. + +## A.3 — Apply the client-scoped policy + +```bash +kubectl apply -f aiac/docs/examples/opa-team1-policy.yaml +``` + +`bundle-service` rebuilds the `team1` bundle on the CR change; `github-agent`'s +OPA polls the bundle on its own interval, so allow **~20–30 s** before testing. + +## A.4 — Test: dev-user allowed, alice blocked + +```bash +probe_as dev-user +# {"error":{"code":-32601,"message":"Method not found"},"id":"1","jsonrpc":"2.0"} +# HTTP_CODE:200 — reaches the app: allowed + +probe_as alice +# {"error":"policy.forbidden","message":"policy denied","plugin":"opa"} +# HTTP_CODE:403 — blocked by OPA, never reaches the app +``` + +> If `alice` still returns `200` right after applying, OPA hasn't polled the +> new bundle yet — wait a few seconds and retry. + +## A.5 — The inbound OPA input, exactly + +With `decision_logs.console: true` (set by the enable overlay), every decision +is logged by the `authbridge-proxy` sidecar. Capture the inbound input: + +```bash +POD=$(kubectl get pod -n team1 -l app.kubernetes.io/name=github-agent -o jsonpath='{.items[0].metadata.name}') +kubectl logs -n team1 "$POD" -c authbridge-proxy --tail=500 \ + | grep 'path=authbridge/inbound/request' | tail -1 +``` + +For the `dev-user` probe the plugin builds this `input` document (rendered as +JSON; the log prints it in Go `map[...]` form): + +```json +{ + "direction": "inbound", + "method": "POST", + "path": "/", + "host": "github-agent.team1.svc.cluster.local:8080", + "headers": { + "accept": "*/*", + "content-length": "66", + "content-type": "application/json", + "user-agent": "curl/8.10.1" + }, + "identity": { + "subject": "dev-user", + "client_id": "rossoctl", + "scopes": [ + "agent-team1-weather-service-advanced-aud", + "agent-team1-github-tool-aud", + "openid", + "agent-team1-github-agent-aud", + "agent-team1-weather-tool-advanced-aud", + "profile", + "email" + ] + } +} +``` + +- `identity` comes from the **validated inbound JWT** (`jwt-validation` runs + before OPA). `subject` is the JWT `sub` claim — here `dev-user`, via the + realm's `username → sub` mapper. `client_id` is the token's client (`rossoctl` + in this probe). `scopes` are the token's granted scopes. +- Credential headers (`authorization`, `cookie`, …) are **redacted** from + `headers` — use `identity` for auth decisions. + +The policy ([`opa-team1-policy.yaml`](examples/opa-team1-policy.yaml)) keys on +`input.identity.subject`: `dev-user` maps to a role whose scopes are allowed → +`allow: true`; `alice` has no role → `allow: false`. The decision appears in +the same log line as `result`: + +``` +result="map[allow:true client_ok:true ns_ok:true]" # dev-user +result="map[allow:false ns_ok:true]" # alice (client_ok never set → denied) +``` + +--- + +# Part B — Outbound token-exchange + OPA + +The agent's outbound call to `github-tool` is intercepted by the forward proxy. +`token-exchange` matches the route, mints a `github-tool`-audience token, and +records a **delegation hop**; OPA (placed after it) then sees both +`input.delegation` and a synthesized `input.identity`. + +## B.1 — Add the github-tool outbound route + +Add a route for `github-tool` to the `authproxy-routes` ConfigMap (this keeps +the existing weather route): + +```bash +kubectl patch configmap authproxy-routes -n team1 --type merge -p "$(python3 -c ' +import json +print(json.dumps({"data":{"routes.yaml": +"""- host: \"weather-tool-advanced-mcp\" + target_audience: \"spiffe://localtest.me/ns/team1/sa/weather-tool-advanced\" + token_scopes: \"openid weather-tool-exchange-aud\" +- host: \"github-tool\" + target_audience: \"spiffe://localtest.me/ns/team1/sa/github-tool\" + token_scopes: \"openid agent-team1-github-tool-aud\" +"""}}))')" +``` + +- `target_audience` is the RFC 8693 `audience` — the `github-tool` SPIFFE ID. +- `token_scopes` is the requested `scope`; `agent-team1-github-tool-aud` is the + realm client-scope whose audience mapper stamps the `github-tool` audience. + +## B.2 — Grant github-agent the exchange scope + +For the `client_credentials` exchange to succeed, the github-agent Keycloak +client must have `agent-team1-github-tool-aud` as an **optional** client scope: + +```bash +KC=http://keycloak.localtest.me:8080 +ADMIN=$(curl -s -X POST "$KC/realms/master/protocol/openid-connect/token" \ + -d client_id=admin-cli -d username=admin -d password=admin -d grant_type=password \ + | python3 -c 'import sys,json;print(json.load(sys.stdin)["access_token"])') + +# github-agent's registered client UUID (its clientId is its SPIFFE ID) +CID=$(curl -s -H "Authorization: Bearer $ADMIN" "$KC/admin/realms/rossoctl/clients" \ + | python3 -c 'import sys,json;print(next(c["id"] for c in json.load(sys.stdin) if c["clientId"].endswith("/sa/github-agent")))') + +# the client-scope that stamps the github-tool audience +SID=$(curl -s -H "Authorization: Bearer $ADMIN" "$KC/admin/realms/rossoctl/client-scopes" \ + | python3 -c 'import sys,json;print(next(s["id"] for s in json.load(sys.stdin) if s["name"]=="agent-team1-github-tool-aud"))') + +curl -s -o /dev/null -w "assign scope HTTP %{http_code}\n" -X PUT -H "Authorization: Bearer $ADMIN" \ + "$KC/admin/realms/rossoctl/clients/$CID/optional-client-scopes/$SID" +# assign scope HTTP 204 +``` + +## B.3 — Restart github-agent to load the route + +Routes are read once at startup, so restart the pod: + +```bash +kubectl delete pod -n team1 -l app.kubernetes.io/name=github-agent +kubectl wait --for=condition=ready pod -n team1 -l app.kubernetes.io/name=github-agent --timeout=120s +``` + +## B.4 — Probe the outbound leg as dev-user + +The github-agent app container (`agent`) is configured with +`HTTP_PROXY=127.0.0.1:8081` (the AuthBridge forward proxy) and has `python3`. +Drive an outbound MCP call through it, carrying a `dev-user` bearer — the token +`token-exchange` uses as the RFC 8693 `subject_token`: + +```bash +POD=$(kubectl get pod -n team1 -l app.kubernetes.io/name=github-agent -o jsonpath='{.items[0].metadata.name}') +TOK=$(curl -s -X POST "http://keycloak.localtest.me:8080/realms/rossoctl/protocol/openid-connect/token" \ + -d client_id=rossoctl -d username=dev-user -d password=dev-user -d grant_type=password -d scope=openid \ + | python3 -c 'import sys,json;print(json.load(sys.stdin)["access_token"])') + +cat > /tmp/probe.py < **The example CR's `outbound/request.rego` denies this `tools/list` probe.** +> The rule admits a call only when the delegated user's role and the target +> service both list the request's `input.mcp.params.name` (the invoked tool). +> A `tools/list` call carries no `params.name`, so neither gate matches, `allow` +> is `false`, and the probe returns `403`/`503` instead of `404`. To see the +> `404` success path, apply only the inbound tier of the CR, or drive a real +> tool invocation whose tool name is present in `subject_role_scopes` and +> `target_scopes` in the outbound rego. + +## B.5 — The outbound OPA input, exactly + +```bash +kubectl logs -n team1 "$POD" -c authbridge-proxy --tail=200 \ + | grep 'path=authbridge/outbound/request' | tail -1 +``` + +The plugin builds this `input` document: + +```json +{ + "direction": "outbound", + "method": "POST", + "path": "/", + "host": "github-tool:9090", + "headers": { + "accept-encoding": "identity", + "connection": "close", + "content-length": "67", + "content-type": "application/json", + "user-agent": "Python-urllib/3.12" + }, + "identity": { + "subject": "dev-user", + "client_id": "spiffe://localtest.me/ns/team1/sa/github-agent", + "scopes": ["openid", "agent-team1-github-tool-aud"], + "service_id": "spiffe://localtest.me/ns/team1/sa/github-tool" + }, + "delegation": { + "origin": "dev-user", + "actor": "dev-user", + "depth": 1, + "chain": [ + { + "subject_id": "dev-user", + "audience": "spiffe://localtest.me/ns/team1/sa/github-tool", + "scopes": ["openid", "agent-team1-github-tool-aud"], + "strategy": "token-exchange", + "from_cache": false, + "timestamp": "2026-08-04T07:56:56Z" + } + ] + }, + "mcp": { + "method": "tools/list" + } +} +``` + +Key differences from the inbound input, and how the outbound `identity` is +built: + +- There is **no validated JWT** on the outbound leg. Instead, when + `token-exchange` mints the downstream token it records a delegation hop, and + OPA synthesizes `input.identity` **in the same shape as inbound** so policies + can branch on `input.identity` uniformly on both legs: + - `subject` = the delegated caller (`delegation.origin`), decoded + best-effort from the incoming bearer's `sub` — here `dev-user`. + - `client_id` = the **agent's own client** (`/shared/client-id.txt`), i.e. + the party performing the exchange — **not** the target audience. + - `scopes` = the scopes the downstream token was minted with (the last hop). + - `service_id` = the **downstream service** the token was minted for (the last + hop's target `audience` — here the `github-tool` SPIFFE ID). This mirrors the + inbound identity, where `jwt-validation` surfaces the validated JWT's + audience; on the outbound leg the equivalent "who is this token for" signal + is the exchange target, exposed as `service_id`. A policy keys on it via + `target_scopes[input.identity.service_id]`. Omitted when the last hop is a + non-exchange hop that recorded no audience. +- `input.delegation` carries the full RFC 8693 chain for policies that need + per-hop detail (`audience`, `strategy`, `from_cache`, `depth`). +- `input.mcp` is present because the probe sent a real MCP body (`tools/list`). + Parser sections (`mcp` / `a2a` / `inference`) appear **only** when the body + matches that parser's protocol — a non-MCP body carries no `input.mcp`. + +The example CR ([`opa-team1-policy.yaml`](examples/opa-team1-policy.yaml)) +carries an `outbound/request.rego` that keys entirely on fields the live plugin +emits on this leg: the synthesized `input.identity.subject`, +`input.identity.service_id` (the exchange target, added to the outbound identity +as shown above), and `input.mcp.params.name` (the specific tool being invoked). +It gates **per tool**: allowing only when the delegated user's role **and** the +target service both admit the invoked tool — an AND across the user→tool and +service→tool gates. The `subject_role_scopes` / `target_scopes` maps in the +example are keyed by the actual MCP tool names exposed by the deployed +github-tool (`aiac/demo/assets/tools/github_tool`): `source-read`, +`source-write`, `issues-read`, `issues-write`. (Because the gate is on +`params.name`, MCP methods that don't invoke a specific tool — like the +`tools/list` probe below — carry no `params.name`, so they never match and are +denied.) + +--- + +## Cleanup + +Undo everything, in reverse order: + +```bash +# 1. delete the inbound policy CR +kubectl delete -f aiac/docs/examples/opa-team1-policy.yaml + +# 2. revert authproxy-routes to weather-only +kubectl patch configmap authproxy-routes -n team1 --type merge -p "$(python3 -c ' +import json +print(json.dumps({"data":{"routes.yaml": +"""- host: \"weather-tool-advanced-mcp\" + target_audience: \"spiffe://localtest.me/ns/team1/sa/weather-tool-advanced\" + token_scopes: \"openid weather-tool-exchange-aud\" +"""}}))')" + +# 3. remove the temporary optional client scope from github-agent +KC=http://keycloak.localtest.me:8080 +ADMIN=$(curl -s -X POST "$KC/realms/master/protocol/openid-connect/token" \ + -d client_id=admin-cli -d username=admin -d password=admin -d grant_type=password \ + | python3 -c 'import sys,json;print(json.load(sys.stdin)["access_token"])') +CID=$(curl -s -H "Authorization: Bearer $ADMIN" "$KC/admin/realms/rossoctl/clients" \ + | python3 -c 'import sys,json;print(next(c["id"] for c in json.load(sys.stdin) if c["clientId"].endswith("/sa/github-agent")))') +SID=$(curl -s -H "Authorization: Bearer $ADMIN" "$KC/admin/realms/rossoctl/client-scopes" \ + | python3 -c 'import sys,json;print(next(s["id"] for s in json.load(sys.stdin) if s["name"]=="agent-team1-github-tool-aud"))') +curl -s -o /dev/null -w "remove scope HTTP %{http_code}\n" -X DELETE -H "Authorization: Bearer $ADMIN" \ + "$KC/admin/realms/rossoctl/clients/$CID/optional-client-scopes/$SID" + +# 4. revert the pipeline (removes the OPA overlay, restarts the agents) +ROSSOCTL_DIR=../rossoctl ./scripts/opa-kind-restore.sh +``` + +Confirm OPA is gone from the pipeline (expect **0**): + +```bash +kubectl get configmap authbridge-runtime-config -n team1 \ + -o jsonpath='{.data.config\.yaml}' | grep -c 'name: opa' +# 0 +``` + +The Keycloak realm/user changes from the Prerequisites are shared, cluster-wide +state and are harmless to leave in place for future runs. diff --git a/authbridge/authlib/plugins/opa/README.md b/authbridge/authlib/plugins/opa/README.md index c301eb7a..15dfa729 100644 --- a/authbridge/authlib/plugins/opa/README.md +++ b/authbridge/authlib/plugins/opa/README.md @@ -55,17 +55,27 @@ pipeline: bundle_url: "http://bundle-server.rossoctl.svc:8080" outbound: plugins: + - name: token-exchange + config: { ... } - name: opa config: bundle_url: "http://bundle-server.rossoctl.svc:8080" - - name: token-exchange - config: { ... } ``` With this config, policies can decide based on caller identity, tool names, model names, hosts, and methods — without any bulk content crossing the OPA evaluation boundary. +> **Outbound ordering matters.** Place `opa` *after* `token-exchange` on the +> outbound leg. `token-exchange` records the target audience and granted +> scopes of the token it mints into the delegation chain, which OPA then +> exposes as both `input.delegation` and a synthesized `input.identity` (see +> below). If `opa` runs first, that signal is not yet populated. On inbound, +> `input.identity` comes from a validated JWT; on outbound no JWT is validated, +> so `input.identity` is synthesized from the delegation hop — letting policies +> branch on `input.identity` on either leg — while `input.delegation` remains +> available for full-chain detail. + ### Example: content-filtering policy If a policy needs to inspect the actual user prompt (e.g., block tasks @@ -246,13 +256,85 @@ On the response path the document also includes: | `path` | always | Request URL path | | `host` | always | HTTP `Host` header value | | `headers` | always | Flattened request headers (lowercase keys, multi-values joined with `,`). Credential headers (`authorization`, `proxy-authorization`, `cookie`, `set-cookie`) are redacted — use `input.identity` for auth decisions. | -| `identity` | when jwt-validation ran | Subject, client ID, and scopes from the validated JWT | +| `identity` | when jwt-validation ran, **or** synthesized outbound from the delegation hop | Inbound: `subject`, `client_id`, and `scopes` from the validated JWT. Outbound: the same shape synthesized from the token-exchange hop (`subject` = delegated caller, `client_id` = the agent's own client, `scopes` = the exchanged scopes) so policies can read `input.identity` uniformly on both legs — see the outbound section below. | | `agent` | when agent identity is set | The agent's own client ID | +| `delegation` | when token-exchange minted/served a token on this leg | The RFC 8693 delegation chain: `origin`, `actor`, `depth`, and `chain[]` of `{subject_id, audience, scopes, strategy, from_cache, timestamp}`. This is the outbound identity signal — see below. | | `a2a` | when a2a-parser ran | A2A protocol metadata (method, session_id, task_id, role) | | `mcp` | when mcp-parser ran | MCP method + filtered params | | `inference` | when inference-parser ran | Model, stream, max_tokens, tool names | | `response` | response path only | Status code and response headers | +### Outbound identity + delegation input + +On the outbound leg there is no inbound JWT to validate. When `token-exchange` +mints (or cache-serves) a downstream token it records a hop in the delegation +chain, and OPA surfaces that hop **two ways**: + +1. **`input.identity`** — synthesized in the *same shape* as the inbound + identity so a policy can branch on `input.identity.subject` / + `input.identity.client_id` / `input.identity.scopes` uniformly on both legs: + + ```json + { + "identity": { + "subject": "dev-user", + "client_id": "github-agent", + "scopes": ["openid", "agent-team1-github-tool-aud"] + } + } + ``` + + - `subject` = the delegated caller (the delegation chain's `origin`). + - `client_id` = **the agent's own client** (the party performing the + exchange), read from `/shared/client-id.txt` — *not* the target audience. + - `scopes` = the scopes the downstream token was minted with (the last hop); + omitted when the hop recorded none. + + A validated inbound `input.identity` always takes precedence: this + synthesized form only appears when no JWT was validated on the leg. + +2. **`input.delegation`** — the full RFC 8693 chain, kept alongside + `input.identity` for policies that need per-hop detail (multi-hop depth, + per-hop audience/strategy/cache): + +```json +{ + "delegation": { + "origin": "agent-team1", + "actor": "agent-team1", + "depth": 1, + "chain": [ + { + "subject_id": "agent-team1", + "audience": "github-tool", + "scopes": ["openid", "github-tool-aud", "github-full-access"], + "strategy": "token-exchange", + "from_cache": false, + "timestamp": "2026-08-03T12:00:00Z" + } + ] + } +} +``` + +- `origin` — subject of the first hop (the original caller). +- `actor` — subject of the most recent hop. +- `depth` — number of hops recorded so far. +- `chain[]` — one entry per exchange. `audience` and `scopes` are what the + token was minted for; `strategy` is `"token-exchange"` today; `from_cache` + reports whether the token was served from the exchange cache. + +`subject_id` (and therefore `origin` / `actor` and the synthesized +`input.identity.subject`) is best-effort: the outbound leg has no validated +identity, so token-exchange decodes the `sub` claim of the incoming bearer — +the token it uses as the RFC 8693 `subject_token` — **without** signature +verification, purely to enrich policy input. It falls back to empty when no +usable bearer is present. The `audience` / `scopes` / `strategy` / `from_cache` +fields are always known from the exchange result. Requires `opa` to run +**after** `token-exchange` (see the ordering note above); a host that matches +no route carries no hop, so both `input.delegation` and the synthesized +`input.identity` are absent for passthrough traffic. + ## Policy contract Each decision path evaluates to an `allow` rule. The plugin supports two @@ -364,6 +446,51 @@ has_value(arr, val) if { } ``` +### Scope-of-delegation control — outbound request (uses `input.delegation`) + +Restrict which exchanged scopes an agent may use against a downstream target. +Because OPA runs after `token-exchange`, the minted token's audience and +scopes are visible in `input.delegation.chain`: + +```rego +package authbridge.outbound.request + +default allow := {"allow": false, "reason": "default deny"} + +# Agents allowed to wield elevated GitHub scopes. +privileged_agents := {"admin-agent", "release-bot"} + +# No exchange happened (passthrough egress, e.g. LLM inference) — allow. +allow := {"allow": true} if { + not input.delegation +} + +# An exchange happened: enforce scope policy on the minted token. +allow := {"allow": true} if { + hop := input.delegation.chain[_] + hop.audience == "github-tool" + not uses_full_access(hop) +} + +allow := {"allow": true} if { + hop := input.delegation.chain[_] + hop.audience == "github-tool" + uses_full_access(hop) + privileged_agents[input.delegation.actor] +} + +uses_full_access(hop) if { + hop.scopes[_] == "github-full-access" +} + +allow := {"allow": false, "reason": "github-full-access requires a privileged agent"} if { + hop := input.delegation.chain[_] + hop.audience == "github-tool" + uses_full_access(hop) + not privileged_agents[input.delegation.actor] +} +``` + ### Task content filtering (A2A) — requires `include: ["a2a.content"]` ```rego diff --git a/authbridge/authlib/plugins/opa/plugin.go b/authbridge/authlib/plugins/opa/plugin.go index 16fd9ab9..f790f282 100644 --- a/authbridge/authlib/plugins/opa/plugin.go +++ b/authbridge/authlib/plugins/opa/plugin.go @@ -428,7 +428,7 @@ func (p *OPA) OnRequest(ctx context.Context, pctx *pipeline.Context) pipeline.Ac } path := p.decisionPath(pctx, "request") - input := buildInput(pctx, p.inc) + input := buildInput(pctx, p.inc, p.agentID) result, err := s.decider.Decision(ctx, sdk.DecisionOptions{ Path: path, Input: input, @@ -476,7 +476,7 @@ func (p *OPA) OnResponse(ctx context.Context, pctx *pipeline.Context) pipeline.A } path := p.decisionPath(pctx, "response") - input := buildInput(pctx, p.inc) + input := buildInput(pctx, p.inc, p.agentID) input["response"] = map[string]any{ "status_code": pctx.StatusCode, "headers": flattenHeaders(pctx.ResponseHeaders), @@ -517,7 +517,7 @@ func (p *OPA) OnResponse(ctx context.Context, pctx *pipeline.Context) pipeline.A return pipeline.Action{Type: pipeline.Continue} } -func buildInput(pctx *pipeline.Context, inc includeSet) map[string]any { +func buildInput(pctx *pipeline.Context, inc includeSet, agentID string) map[string]any { input := map[string]any{ "direction": pctx.Direction.String(), "method": pctx.Method, @@ -531,12 +531,23 @@ func buildInput(pctx *pipeline.Context, inc includeSet) map[string]any { "client_id": pctx.Identity.ClientID(), "scopes": pctx.Identity.Scopes(), } + } else if pctx.Extensions.Delegation != nil { + // Outbound leg: no validated JWT to build identity from, but + // token-exchange recorded a delegation hop. Surface it in the SAME + // shape as the inbound identity so policies can branch on + // input.identity uniformly across both legs. input.delegation (the + // full multi-hop chain) is still emitted below for policies that + // need per-hop detail. + input["identity"] = buildOutboundIdentity(pctx.Extensions.Delegation, agentID) } if pctx.Agent != nil { input["agent"] = map[string]any{ "client_id": pctx.Agent.ClientID, } } + if pctx.Extensions.Delegation != nil { + input["delegation"] = buildDelegationInput(pctx.Extensions.Delegation) + } if pctx.Extensions.A2A != nil { input["a2a"] = buildA2AInput(pctx.Extensions.A2A, inc) @@ -551,6 +562,75 @@ func buildInput(pctx *pipeline.Context, inc includeSet) map[string]any { return input } +// buildOutboundIdentity mirrors the inbound input.identity shape on the +// outbound leg, where there is no validated JWT. Values come from the +// token-exchange delegation hop: +// +// subject = the delegated caller (chain origin) +// client_id = this agent's own client (the party performing the exchange) +// scopes = the scopes the downstream token was minted with (last hop) +// service_id = the downstream service the token was minted for (last hop's target audience) +// +// scopes and service_id are omitted when the last hop recorded none, matching +// the inbound branch where an empty value simply isn't asserted. service_id +// lets an outbound policy key on the exchange target the same way the inbound +// identity exposes audience (jwt-validation surfaces the JWT's audience) — e.g. +// gating a requested function against target_scopes[input.identity.service_id]. +func buildOutboundIdentity(d *pipeline.DelegationExtension, agentID string) map[string]any { + id := map[string]any{"subject": d.Origin, "client_id": agentID} + if chain := d.Chain(); len(chain) > 0 { + last := chain[len(chain)-1] + if len(last.Scopes) > 0 { + id["scopes"] = last.Scopes + } + if last.Audience != "" { + id["service_id"] = last.Audience + } + } + return id +} + +// buildDelegationInput exposes the token-delegation chain recorded by +// token-exchange (RFC 8693) so outbound policy can reason about WHAT a +// token was minted for and WITH WHICH scopes. +// +// Unlike input.identity — which requires a validated JWT on this leg and +// is therefore normally absent outbound — the delegation chain is +// populated by the auth plugin itself as a side effect of the exchange +// (see tokenexchange.recordDelegationHop). It is available on the +// outbound REQUEST path as soon as token-exchange runs, with no re-parse +// of the minted token and without a fail-closed gate that would reject +// passthrough traffic. Put OPA after token-exchange in the outbound +// pipeline to read it. +func buildDelegationInput(d *pipeline.DelegationExtension) map[string]any { + del := map[string]any{ + "origin": d.Origin, + "actor": d.Actor, + "depth": d.Depth(), + } + chain := d.Chain() + if len(chain) > 0 { + hops := make([]map[string]any, len(chain)) + for i, h := range chain { + hop := map[string]any{ + "subject_id": h.SubjectID, + "audience": h.Audience, + "strategy": h.Strategy, + "from_cache": h.FromCache, + } + if len(h.Scopes) > 0 { + hop["scopes"] = h.Scopes + } + if !h.Timestamp.IsZero() { + hop["timestamp"] = h.Timestamp.UTC().Format(time.RFC3339) + } + hops[i] = hop + } + del["chain"] = hops + } + return del +} + func buildA2AInput(ext *pipeline.A2AExtension, inc includeSet) map[string]any { a2a := map[string]any{ "method": ext.Method, diff --git a/authbridge/authlib/plugins/opa/plugin_test.go b/authbridge/authlib/plugins/opa/plugin_test.go index 566dbd47..4c57e551 100644 --- a/authbridge/authlib/plugins/opa/plugin_test.go +++ b/authbridge/authlib/plugins/opa/plugin_test.go @@ -6,6 +6,7 @@ import ( "fmt" "net/http" "testing" + "time" "github.com/open-policy-agent/opa/sdk" @@ -340,7 +341,7 @@ func TestBuildInput_Basic(t *testing.T) { Host: "my-service", Headers: http.Header{"Content-Type": {"application/json"}}, } - input := buildInput(pctx, inc) + input := buildInput(pctx, inc, "") if input["direction"] != "inbound" { t.Errorf("expected inbound, got %v", input["direction"]) } @@ -378,7 +379,7 @@ func TestBuildInput_WithIdentity(t *testing.T) { Headers: http.Header{}, Identity: testIdentity{}, } - input := buildInput(pctx, inc) + input := buildInput(pctx, inc, "") id, ok := input["identity"].(map[string]any) if !ok { t.Fatal("expected identity map") @@ -401,7 +402,7 @@ func TestBuildInput_WithAgent(t *testing.T) { Headers: http.Header{}, Agent: &pipeline.AgentIdentity{ClientID: "agent-x"}, } - input := buildInput(pctx, inc) + input := buildInput(pctx, inc, "") agent, ok := input["agent"].(map[string]any) if !ok { t.Fatal("expected agent map") @@ -411,6 +412,183 @@ func TestBuildInput_WithAgent(t *testing.T) { } } +// --- Delegation input tests --- + +func TestBuildInput_NoDelegation(t *testing.T) { + inc := newIncludeSet(nil) + pctx := &pipeline.Context{ + Direction: pipeline.Outbound, + Method: "GET", + Path: "/data", + Host: "github-tool-mcp", + Headers: http.Header{}, + } + input := buildInput(pctx, inc, "") + if _, ok := input["delegation"]; ok { + t.Error("delegation should be absent when no hops recorded") + } +} + +func TestBuildInput_WithDelegation(t *testing.T) { + inc := newIncludeSet(nil) + del := &pipeline.DelegationExtension{} + ts := time.Date(2026, 8, 3, 12, 0, 0, 0, time.UTC) + del.AppendHop(pipeline.DelegationHop{ + SubjectID: "agent-team1", + Scopes: []string{"openid", "github-full-access"}, + Audience: "github-tool", + Strategy: "token-exchange", + FromCache: true, + Timestamp: ts, + }) + pctx := &pipeline.Context{ + Direction: pipeline.Outbound, + Method: "POST", + Path: "/mcp", + Host: "github-tool-mcp", + Headers: http.Header{}, + } + pctx.Extensions.Delegation = del + + input := buildInput(pctx, inc, "") + d, ok := input["delegation"].(map[string]any) + if !ok { + t.Fatal("expected delegation map") + } + if d["origin"] != "agent-team1" { + t.Errorf("expected origin agent-team1, got %v", d["origin"]) + } + if d["actor"] != "agent-team1" { + t.Errorf("expected actor agent-team1, got %v", d["actor"]) + } + if d["depth"] != 1 { + t.Errorf("expected depth 1, got %v", d["depth"]) + } + chain, ok := d["chain"].([]map[string]any) + if !ok || len(chain) != 1 { + t.Fatalf("expected chain with 1 hop, got %v", d["chain"]) + } + hop := chain[0] + if hop["audience"] != "github-tool" { + t.Errorf("expected audience github-tool, got %v", hop["audience"]) + } + if hop["strategy"] != "token-exchange" { + t.Errorf("expected strategy token-exchange, got %v", hop["strategy"]) + } + if hop["from_cache"] != true { + t.Errorf("expected from_cache true, got %v", hop["from_cache"]) + } + scopes, ok := hop["scopes"].([]string) + if !ok || len(scopes) != 2 || scopes[1] != "github-full-access" { + t.Errorf("expected scopes [openid github-full-access], got %v", hop["scopes"]) + } + if hop["timestamp"] != "2026-08-03T12:00:00Z" { + t.Errorf("expected RFC3339 timestamp, got %v", hop["timestamp"]) + } +} + +// TestBuildInput_OutboundIdentityFromDelegation verifies the outbound leg — +// where there is no validated JWT — synthesizes input.identity in the SAME +// shape as inbound, sourced from the delegation hop, while STILL emitting the +// full input.delegation chain. subject = origin, client_id = the agent's own +// client, scopes = the last hop's scopes. +func TestBuildInput_OutboundIdentityFromDelegation(t *testing.T) { + inc := newIncludeSet(nil) + del := &pipeline.DelegationExtension{} + del.AppendHop(pipeline.DelegationHop{ + SubjectID: "dev-user", + Scopes: []string{"openid", "agent-team1-github-tool-aud"}, + Audience: "github-tool", + Strategy: "token-exchange", + }) + pctx := &pipeline.Context{ + Direction: pipeline.Outbound, + Method: "POST", + Path: "/mcp", + Host: "github-tool-mcp", + Headers: http.Header{}, + // Identity nil — the outbound forward-proxy leg has no validated JWT. + } + pctx.Extensions.Delegation = del + + input := buildInput(pctx, inc, "github-agent") + + id, ok := input["identity"].(map[string]any) + if !ok { + t.Fatal("expected synthesized identity map on outbound") + } + if id["subject"] != "dev-user" { + t.Errorf("subject = %v, want dev-user", id["subject"]) + } + if id["client_id"] != "github-agent" { + t.Errorf("client_id = %v, want github-agent (agent's own client)", id["client_id"]) + } + scopes, ok := id["scopes"].([]string) + if !ok || len(scopes) != 2 || scopes[1] != "agent-team1-github-tool-aud" { + t.Errorf("scopes = %v, want [openid agent-team1-github-tool-aud]", id["scopes"]) + } + // service_id mirrors the last hop's target audience so outbound policy can + // key on input.identity.service_id the same way inbound keys on audience. + if id["service_id"] != "github-tool" { + t.Errorf("service_id = %v, want github-tool (last hop target)", id["service_id"]) + } + // Both signals must coexist: the full chain stays available. + if _, ok := input["delegation"].(map[string]any); !ok { + t.Error("expected input.delegation to still be present alongside synthesized identity") + } +} + +// TestBuildInput_OutboundIdentityOmitsServiceIDWhenAbsent verifies that a hop +// with no recorded target audience (e.g. a non-exchange hop) does not assert an +// empty input.identity.service_id, matching the scopes-omitted behavior. +func TestBuildInput_OutboundIdentityOmitsServiceIDWhenAbsent(t *testing.T) { + inc := newIncludeSet(nil) + del := &pipeline.DelegationExtension{} + del.AppendHop(pipeline.DelegationHop{SubjectID: "dev-user"}) // no Audience, no Scopes + pctx := &pipeline.Context{ + Direction: pipeline.Outbound, + Method: "POST", + Path: "/mcp", + Host: "github-tool-mcp", + Headers: http.Header{}, + } + pctx.Extensions.Delegation = del + + input := buildInput(pctx, inc, "github-agent") + id := input["identity"].(map[string]any) + if _, ok := id["service_id"]; ok { + t.Errorf("service_id should be absent when last hop recorded none, got %v", id["service_id"]) + } + if _, ok := id["scopes"]; ok { + t.Errorf("scopes should be absent when last hop recorded none, got %v", id["scopes"]) + } +} + +// TestBuildInput_ValidatedIdentityWinsOverDelegation confirms that when a +// validated Identity IS present (e.g. an inbound leg that also recorded a +// delegation hop), the real identity is used and NOT overwritten by the +// delegation-synthesized one. +func TestBuildInput_ValidatedIdentityWinsOverDelegation(t *testing.T) { + inc := newIncludeSet(nil) + del := &pipeline.DelegationExtension{} + del.AppendHop(pipeline.DelegationHop{SubjectID: "dev-user", Audience: "tool"}) + pctx := &pipeline.Context{ + Direction: pipeline.Inbound, + Method: "GET", + Path: "/", + Host: "svc", + Headers: http.Header{}, + Identity: testIdentity{}, + } + pctx.Extensions.Delegation = del + + input := buildInput(pctx, inc, "github-agent") + id := input["identity"].(map[string]any) + if id["subject"] != "user-123" || id["client_id"] != "client-abc" { + t.Errorf("validated identity should win: got subject=%v client_id=%v", id["subject"], id["client_id"]) + } +} + // --- A2A input tests --- func TestBuildInput_A2A_LeanMode(t *testing.T) { @@ -432,7 +610,7 @@ func TestBuildInput_A2A_LeanMode(t *testing.T) { Artifact: "some artifact", ErrorMessage: "some error", } - input := buildInput(pctx, inc) + input := buildInput(pctx, inc, "") a2a := input["a2a"].(map[string]any) // Always present @@ -484,7 +662,7 @@ func TestBuildInput_A2A_WithContent(t *testing.T) { Artifact: "artifact data", ErrorMessage: "err msg", } - input := buildInput(pctx, inc) + input := buildInput(pctx, inc, "") a2a := input["a2a"].(map[string]any) parts, ok := a2a["parts"].([]map[string]any) @@ -527,7 +705,7 @@ func TestBuildInput_MCP_LeanMode(t *testing.T) { Result: map[string]any{"content": "result data"}, Err: &pipeline.MCPError{Code: -1, Message: "fail", Data: "detail"}, } - input := buildInput(pctx, inc) + input := buildInput(pctx, inc, "") mcp := input["mcp"].(map[string]any) if mcp["method"] != "tools/call" { @@ -579,7 +757,7 @@ func TestBuildInput_MCP_FullParams(t *testing.T) { "arguments": map[string]any{"title": "Bug"}, }, } - input := buildInput(pctx, inc) + input := buildInput(pctx, inc, "") mcp := input["mcp"].(map[string]any) params := mcp["params"].(map[string]any) if _, ok := params["arguments"]; !ok { @@ -604,7 +782,7 @@ func TestBuildInput_MCP_CustomParamKey(t *testing.T) { "arguments": map[string]any{"large": "data"}, }, } - input := buildInput(pctx, inc) + input := buildInput(pctx, inc, "") mcp := input["mcp"].(map[string]any) params := mcp["params"].(map[string]any) if params["name"] != "read_resource" { @@ -633,7 +811,7 @@ func TestBuildInput_MCP_WithResultAndError(t *testing.T) { Result: map[string]any{"content": "data"}, Err: &pipeline.MCPError{Code: -1, Message: "fail", Data: "detail"}, } - input := buildInput(pctx, inc) + input := buildInput(pctx, inc, "") mcp := input["mcp"].(map[string]any) if _, ok := mcp["result"]; !ok { t.Error("expected result when mcp.result is included") @@ -687,7 +865,7 @@ func TestBuildInput_Inference_LeanMode(t *testing.T) { {ID: "tc1", Name: "create_issue", Arguments: `{"title":"Bug"}`}, }, } - input := buildInput(pctx, inc) + input := buildInput(pctx, inc, "") inf := input["inference"].(map[string]any) // Always present @@ -760,7 +938,7 @@ func TestBuildInput_Inference_WithMessages(t *testing.T) { {Role: "assistant", Content: "Hi there"}, }, } - input := buildInput(pctx, inc) + input := buildInput(pctx, inc, "") inf := input["inference"].(map[string]any) messages, ok := inf["messages"].([]map[string]any) if !ok { @@ -789,7 +967,7 @@ func TestBuildInput_Inference_WithToolsDetail(t *testing.T) { {Name: "create_issue", Description: "Creates issues", Parameters: map[string]any{"type": "object"}}, }, } - input := buildInput(pctx, inc) + input := buildInput(pctx, inc, "") inf := input["inference"].(map[string]any) tools, ok := inf["tools"].([]map[string]any) if !ok { @@ -822,7 +1000,7 @@ func TestBuildInput_Inference_WithCompletion(t *testing.T) { Model: "gpt-4", Completion: "The answer is 42", } - input := buildInput(pctx, inc) + input := buildInput(pctx, inc, "") inf := input["inference"].(map[string]any) if inf["completion"] != "The answer is 42" { t.Errorf("expected completion, got %v", inf["completion"]) @@ -844,7 +1022,7 @@ func TestBuildInput_Inference_WithToolCalls(t *testing.T) { {ID: "tc1", Name: "create_issue", Arguments: `{"title":"Bug"}`}, }, } - input := buildInput(pctx, inc) + input := buildInput(pctx, inc, "") inf := input["inference"].(map[string]any) tcs, ok := inf["tool_calls"].([]map[string]any) if !ok { diff --git a/authbridge/authlib/plugins/tokenexchange/delegation_test.go b/authbridge/authlib/plugins/tokenexchange/delegation_test.go index 96715bd7..455fd964 100644 --- a/authbridge/authlib/plugins/tokenexchange/delegation_test.go +++ b/authbridge/authlib/plugins/tokenexchange/delegation_test.go @@ -1,9 +1,15 @@ package tokenexchange import ( + "crypto/rand" + "crypto/rsa" "reflect" "testing" + "github.com/lestrrat-go/jwx/v2/jwa" + "github.com/lestrrat-go/jwx/v2/jwk" + "github.com/lestrrat-go/jwx/v2/jwt" + "github.com/rossoctl/cortex/authbridge/authlib/auth" "github.com/rossoctl/cortex/authbridge/authlib/pipeline" ) @@ -29,7 +35,7 @@ func TestRecordDelegationHop_AppendsExchangeHop(t *testing.T) { TargetAudience: "github-tool", RequestedScopes: "openid github-tool-aud", } - recordDelegationHop(pctx, result) + recordDelegationHop(pctx, "", result) d := pctx.Extensions.Delegation if d == nil { @@ -58,7 +64,10 @@ func TestRecordDelegationHop_AppendsExchangeHop(t *testing.T) { func TestRecordDelegationHop_SubjectFromIdentityWhenPresent(t *testing.T) { pctx := &pipeline.Context{Identity: stubIdentity{subject: "alice"}} - recordDelegationHop(pctx, &auth.OutboundResult{TargetAudience: "tool", RequestedScopes: ""}) + // A bearer with a different subject is present but MUST be ignored when a + // validated Identity exists — Identity is the trusted source. + other := signSubjectToken(t, "someone-else") + recordDelegationHop(pctx, "Bearer "+other, &auth.OutboundResult{TargetAudience: "tool", RequestedScopes: ""}) if got := pctx.Extensions.Delegation.Chain()[0].SubjectID; got != "alice" { t.Fatalf("subject = %q, want alice", got) } @@ -69,6 +78,79 @@ func TestRecordDelegationHop_SubjectFromIdentityWhenPresent(t *testing.T) { } } +// TestRecordDelegationHop_SubjectFromBearerWhenNoIdentity covers the outbound +// leg: no validated Identity, but the incoming bearer (the RFC 8693 +// subject_token) names the delegated caller. We decode its `sub` best-effort so +// the delegation chain — and the OPA-synthesized outbound input.identity — +// carries the real subject instead of empty. +func TestRecordDelegationHop_SubjectFromBearerWhenNoIdentity(t *testing.T) { + pctx := &pipeline.Context{} // Identity nil + token := signSubjectToken(t, "dev-user") + recordDelegationHop(pctx, "Bearer "+token, &auth.OutboundResult{ + TargetAudience: "github-tool", + RequestedScopes: "openid agent-team1-github-tool-aud", + }) + + d := pctx.Extensions.Delegation + if got := d.Chain()[0].SubjectID; got != "dev-user" { + t.Fatalf("subject = %q, want dev-user", got) + } + if d.Origin != "dev-user" || d.Actor != "dev-user" { + t.Fatalf("origin/actor not derived from bearer: origin=%q actor=%q", d.Origin, d.Actor) + } +} + +// TestRecordDelegationHop_EmptySubjectOnMalformedBearer confirms the fallback +// safe-fails to empty rather than panicking when no usable bearer is present. +func TestRecordDelegationHop_EmptySubjectOnMalformedBearer(t *testing.T) { + pctx := &pipeline.Context{} + recordDelegationHop(pctx, "Bearer not-a-jwt", &auth.OutboundResult{TargetAudience: "tool"}) + if got := pctx.Extensions.Delegation.Chain()[0].SubjectID; got != "" { + t.Fatalf("subject = %q, want empty on malformed bearer", got) + } +} + +// TestSubjectFromToken exercises the unverified decode helper directly. +func TestSubjectFromToken(t *testing.T) { + if got := subjectFromToken(""); got != "" { + t.Errorf("empty header: got %q", got) + } + if got := subjectFromToken("Bearer "); got != "" { + t.Errorf("empty bearer: got %q", got) + } + if got := subjectFromToken("Bearer garbage"); got != "" { + t.Errorf("malformed jwt: got %q", got) + } + token := signSubjectToken(t, "dev-user") + if got := subjectFromToken("Bearer " + token); got != "dev-user" { + t.Errorf("valid jwt: got %q, want dev-user", got) + } +} + +// signSubjectToken mints a signed JWT carrying only the given subject. The +// signature is real (jwx rejects alg=none), but subjectFromToken decodes it +// WITHOUT verification, so the key is throwaway. +func signSubjectToken(t *testing.T, subject string) string { + t.Helper() + privKey, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatal(err) + } + privJWK, err := jwk.FromRaw(privKey) + if err != nil { + t.Fatal(err) + } + tok, err := jwt.NewBuilder().Subject(subject).Build() + if err != nil { + t.Fatal(err) + } + signed, err := jwt.Sign(tok, jwt.WithKey(jwa.RS256, privJWK)) + if err != nil { + t.Fatal(err) + } + return string(signed) +} + // stubIdentity is a minimal pipeline.Identity for the subject-present path. type stubIdentity struct{ subject string } diff --git a/authbridge/authlib/plugins/tokenexchange/plugin.go b/authbridge/authlib/plugins/tokenexchange/plugin.go index 7e24be47..61fa184c 100644 --- a/authbridge/authlib/plugins/tokenexchange/plugin.go +++ b/authbridge/authlib/plugins/tokenexchange/plugin.go @@ -12,6 +12,8 @@ import ( "sync/atomic" "time" + "github.com/lestrrat-go/jwx/v2/jwt" + "github.com/rossoctl/cortex/authbridge/authlib/auth" "github.com/rossoctl/cortex/authbridge/authlib/config" "github.com/rossoctl/cortex/authbridge/authlib/pipeline" @@ -703,7 +705,7 @@ func (p *TokenExchange) OnRequest(ctx context.Context, pctx *pipeline.Context) p return pipeline.DenyStatus(result.DenyStatus, code, result.DenyReason) case auth.ActionReplaceToken: pctx.Headers.Set("Authorization", "Bearer "+result.Token) - recordDelegationHop(pctx, result) + recordDelegationHop(pctx, authHeader, result) reason := "token_replaced" if result.CacheHit { reason = "cache_hit" @@ -774,17 +776,22 @@ func boolStr(b bool) string { // whether it came from cache. // // SubjectID is best-effort: the forward-proxy outbound pctx generally has -// no validated Identity (JWT validation runs on the separate inbound pass), -// so it's populated only when an Identity happens to be present. The -// audience / scopes / strategy / from-cache fields are always known from -// the exchange result and carry the bulk of the signal regardless. -func recordDelegationHop(pctx *pipeline.Context, result *auth.OutboundResult) { +// no validated Identity (JWT validation runs on the separate inbound pass). +// When an Identity is present we use it; otherwise we fall back to the `sub` +// claim of the incoming bearer — the token token-exchange uses as the RFC +// 8693 subject_token — decoded WITHOUT signature verification (see +// subjectFromToken). Either way, empty is a valid result: the audience / +// scopes / strategy / from-cache fields are always known from the exchange +// result and carry the bulk of the signal regardless. +func recordDelegationHop(pctx *pipeline.Context, authHeader string, result *auth.OutboundResult) { if pctx.Extensions.Delegation == nil { pctx.Extensions.Delegation = &pipeline.DelegationExtension{} } subject := "" if pctx.Identity != nil { subject = pctx.Identity.Subject() + } else { + subject = subjectFromToken(authHeader) } pctx.Extensions.Delegation.AppendHop(pipeline.DelegationHop{ SubjectID: subject, @@ -796,6 +803,26 @@ func recordDelegationHop(pctx *pipeline.Context, result *auth.OutboundResult) { }) } +// subjectFromToken best-effort extracts the `sub` claim from an incoming +// Authorization bearer WITHOUT signature verification. On the outbound leg +// there is no validated Identity and no JWKS trust anchor for the caller's +// token, but that token (used as the RFC 8693 subject_token) still names the +// delegated subject. This is used only to enrich observability / policy input +// (delegation provenance), never for an auth decision — so an unverified +// decode is acceptable and mirrors how the protocol parsers read request +// bodies. Returns "" on any error (missing/empty bearer, malformed JWT). +func subjectFromToken(authHeader string) string { + raw := auth.ExtractBearer(authHeader) + if raw == "" { + return "" + } + tok, err := jwt.ParseInsecure([]byte(raw)) + if err != nil { + return "" + } + return tok.Subject() +} + // splitScopes splits a raw space-separated OAuth scope string into a // slice, dropping empty fields. Returns nil (not an empty slice) for an // unscoped exchange so the hop's ScopesGranted stays absent rather than diff --git a/scripts/opa-kind-enable.sh b/scripts/opa-kind-enable.sh index 3da6e25f..a16d9cef 100755 --- a/scripts/opa-kind-enable.sh +++ b/scripts/opa-kind-enable.sh @@ -6,6 +6,10 @@ # mcp-parser, inference-parser) so OPA policies have input.a2a / input.mcp / # input.inference available on both legs, not just input.host. # +# On the outbound leg OPA is placed AFTER token-exchange so policies can +# read input.delegation (the target audience + scopes the agent's token was +# exchanged for). See the overlay comment in Step 3 for the rationale. +# # Does NOT modify charts/rossoctl/values.yaml on disk. The pipeline override # lives in a throwaway temp file merged on top of the real values.yaml via a # second `helm upgrade -f` — Helm layers -f files left-to-right, so the repo @@ -94,32 +98,58 @@ cat > "$OVERLAY_FILE" <<'YAML' # authbridge/demos/ibac/k8s/ibac-patch.yaml. # - opa runs after jwt-validation on inbound so input.identity is set # (see authbridge/docs/opa-migration-guide.md Step 1). -# - token-exchange is intentionally omitted, as in the runbook: this -# overlay demonstrates OPA + parser signals only. Add a token-exchange -# entry back into pipeline.outbound.plugins if you also need -# per-destination token exchange. -pipeline: | - inbound: - plugins: - - name: a2a-parser - - name: mcp-parser - - name: inference-parser - - name: jwt-validation - config: - issuer: "http://keycloak.localtest.me:8080/realms/rossoctl" - keycloak_url: "http://keycloak-service.keycloak.svc:8080" - keycloak_realm: "rossoctl" - - name: opa - config: - bundle_url: "http://bundle-service.rossoctl-system.svc.cluster.local:8080" - outbound: - plugins: - - name: a2a-parser - - name: mcp-parser - - name: inference-parser - - name: opa - config: - bundle_url: "http://bundle-service.rossoctl-system.svc.cluster.local:8080" +# - On OUTBOUND, opa runs AFTER token-exchange so the delegation signal +# is populated: token-exchange records the target audience + granted +# scopes it minted a token for (RFC 8693) into the delegation chain, +# and OPA exposes it as input.delegation (origin, actor, depth, and a +# chain of {subject_id, audience, scopes, strategy, from_cache}). This +# lets outbound policy reason about WHAT the agent's token was +# exchanged for — e.g. "deny github-full-access to non-admin agents" — +# without re-parsing the minted token and without a fail-closed JWT +# gate that would reject passthrough egress. input.identity stays +# empty outbound (no JWT is validated on this leg); input.delegation is +# the outbound identity signal. +# - token-exchange uses the chart's default shape (client-secret identity +# from /shared, passthrough default policy). Per-destination routes +# come from the authproxy-routes ConfigMap; hosts with no route fall +# through unchanged and simply carry no delegation hop. +# NOTE: the rossoctl chart reads the pipeline from `.Values.authBridge.pipeline` +# (a multiline string rendered via tpl() into the namespace +# authbridge-runtime-config ConfigMap — see charts/rossoctl/templates/ +# _helpers.tpl "rossoctl.authbridge-runtime-config-yaml"). The operator webhook +# then uses that ConfigMap's `pipeline:` verbatim as the base for each per-agent +# authbridge-config- ConfigMap. So the override MUST be nested under +# `authBridge.pipeline` — a top-level `pipeline:` key is silently ignored. +authBridge: + pipeline: | + inbound: + plugins: + - name: a2a-parser + - name: mcp-parser + - name: inference-parser + - name: jwt-validation + config: + issuer: "http://keycloak.localtest.me:8080/realms/rossoctl" + keycloak_url: "http://keycloak-service.keycloak.svc:8080" + keycloak_realm: "rossoctl" + - name: opa + config: + bundle_url: "http://bundle-service.rossoctl-system.svc.cluster.local:8080" + outbound: + plugins: + - name: a2a-parser + - name: mcp-parser + - name: inference-parser + - name: token-exchange + config: + keycloak_url: "http://keycloak-service.keycloak.svc:8080" + keycloak_realm: "rossoctl" + default_policy: "passthrough" + identity: + type: "client-secret" + - name: opa + config: + bundle_url: "http://bundle-service.rossoctl-system.svc.cluster.local:8080" YAML echo "==> Step 4/5: helm upgrade (base values.yaml + overlay — base file not modified)" From e4601e89ddf992c608ec4745c0458282e4dbe891 Mon Sep 17 00:00:00 2001 From: Anatoly Koyfman Date: Tue, 4 Aug 2026 16:47:51 +0000 Subject: [PATCH 06/42] opa-kind-driver.sh Signed-off-by: Anatoly Koyfman --- aiac/docs/examples/opa-team1-policy.yaml | 1 + aiac/docs/opa-kind-runbook.md | 41 +- scripts/opa-kind-driver.sh | 525 +++++++++++++++++++++++ 3 files changed, 551 insertions(+), 16 deletions(-) create mode 100755 scripts/opa-kind-driver.sh diff --git a/aiac/docs/examples/opa-team1-policy.yaml b/aiac/docs/examples/opa-team1-policy.yaml index 755ae8f1..fdc14afa 100644 --- a/aiac/docs/examples/opa-team1-policy.yaml +++ b/aiac/docs/examples/opa-team1-policy.yaml @@ -90,6 +90,7 @@ spec: } source_ok if { not input.identity.client_id } + source_ok if { input.identity.client_id == "rossoctl"} source_ok if { some role in source_roles[input.identity.client_id] some scope in role_scopes[role] diff --git a/aiac/docs/opa-kind-runbook.md b/aiac/docs/opa-kind-runbook.md index b70dd34d..e4392245 100644 --- a/aiac/docs/opa-kind-runbook.md +++ b/aiac/docs/opa-kind-runbook.md @@ -334,34 +334,43 @@ TOK=$(curl -s -X POST "http://keycloak.localtest.me:8080/realms/rossoctl/protoco | python3 -c 'import sys,json;print(json.load(sys.stdin)["access_token"])') cat > /tmp/probe.py < **The example CR's `outbound/request.rego` denies this `tools/list` probe.** +> **The example CR's `outbound/request.rego` denies this `tools/list` probe** — +> and because the outbound pipeline includes `mcp-parser`, that denial is +> surfaced the MCP-correct way: a **JSON-RPC 2.0 error frame at HTTP 200** +> (`error.code: -32000`, `error.data.plugin: "opa"`), not an HTTP error status. +> The forward proxy renders a `Reject` for an MCP JSON-RPC request (one with a +> `method` and an `id`) as an application-layer error frame so the caller's MCP +> client sees a single failed tool call rather than a transport break — see +> `writeMCPRejection` in +> `authbridge/authlib/listener/httpx/render.go`. The request is **denied and +> never reaches `github-tool`**; the `HTTP 200` is only the JSON-RPC transport +> envelope. Classify the outcome by the response **body** (an `error` frame = +> denied, a `result` frame = allowed), not the HTTP status. +> > The rule admits a call only when the delegated user's role and the target > service both list the request's `input.mcp.params.name` (the invoked tool). -> A `tools/list` call carries no `params.name`, so neither gate matches, `allow` -> is `false`, and the probe returns `403`/`503` instead of `404`. To see the -> `404` success path, apply only the inbound tier of the CR, or drive a real -> tool invocation whose tool name is present in `subject_role_scopes` and -> `target_scopes` in the outbound rego. +> A `tools/list` call carries no `params.name`, so neither gate matches and +> `allow` is `false`. A non-MCP-shaped rejection (no parser, or a JSON-RPC +> *notification* with no `id`) instead falls through to a plain HTTP `403`; a +> `token-exchange` failure surfaces as `503` before OPA is even consulted. To +> see the full allow path (a `result` frame at HTTP 200), apply only the inbound +> tier of the CR, or drive a real tool invocation whose tool name is present in +> `subject_role_scopes` and `target_scopes` in the outbound rego. ## B.5 — The outbound OPA input, exactly diff --git a/scripts/opa-kind-driver.sh b/scripts/opa-kind-driver.sh new file mode 100755 index 00000000..4f125b59 --- /dev/null +++ b/scripts/opa-kind-driver.sh @@ -0,0 +1,525 @@ +#!/usr/bin/env bash +# opa-kind-driver.sh — execute aiac/docs/opa-kind-runbook.md end-to-end. +# +# This is an automated driver for the AIAC "OPA Kind Cluster Runbook" +# (aiac/docs/opa-kind-runbook.md). It runs every step of that runbook in +# order, prints each step and the result it obtained, prints the OPA `input` +# documents for BOTH the inbound and the outbound legs, and FAILS with a clear +# message the moment an observed result does not match the runbook's stated +# expectation (instead of silently continuing). +# +# What it does, mirroring the runbook 1:1: +# Step 1 enable OPA in both legs (scripts/opa-kind-enable.sh) +# Step 2 verify the starting point (pods, bundle-service, client-id) +# Part A inbound authorization +# A.1 dev-user token carries sub=dev-user +# A.2 baseline: dev-user AND alice both reach the app (HTTP 200) +# A.3 apply the client-scoped policy CR +# A.4 enforced: dev-user -> 200, alice -> 403 +# A.5 print the INBOUND OPA input + assert the decision result +# Part B outbound token-exchange + OPA +# B.1 add the github-tool outbound route +# B.2 grant github-agent the exchange scope (expect HTTP 204) +# B.3 restart github-agent to load the route +# B.4 outbound tools/list probe -> DENIED (JSON-RPC error frame at +# HTTP 200, or 403/503; outbound OPA present) +# B.5 print the OUTBOUND OPA input +# +# Requires: kubectl, helm, kind, python3, curl, and docker (or podman). +# Env vars (runbook defaults shown): +# OPERATOR_DIR path to the rossoctl/operator clone (default: ../operator) +# ROSSOCTL_DIR path to the rossoctl/rossoctl clone (default: ../rossoctl) +# NS agent namespace (default: team1) +# SYS_NS platform namespace (default: rossoctl-system) +# KC Keycloak base URL (default: http://keycloak.localtest.me:8080) +# REALM Keycloak realm (default: rossoctl) +# POLL_SECS max seconds to wait for OPA to poll a new bundle (default: 60) +# SKIP_ENABLE if set to 1, skip Step 1's image rebuild + opa-kind-enable.sh +# and only verify OPA is already wired (fast path when iterating +# on the policy CR against an already-enabled cluster). +# +# Run from the repo root (cortex/): +# OPERATOR_DIR=../operator ROSSOCTL_DIR=../rossoctl ./scripts/opa-kind-driver.sh +# SKIP_ENABLE=1 ./scripts/opa-kind-driver.sh # skip the rebuild, just re-test + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +CORTEX_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" + +# ── Configuration (runbook defaults) ──────────────────────────────────────── +# Sibling repo clones the enable step needs; default to ../operator and +# ../rossoctl (relative to the cortex repo root) when not set, mirroring +# opa-kind-enable.sh. +OPERATOR_DIR="${OPERATOR_DIR:-$(cd "$CORTEX_DIR/../operator" 2>/dev/null && pwd || echo "")}" +ROSSOCTL_DIR="${ROSSOCTL_DIR:-$(cd "$CORTEX_DIR/../rossoctl" 2>/dev/null && pwd || echo "")}" + +NS="${NS:-team1}" +SYS_NS="${SYS_NS:-rossoctl-system}" +KC="${KC:-http://keycloak.localtest.me:8080}" +REALM="${REALM:-rossoctl}" +POLL_SECS="${POLL_SECS:-60}" + +AGENT_LABEL="app.kubernetes.io/name=github-agent" +EXPECTED_SPIFFE="spiffe://localtest.me/ns/${NS}/sa/github-agent" +POLICY_FILE="${CORTEX_DIR}/aiac/docs/examples/opa-team1-policy.yaml" +ENABLE_SCRIPT="${SCRIPT_DIR}/opa-kind-enable.sh" +RESTORE_SCRIPT="${SCRIPT_DIR}/opa-kind-restore.sh" + +# ── Output helpers ────────────────────────────────────────────────────────── +if [ -t 1 ]; then + C_RED=$'\033[31m'; C_GRN=$'\033[32m'; C_YEL=$'\033[33m' + C_CYN=$'\033[36m'; C_BLD=$'\033[1m'; C_RST=$'\033[0m' +else + C_RED=""; C_GRN=""; C_YEL=""; C_CYN=""; C_BLD=""; C_RST="" +fi + +STEP_N=0 +step() { STEP_N=$((STEP_N + 1)); printf '\n%s==> [%02d] %s%s\n' "$C_BLD$C_CYN" "$STEP_N" "$*" "$C_RST"; } +info() { printf ' %s\n' "$*"; } +pass() { printf ' %sPASS%s %s\n' "$C_GRN" "$C_RST" "$*"; } +warn() { printf ' %sWARN%s %s\n' "$C_YEL" "$C_RST" "$*"; } +die() { printf '\n%sFAIL:%s %s\n' "$C_RED$C_BLD" "$C_RST" "$*" >&2; exit 1; } + +# expect_eq