From cd41783a5156de2f0034707f0888b5b5d0af8d0e Mon Sep 17 00:00:00 2001 From: Omer Boehm Date: Thu, 18 Jun 2026 20:05:54 +0300 Subject: [PATCH 1/7] initial rbac commit Signed-off-by: Omer Boehm --- authbridge/demos/github-issue/aiac/aiac.env | 5 + .../demos/github-issue/aiac/config.yaml | 54 + authbridge/demos/github-issue/demo-rbac.md | 1186 +++++++++++++++++ .../demos/github-issue/setup_keycloak.py | 972 +++++++++++++- .../demos/github-issue/setup_keycloak_rbac.py | 648 +++++++++ 5 files changed, 2825 insertions(+), 40 deletions(-) create mode 100644 authbridge/demos/github-issue/aiac/aiac.env create mode 100644 authbridge/demos/github-issue/aiac/config.yaml create mode 100644 authbridge/demos/github-issue/demo-rbac.md create mode 100644 authbridge/demos/github-issue/setup_keycloak_rbac.py diff --git a/authbridge/demos/github-issue/aiac/aiac.env b/authbridge/demos/github-issue/aiac/aiac.env new file mode 100644 index 000000000..f72b4fb4e --- /dev/null +++ b/authbridge/demos/github-issue/aiac/aiac.env @@ -0,0 +1,5 @@ +# Keycloak connection settings +KEYCLOAK_URL=http://keycloak.localtest.me:8080 +KEYCLOAK_ADMIN_USERNAME=admin +KEYCLOAK_ADMIN_PASSWORD=admin +REALM_NAME=kagenti diff --git a/authbridge/demos/github-issue/aiac/config.yaml b/authbridge/demos/github-issue/aiac/config.yaml new file mode 100644 index 000000000..b48044118 --- /dev/null +++ b/authbridge/demos/github-issue/aiac/config.yaml @@ -0,0 +1,54 @@ +# Client configurations with roles +# Each client can have multiple roles, each role gets its own audience scope +# Optional 'secret' parameter: if not provided, Keycloak will auto-generate the client secret +clients: + - client_id: "kagenti" + secret: "demo-ui-secret" + direct_access_grants: true + roles: + - name: "demo-ui" + description: "Access to the demo UI interface" + - client_id: "spiffe://localtest.me/ns/team1/sa/git-issue-agent" + #secret: "github-agent-secret-for-testing-do-not-use" + direct_access_grants: true + roles: + - name: "github-agent" + description: "Access to the GitHub agent service" + - client_id: "github-tool" + #secret: "github-tool-secret-for-testing-do-not-use" + roles: + - name: "github-tool-aud" + description: "Provides access to public GitHub repositories" + - name: "github-full-access" + description: "Provides access to private GitHub repositories" + +# Realm roles (can be composites of client roles) +realm_roles: + - name: "developer" + description: "R&D team members" + - name: "tech-support" + description: "Technical support staff providing customer assistance" + - name: "sales" + description: "Sales team members managing customer relationships" + +# Client audience targets +client_audience_targets: + kagenti: + - spiffe://localtest.me/ns/team1/sa/git-issue-agent + spiffe://localtest.me/ns/team1/sa/git-issue-agent: + - github-tool + github-tool: [] + +# User configurations +users: + - username: "alice" + roles: + - "developer" + + - username: "bob" + roles: + - "tech-support" + + - username: "charlie" + roles: + - "sales" diff --git a/authbridge/demos/github-issue/demo-rbac.md b/authbridge/demos/github-issue/demo-rbac.md new file mode 100644 index 000000000..a9b821dda --- /dev/null +++ b/authbridge/demos/github-issue/demo-rbac.md @@ -0,0 +1,1186 @@ +# GitHub Issue Agent Demo with AuthBridge (Manual Deployment) + +This guide walks through deploying the **GitHub Issue Agent** with **AuthBridge** +using `kubectl` commands exclusively. All resources — agent, tool, ConfigMaps, and +secrets — are deployed via Kubernetes manifests. + +For a UI-driven deployment using the Kagenti dashboard, see [demo-ui.md](demo-ui.md). +For a simpler getting-started demo, see the [Weather Agent demo](../weather-agent/demo-ui.md). + +## What This Demo Shows + +In this demo, we deploy the GitHub Issue Agent and GitHub MCP Tool with AuthBridge +providing end-to-end security: + +1. **Agent identity** — The agent automatically registers with Keycloak using its + SPIFFE ID, with no hardcoded secrets +2. **Inbound validation** — Requests to the agent are validated (JWT signature, + issuer, and audience) before reaching the agent code +3. **Transparent token exchange** — When the agent calls the GitHub tool, AuthBridge + automatically exchanges the user's token for one scoped to the tool +4. **Subject preservation** — The end user's identity (`sub` claim) is preserved + through the exchange, enabling per-user authorization at the tool +5. **Scope-based access** — The tool uses token scopes to determine whether to + grant public or privileged GitHub API access + +## Architecture + +``` +┌──────────────────────────────────────────────────────────────────────────────────┐ +│ KUBERNETES CLUSTER │ +│ │ +│ ┌───────────────────────────────────────────────────────────────────────────┐ │ +│ │ GIT-ISSUE-AGENT POD (namespace: team1) │ │ +│ │ │ │ +│ │ ┌──────────────────┐ ┌────────────────────────────────────────────┐ │ │ +│ │ │ git-issue-agent │ │ AuthBridge sidecar (combined image) │ │ │ +│ │ │ (A2A agent, │ │ Container name depends on resolved mode: │ │ │ +│ │ │ port 8000) │ │ proxy-sidecar (default): authbridge-proxy│ │ │ +│ │ └──────────────────┘ │ envoy-sidecar: envoy-proxy │ │ │ +│ │ │ │ │ │ +│ │ │ Inbound: │ │ │ +│ │ │ - Validates JWT (signature + issuer + │ │ │ +│ │ │ audience via JWKS) │ │ │ +│ │ │ - Returns 401 for invalid/missing tokens │ │ │ +│ │ │ Outbound: │ │ │ +│ │ │ - HTTP: Exchanges token via Keycloak │ │ │ +│ │ │ → aud: github-tool │ │ │ +│ │ │ - HTTPS: TLS passthrough │ │ │ +│ │ │ │ │ │ +│ │ │ spiffe-helper bundled inside the image │ │ │ +│ │ │ (gated by SPIRE_ENABLED). │ │ │ +│ │ │ Keycloak client registration is │ │ │ +│ │ │ operator-managed; the resulting Secret │ │ │ +│ │ │ is mounted at /shared/client-{id,secret}.txt│ │ │ +│ │ └────────────────────────────────────────────┘ │ │ +│ └───────────────────────────────────────────────────────────────────────────┘ │ +│ │ │ +│ Exchanged token │(aud: github-tool) │ +│ ▼ │ +│ ┌───────────────────────────────────────────────────────────────────────────┐ │ +│ │ GITHUB-TOOL POD (namespace: team1) │ │ +│ │ │ │ +│ │ ┌──────────────────────────────────────────────────────────────────┐ │ │ +│ │ │ github-tool (port 9090) │ │ │ +│ │ │ - Validates token (aud: github-tool, issuer: Keycloak) │ │ │ +│ │ │ - Token has github-full-access scope? → PRIVILEGED_ACCESS_PAT │ │ │ +│ │ │ - Otherwise → PUBLIC_ACCESS_PAT │ │ │ +│ │ └──────────────────────────────────────────────────────────────────┘ │ │ +│ └───────────────────────────────────────────────────────────────────────────┘ │ +│ │ +├──────────────────────────────────────────────────────────────────────────────────┤ +│ EXTERNAL SERVICES │ +│ │ +│ ┌──────────────────────┐ ┌──────────────────────┐ │ +│ │ SPIRE (namespace: │ │ KEYCLOAK (namespace: │ │ +│ │ spire) │ │ keycloak) │ │ +│ │ │ │ │ │ +│ │ Provides SPIFFE │ │ - kagenti realm │ │ +│ │ identities (SVIDs) │ │ - token exchange │ │ +│ └──────────────────────┘ └──────────────────────┘ │ +└──────────────────────────────────────────────────────────────────────────────────┘ +``` + +## Token Flow + +``` + User/Client Agent Pod Keycloak GitHub Tool + │ │ │ │ + │ 1. Get token │ │ │ + │ (client_credentials│ │ │ + │ or user login) │ │ │ + │───────────────────────────────────────────►│ │ + │◄───────────────────────────────────────────│ │ + │ Token: aud=Agent's SPIFFE ID │ │ + │ │ │ │ + │ 2. Send prompt │ │ │ + │ + Bearer token │ │ │ + │────────────────────►│ │ │ + │ │ │ │ + │ ┌──────┴──────┐ │ │ + │ │ AuthBridge │ │ │ + │ │ INBOUND │ │ │ + │ │ validates: │ │ │ + │ │ ✓ signature│ │ │ + │ │ ✓ issuer │ │ │ + │ │ ✓ audience │ │ │ + │ └──────┬──────┘ │ │ + │ │ │ │ + │ Agent processes prompt, │ │ + │ calls GitHub tool │ │ + │ │ │ │ + │ ┌──────┴──────┐ │ │ + │ │ AuthBridge │ │ │ + │ │ OUTBOUND │ │ │ + │ │ intercepts │ │ │ + │ └──────┬──────┘ │ │ + │ │ │ │ + │ │ 3. Token Exchange │ │ + │ │ (RFC 8693) │ │ + │ │─────────────────────►│ │ + │ │◄─────────────────────│ │ + │ │ New token: │ │ + │ │ aud=github-tool │ │ + │ │ sub= │ │ + │ │ │ │ + │ │ 4. Forward request │ │ + │ │ with exchanged token│ │ + │ │───────────────────────────────────────►│ + │ │ │ │ + │ │ │ Tool validates│ + │ │ │ token, checks │ + │ │ │ scopes, uses │ + │ │ │ appropriate │ + │ │ │ GitHub PAT │ + │ │ │ │ + │ │◄───────────────────────────────────────│ + │◄────────────────────│ 5. Response │ │ + │ GitHub issues │ │ │ +``` + +## Key Security Properties + +| Property | How It's Achieved | +|----------|-------------------| +| **No hardcoded agent secrets** | Client credentials dynamically generated by client-registration using SPIFFE ID | +| **Identity-based auth** | SPIFFE ID is both the pod identity and the Keycloak client ID | +| **Inbound validation** | AuthBridge validates all incoming requests (JWT signature, issuer, audience) before they reach the agent | +| **Audience-scoped tokens** | Original token scoped to Agent; exchanged token scoped to GitHub tool | +| **User attribution** | `sub` and `preferred_username` preserved through token exchange | +| **Scope-based authorization** | Tool uses token scopes to determine access level (public vs. privileged) | +| **Transparent to agent code** | The agent makes plain HTTP calls; AuthBridge handles all token management | + +### Inbound Verification (AuthProxy) + +The AuthBridge sidecar includes an Envoy-based ext_proc that validates **every** +inbound request before it reaches the agent. The ext_proc (port 9090) performs +three checks on the `Authorization: Bearer ` header: + +1. **Signature** — Verifies the JWT signature against Keycloak's JWKS keys + (auto-refreshed via cache). Rejects tampered or forged tokens. +2. **Issuer** — Confirms the `iss` claim matches the expected Keycloak realm + (`ISSUER` in `authbridge-config`). Rejects tokens from other identity providers. +3. **Audience** — Confirms the `aud` claim includes the agent's CLIENT_ID + (from `/shared/client-id.txt`). Rejects tokens intended for a different agent. + +Requests that fail any check receive an immediate `401 Unauthorized` response from +Envoy — the agent application never sees them. This is tested in +[Step 8a–8c](#step-8-test-the-authbridge-flow). + +--- + +## Prerequisites + +Ensure you have completed the Kagenti platform setup as described in the +[Installation Guide](https://github.com/kagenti/kagenti/blob/main/docs/install.md). + +You should also have: +- The [kagenti-extensions](https://github.com/kagenti/kagenti-extensions) repo cloned +- The [agent-examples](https://github.com/kagenti/agent-examples) repo cloned +- Python 3.9+ with `venv` support +- **Ollama running** with the `ibm/granite4:latest` model (or another model of your choice) +- Two GitHub Personal Access Tokens (PATs): + - `` — access to public repositories only + - `` — access to all repositories + +### Creating GitHub Personal Access Tokens + +Follow [GitHub's instructions](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens#creating-a-fine-grained-personal-access-token) +to create fine-grained PAT tokens: + +- **``** — select **Public Repositories (read-only)** access +- **``** — select **All Repositories** access + +This lets you demonstrate finer-grained authorization: a user with full access +can see issues on all repositories, while a user with partial access can only +see issues on public repositories. + +### Build and Load Container Images + + + +The agent and tool container images must be built locally and loaded into the kind +cluster (they are not published to a public registry): + +```bash +cd /agent-examples + +# Build the GitHub tool image +docker build -t ghcr.io/kagenti/agent-examples/github-tool:latest \ + -f mcp/github_tool/Dockerfile mcp/github_tool/ + +# Build the GitHub Issue Agent image +docker build -t ghcr.io/kagenti/agent-examples/git-issue-agent:latest \ + -f a2a/git_issue_agent/Dockerfile a2a/git_issue_agent/ + +# Load both images into the kind cluster +kind load docker-image --name kagenti ghcr.io/kagenti/agent-examples/github-tool:latest +kind load docker-image --name kagenti ghcr.io/kagenti/agent-examples/git-issue-agent:latest +``` + +--- + +## Step 1: Deploy the Operator Webhook with AuthBridge Support + +The [kagenti-operator](https://github.com/kagenti/kagenti-operator) webhook automatically injects AuthBridge sidecars into agent deployments. See the operator docs for installation. + +Once the webhook is deployed, create the namespace and apply the ConfigMaps: + +```bash +kubectl create namespace team1 +kubectl apply -f authbridge/demos/github-issue/k8s/configmaps-webhook.yaml -n team1 +``` + +> **Note:** If you want to use a different namespace, set `AUTHBRIDGE_NAMESPACE=` and update all subsequent commands accordingly. + +--- + +## Step 2: Apply Demo ConfigMaps + +The Kagenti installer creates default ConfigMaps (`authbridge-config`, +`spiffe-helper-config`, `envoy-config`) and the `keycloak-admin-secret` Secret +in the target namespace with the correct `kagenti` realm settings and 300s Envoy +timeouts. No manual secret creation is needed for this demo. + +> If your Keycloak admin credentials differ from the default (`admin`/`admin`), +> update the secret: +> ```bash +> kubectl create secret generic keycloak-admin-secret -n team1 \ +> --from-literal=KEYCLOAK_ADMIN_USERNAME= \ +> --from-literal=KEYCLOAK_ADMIN_PASSWORD= \ +> --dry-run=client -o yaml | kubectl apply -f - +> ``` + +Apply the demo-specific ConfigMaps — the `authproxy-routes` ConfigMap +configures per-route token exchange (target audience and scopes for the +`github-tool` host), and `authbridge-config` sets the agent's SPIFFE ID for +inbound audience validation. Apply this **before** deploying the agent. + +```bash +cd authbridge + +# Apply demo ConfigMaps (authbridge-config and authproxy-routes) +kubectl apply -f demos/github-issue/k8s/configmaps.yaml +``` + +> **Note:** If you're using a different namespace, edit +> `configmaps.yaml` and update the `namespace` field. + +--- + +## Step 3: Create GitHub PAT Secret + +The GitHub tool needs PAT tokens to access the GitHub API. Create a Kubernetes secret +with your tokens: + +```bash +export PRIVILEGED_ACCESS_PAT= +export PUBLIC_ACCESS_PAT= +``` + +Provide your actual GitHub Personal Access Tokens. + +```bash +kubectl create secret generic github-tool-secrets -n team1 \ + --from-literal=INIT_AUTH_HEADER="Bearer $PRIVILEGED_ACCESS_PAT" \ + --from-literal=UPSTREAM_HEADER_TO_USE_IF_IN_AUDIENCE="Bearer $PRIVILEGED_ACCESS_PAT" \ + --from-literal=UPSTREAM_HEADER_TO_USE_IF_NOT_IN_AUDIENCE="Bearer $PUBLIC_ACCESS_PAT" +``` + +--- + +## Step 4: Deploy the GitHub Tool + +Deploy the GitHub MCP tool as a target service. This deployment does **not** get +AuthBridge injection (it is the target, not the caller): + +```bash +kubectl apply -f demos/github-issue/k8s/github-tool-deployment.yaml +# Wait for the tool to be ready: +kubectl wait --for=condition=available --timeout=120s deployment/github-tool -n team1 +``` + +--- + +## Step 5: Deploy the GitHub Issue Agent + +Deploy the agent with AuthBridge labels. The webhook will automatically +inject one combined AuthBridge sidecar (post-#411). In envoy-sidecar mode +it also injects a `proxy-init` init container for iptables setup: + +```bash +kubectl apply -f demos/github-issue/k8s/git-issue-agent-deployment.yaml +# Wait for the agent to be ready: +kubectl wait --for=condition=available --timeout=180s deployment/git-issue-agent -n team1 +``` + +> **Note:** The agent may take longer to start because it waits on +> `/shared/client-{id,secret}.txt` to be populated by the operator's +> `ClientRegistrationReconciler` before the AuthBridge sidecar becomes +> ready. + +### Verify injected containers + +Confirm that the webhook injected the combined AuthBridge sidecar: + +```bash +kubectl get pod -n team1 -l app.kubernetes.io/name=git-issue-agent \ + -o jsonpath='{.items[0].spec.containers[*].name}' +``` + +Expected (proxy-sidecar mode, the cluster default): + +```txt +agent authbridge-proxy +``` + +Or, in envoy-sidecar mode: + +```txt +agent envoy-proxy +``` + +--- + +## Step 6: Validate the Deployment + +### Check pod status + +```bash +kubectl get pods -n team1 +``` + +Expected output: + +``` +NAME READY STATUS RESTARTS AGE +git-issue-agent-58768bdb67-xxxxx 2/2 Running 0 2m +github-tool-7f8c9d6b44-yyyyy 1/1 Running 0 3m +``` + +### Check operator-managed client registration + +After kagenti-extensions#411 / kagenti-operator#361, registration runs in +the kagenti-operator (outside the workload pod). Verify the resulting +Secret was mounted into the agent's sidecar: + +```bash +kubectl get pod -n team1 -l app.kubernetes.io/name=git-issue-agent \ + -o jsonpath='{.items[0].spec.volumes[?(@.secret)].secret.secretName}' +# Expect a Secret name starting with: kagenti-keycloak-client-credentials- +``` + +Follow the operator-side registration: + +```bash +kubectl logs -n kagenti-system deployment/kagenti-controller-manager \ + | grep -iE "clientregistration|git-issue-agent" | tail -20 +``` + +Expected (operator log lines, exact format depends on the operator's +log format): + +``` +ClientRegistrationReconciler: ensured Keycloak client + spiffe://localtest.me/ns/team1/sa/git-issue-agent +ClientRegistrationReconciler: wrote Secret + kagenti-keycloak-client-credentials- +``` + +### Check agent logs + +```bash +kubectl logs deployment/git-issue-agent -n team1 -c agent +``` + +Expected: + +``` +SVID JWT file /opt/jwt_svid.token not found. +SVID JWT file /opt/jwt_svid.token not found. +CLIENT_SECRET file not found at /shared/secret.txt +INFO: JWKS_URI is set - using JWT Validation middleware +INFO: Started server process [17] +INFO: Waiting for application startup. +INFO: Application startup complete. +INFO: Uvicorn running on http://0.0.0.0:8000 (Press CTRL+C to quit) +``` + + + +> **These warnings are expected and harmless.** The agent's built-in auth code +> probes for SVID and client-secret files at startup. With AuthBridge, these files +> are produced and consumed inside the AuthBridge sidecar (and the operator's +> ClientRegistrationReconciler), not by the agent container directly. The agent +> falls back to JWKS-based JWT validation (`JWKS_URI is set`), which is the +> correct behavior — AuthBridge handles inbound JWT validation and outbound +> token exchange on behalf of the agent. +> These warnings will be removed once the agent's built-in auth logic is cleaned up +> ([kagenti/agent-examples#129](https://github.com/kagenti/agent-examples/issues/129)). + +### Verify Ollama is running + +The agent uses LLM for inference. You can use Ollama, OpenAI, or anything else you want. + +For this demo we used Ollama. You should see `ibm/granite4:latest` (or whichever model you configured) on the list. +If Ollama is not running, start it in a separate terminal (`ollama serve`) and ensure the +model is pulled (`ollama pull ibm/granite4:latest`). + +```bash +ollama pull ibm/granite4:latest +ollama list +ollama serve +``` + +> **Tip:** If using a different model, update `TASK_MODEL_ID` in +> `git-issue-agent-deployment.yaml` before deploying. + +> **Note:** The `git-issue-agent-deployment.yaml` file defaults to `LLM_API_BASE=http://host.docker.internal:11434` and `OLLAMA_API_BASE=http://host.docker.internal:11434`, +> which reaches Ollama running on your host machine via the Kind/Docker Desktop gateway. +> If you deploy Ollama inside the cluster instead, modify the `git-issue-agent-deployment.yaml` file directly or patch the agent: +> ```bash +> kubectl set env deployment/git-issue-agent -n team1 -c agent \ +> LLM_API_BASE="http://ollama.ollama.svc:11434" \ +> OLLAMA_API_BASE="http://ollama.ollama.svc:11434" +> ``` + +--- + +## Step 7: Configure Keycloak + +Keycloak needs to be configured with the correct clients, scopes, and users for the +token exchange flow between the agent and the GitHub tool. + +### Port-forward Keycloak (if needed) + +The setup script connects to Keycloak at `http://keycloak.localtest.me:8080`. +If Keycloak is not already reachable at that address (e.g., via an ingress), +start a port-forward in a separate terminal: + +```bash +kubectl port-forward service/keycloak-service -n keycloak 8080:8080 +``` + +### Run the setup script + +```bash +cd authbridge + +# Create virtual environment (if not already done) +uv sync +source venv/bin/activate +uv pip install --upgrade pip +uv pip install -r requirements.txt + +# Run the Keycloak setup for this demo +python demos/github-issue/setup_keycloak.py +``` + +This creates: + +| Resource | Name | Purpose | +|----------|------|---------| +| **Realm** | `kagenti` | Keycloak realm for the demo | +| **Client** | `spiffe://localtest.me/ns/team1/sa/git-issue-agent` | Agent client with github-agent role | +| **Client** | `github-tool` | Target audience for token exchange | +| **Client Role** | `github-agent` | Access role for the GitHub issue agent | +| **Client Role** | `github-tool-aud` | Audience role for GitHub tool access | +| **Client Role** | `github-full-access` | Full access role for GitHub tool operations | +| **Scope** | `github-agent` | Client scope for agent access (DEFAULT for agent client) | +| **Scope** | `github-tool-aud` | Client scope for tool audience (OPTIONAL for agent) | +| **Scope** | `github-full-access` | Client scope for privileged access (OPTIONAL for agent) | +| **Realm Role** | `regular` | Standard user access level | +| **Realm Role** | `privileged` | Elevated user access level | +| **User** | `alice` (password: `alice123`) | User with 'regular' realm role | +| **User** | `bob` (password: `bob123`) | User with 'privileged' realm role | + +--- + +## Step 8: Test the AuthBridge Flow + +These tests verify both **inbound** JWT validation and **outbound** token exchange +end-to-end. + +### Setup + +```bash +# Start a test client pod (sends requests from outside the agent pod) +kubectl run test-client --image=nicolaka/netshoot -n team1 --restart=Never -- sleep 3600 +kubectl wait --for=condition=ready pod/test-client -n team1 --timeout=30s +``` + +### 8a. Agent Card - Public Endpoint (No Token Required) + +The `/.well-known/agent.json` endpoint is publicly accessible — authbridge +[bypasses JWT validation](https://github.com/kagenti/kagenti-extensions/pull/133) +for `/.well-known/*`, `/healthz`, `/readyz`, and `/livez` by default: + +```bash +kubectl exec test-client -n team1 -- curl -s \ + http://git-issue-agent:8080/.well-known/agent.json | jq .name +# Expected: "Github issue agent" +``` + +### 8b. Inbound Rejection - No Token + +Non-public endpoints require a valid JWT: + +```bash +kubectl exec test-client -n team1 -- curl -s \ + http://git-issue-agent:8080/ +# Expected: {"error":"unauthorized","message":"missing Authorization header"} +``` + +### 8c. Inbound Rejection - Invalid Token + +A malformed or tampered token fails the JWKS signature check: + +```bash +kubectl exec test-client -n team1 -- curl -s \ + -H "Authorization: Bearer invalid-token" \ + http://git-issue-agent:8080/ +# Expected: {"error":"unauthorized","message":"token validation failed: ..."} +``` + +### 8d. Inbound Rejection - Wrong Issuer + +A properly signed token from a **different Keycloak realm** has a valid signature +(same Keycloak instance) but the wrong `iss` claim. AuthProxy rejects it because +the issuer does not match the configured `ISSUER` in `authbridge-config`: + +```bash +# Get a valid token from the master realm (different issuer than "kagenti") +WRONG_ISSUER_TOKEN=$(kubectl exec test-client -n team1 -- curl -s \ + "http://keycloak-service.keycloak.svc:8080/realms/master/protocol/openid-connect/token" \ + -d "grant_type=password" \ + -d "client_id=admin-cli" \ + -d "username=admin" \ + -d "password=admin" | jq -r '.access_token') + +kubectl exec test-client -n team1 -- curl -s \ + -H "Authorization: Bearer $WRONG_ISSUER_TOKEN" \ + http://git-issue-agent:8080/ +# Expected: {"error":"unauthorized","message":"token validation failed: invalid issuer: expected http://keycloak.localtest.me:8080/realms/kagenti, got ..."} +``` + +> **Why this matters:** Even though the token is cryptographically valid (signed by +> the same Keycloak instance), AuthProxy's issuer check ensures only tokens from the +> correct realm are accepted. This prevents cross-realm token reuse attacks. + +### 8e. Valid Token - Agent Card + +```bash +# The AuthBridge sidecar's container name depends on the resolved mode: +# proxy-sidecar (default): authbridge-proxy +# envoy-sidecar: envoy-proxy +SIDECAR=$(kubectl get pod -n team1 -l app.kubernetes.io/name=git-issue-agent \ + -o jsonpath='{.items[0].spec.containers[*].name}' | tr ' ' '\n' \ + | grep -E '^(authbridge-proxy|envoy-proxy)$' | head -1) + +# Get the agent's client credentials (mounted by the operator-managed +# ClientRegistration controller via the kagenti-keycloak-client-credentials +# Secret, then mounted into the sidecar at /shared/). +CLIENT_ID=$(kubectl exec deployment/git-issue-agent -n team1 -c "$SIDECAR" -- cat /shared/client-id.txt) +CLIENT_SECRET=$(kubectl exec deployment/git-issue-agent -n team1 -c "$SIDECAR" -- cat /shared/client-secret.txt) +echo "Agent Client ID: $CLIENT_ID" + +# Get a service account token (simulating what the UI would obtain) +TOKEN=$(kubectl exec test-client -n team1 -- curl -s -X POST \ + "http://keycloak-service.keycloak.svc:8080/realms/kagenti/protocol/openid-connect/token" \ + -d "grant_type=client_credentials" \ + -d "client_id=$CLIENT_ID" \ + -d "client_secret=$CLIENT_SECRET" | jq -r '.access_token') + +kubectl exec test-client -n team1 -- curl -s \ + -H "Authorization: Bearer $TOKEN" \ + http://git-issue-agent:8080/.well-known/agent.json | jq +``` + +```json +# Expected: Agent card JSON response +{ + "capabilities": { "streaming": true }, + "defaultInputModes": ["text"], + "defaultOutputModes": ["text"], + "description": "Answer queries about Github issues", + "name": "Github issue agent", + "preferredTransport": "JSONRPC", + "protocolVersion": "0.3.0", + ... +} +``` + +### 8f. Check Inbound Validation Logs + +Verify that AuthProxy validated (and rejected) the inbound requests from steps 8b–8e. + +> **Tip:** The combined AuthBridge sidecar runs the inbound JWT validation +> plugin. Inbound validation messages include the `[Inbound]` marker — +> filter by `"[Inbound]"` to see only inbound output. +> +> The container name depends on the resolved mode (`authbridge-proxy` for +> proxy-sidecar, `envoy-proxy` for envoy-sidecar). The `$SIDECAR` shell +> variable from §8e auto-detects it. + +```bash +kubectl logs deployment/git-issue-agent -n team1 -c "$SIDECAR" 2>&1 | grep "\[Inbound\]" +``` + +Expected (one line per request in 8b–8e): + +``` +[Inbound] Missing Authorization header +[Inbound] JWT validation failed: failed to parse/validate token: ... +[Inbound] JWT validation failed: invalid issuer: expected http://keycloak.localtest.me:8080/realms/kagenti, got ... +[Inbound] Token validated - issuer: http://keycloak.localtest.me:8080/realms/kagenti, audience: [...] +[Inbound] JWT validation succeeded, forwarding request +``` + +> **Note:** Outbound token exchange logs (`[Token Exchange] ...`) will only appear +> after [Step 9](#step-9-end-to-end--query-github-issues), when the agent calls the +> GitHub tool. + +--- + +## Step 9: End-to-End — Query GitHub Issues + +This is the full demo flow — the request goes through inbound validation, reaches +the agent, the agent calls the GitHub tool (token exchange happens transparently), +and returns the result. + +> **Prerequisite:** This step uses the `test-client` pod created in +> [Step 8 Setup](#setup). If you already deleted it, re-create it first. + +> **Note:** JWT tokens passed via `kubectl exec -- curl -H "Authorization: Bearer $TOKEN"` +> can get mangled by double shell expansion. To avoid this, we exec into the test-client +> pod and run all commands from inside it. + +### 9a. Open a shell inside the test-client pod + +```bash +kubectl exec -it test-client -n team1 -- sh +``` + +### 9b. Get credentials and a token + +Inside the test-client pod, run: + +```bash +# Get a Keycloak admin token from the kagenti realm +ADMIN_TOKEN=$(curl -s http://keycloak-service.keycloak.svc:8080/realms/kagenti/protocol/openid-connect/token \ + -d "grant_type=password" \ + -d "client_id=admin-cli" \ + -d "username=admin" \ + -d "password=admin" | jq -r ".access_token") + +echo "Admin token length: ${#ADMIN_TOKEN}" +# Expected: Admin token length: 782 +# If 0 or 4 (null), Keycloak is not reachable or credentials are wrong — stop here. + +# Look up the agent's client in the kagenti realm. +# The client ID is the SPIFFE ID (URL-encoded in the query parameter). +SPIFFE_ID="spiffe://localtest.me/ns/team1/sa/git-issue-agent" +CLIENTS=$(curl -s -H "Authorization: Bearer $ADMIN_TOKEN" \ + "http://keycloak-service.keycloak.svc:8080/admin/realms/kagenti/clients" \ + --data-urlencode "clientId=$SPIFFE_ID" --get) +INTERNAL_ID=$(echo "$CLIENTS" | jq -r ".[0].id") +CLIENT_ID=$(echo "$CLIENTS" | jq -r ".[0].clientId") + +echo "Internal ID: $INTERNAL_ID" +echo "Client ID: $CLIENT_ID" +# If you see "null", the client was not found — check setup_keycloak.py ran. + +# Get the client secret (extract directly from the client listing; +# the Keycloak /client-secret endpoint returns null for auto-registered clients) +CLIENT_SECRET=$(echo "$CLIENTS" | jq -r ".[0].secret") + +echo "Secret length: ${#CLIENT_SECRET}" + +# Get an OAuth token for the agent +TOKEN=$(curl -s -X POST \ + "http://keycloak-service.keycloak.svc:8080/realms/kagenti/protocol/openid-connect/token" \ + -d "grant_type=client_credentials" \ + --data-urlencode "client_id=$CLIENT_ID" \ + --data-urlencode "client_secret=$CLIENT_SECRET" | jq -r ".access_token") + +echo "Token length: ${#TOKEN}" +``` + +You should see output like: + +``` +Admin token length: 1543 +Internal ID: 8577145b-cb77-4bbc-abde-1f5eb7643344 +Client ID: spiffe://localtest.me/ns/team1/sa/git-issue-agent +Secret length: 32 +Token length: 1165 +``` + +> **Troubleshooting:** If `INTERNAL_ID` shows `null`, the Keycloak query didn't find +> the client. Verify `$ADMIN_TOKEN` is not empty (Keycloak reachable?) and that +> `setup_keycloak.py` was run. You can also list all clients with: +> `curl -s -H "Authorization: Bearer $ADMIN_TOKEN" "http://keycloak-service.keycloak.svc:8080/admin/realms/kagenti/clients" | jq '.[].clientId'` + +### 9c. Send a prompt to the agent + +Still inside the test-client pod, send the A2A v0.3.0 request: + +> **Note:** This request may take 30-60 seconds as the agent calls the LLM and the +> GitHub tool. The Envoy route timeout is set to 300 seconds (5 minutes) to +> accommodate slow LLM inference. If you still see `upstream request timeout`, +> check **Retrieving async results** below. +> +> **Important:** Ollama must be running and the model must be loaded before sending +> this request. If you see `OllamaException - upstream connect error`, ensure +> `ollama serve` is running and the model is pulled (see +> [Step 7: Verify Ollama](#verify-ollama-is-running)). + +```bash +curl -s --max-time 300 \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -X POST http://git-issue-agent:8080/ \ + -d '{ + "jsonrpc": "2.0", + "id": "test-1", + "method": "message/send", + "params": { + "message": { + "role": "user", + "messageId": "msg-001", + "parts": [{"type": "text", "text": "List issues in kagenti/kagenti repo"}] + } + } + }' | jq +``` + +### 9d. Exit the test-client pod + +```bash +exit +``` + +### Retrieving async results + +If the request timed out but the agent completed the task in the background, +check the agent logs for the task ID: + +```bash +kubectl logs deployment/git-issue-agent -n team1 -c agent --tail=50 +# Look for: Task saved successfully / TaskState.completed +``` + +Then exec back into the test-client pod and retrieve the result: + +```bash +kubectl exec -it test-client -n team1 -- sh + +# (re-run the token setup from Step 9b above, then:) +curl -s --max-time 10 \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -X POST http://git-issue-agent:8080/ \ + -d '{ + "jsonrpc": "2.0", + "id": "get-1", + "method": "tasks/get", + "params": { + "id": "" + } + }' | jq '.result.artifacts[0].parts[0].text' +``` + +### 9e. Verify AuthProxy Logs (Inbound + Outbound) + +Check the AuthBridge sidecar logs to confirm both inbound validation and +outbound token exchange are working. The combined sidecar handles both +directions; the container name depends on the resolved mode +(`authbridge-proxy` for proxy-sidecar, `envoy-proxy` for envoy-sidecar). + +**Inbound validation logs** (JWT signature, issuer, audience checks): + +```bash +kubectl logs deployment/git-issue-agent -n team1 -c "$SIDECAR" 2>&1 | grep -i "inbound" +``` + +Expected output: + +``` +[Inbound] Token validated - issuer: http://keycloak.localtest.me:8080/realms/kagenti, audience: [spiffe://localtest.me/ns/team1/sa/git-issue-agent ...] +[Inbound] JWT validation succeeded, forwarding request +``` + +If you ran the rejection tests (8b, 8c, 8d), you should also see: + +``` +[Inbound] Missing Authorization header +[Inbound] JWT validation failed: failed to parse/validate token: ... +[Inbound] JWT validation failed: invalid issuer: expected http://keycloak.localtest.me:8080/realms/kagenti, got ... +``` + +**Outbound token exchange logs** (RFC 8693 token exchange for the GitHub tool): + +```bash +kubectl logs deployment/git-issue-agent -n team1 -c "$SIDECAR" 2>&1 | grep "^2026/" | grep "\[Token Exchange\]" +``` + +Expected: + +``` +[Token Exchange] Token URL: http://keycloak-service.keycloak.svc:8080/realms/kagenti/protocol/openid-connect/token +[Token Exchange] Client ID: spiffe://localtest.me/ns/team1/sa/git-issue-agent +[Token Exchange] Audience: github-tool +[Token Exchange] Scopes: openid github-tool-aud github-full-access +[Token Exchange] Successfully exchanged token +[Token Exchange] Successfully exchanged token, replacing Authorization header +``` + +### Clean Up Test Client + +```bash +kubectl delete pod test-client -n team1 --ignore-not-found +``` + +--- + +## Step 10: Access Control — Alice vs Bob +This step demonstrates **scope-based access control**: two users with different +privilege levels get different GitHub API access through the same agent. + +| User | Token Scope | Tool PAT Used | Public Repos | Private Repos | +|------|-------------|---------------|:------------:|:-------------:| +| **Alice** | `openid` (no `github-full-access`) | `PUBLIC_ACCESS_PAT` | Yes | No | +| **Bob** | `openid github-full-access` | `PRIVILEGED_ACCESS_PAT` | Yes | Yes | + +The flow: +1. User authenticates with Keycloak using `password` grant +2. Request is sent to Agent (on behalf of User) +3. Agent invokes Tool to perform its github task +4. AuthBridge exchanges the token +5. The GitHub tool checks for `REQUIRED_SCOPE` (`github-full-access`) in the exchanged token +6. Tokens with the scope get the privileged PAT; tokens without get the public-only PAT + +> **Prerequisite:** You need a **private** GitHub repository that the `PRIVILEGED_ACCESS_PAT` +> can access but the `PUBLIC_ACCESS_PAT` cannot. Replace `` +> below with your own private repo. + +### 10a. Open a shell inside the test-client pod + +```bash +kubectl run test-client --image=nicolaka/netshoot -n team1 --restart=Never -- sleep 3600 2>/dev/null +kubectl wait --for=condition=ready pod/test-client -n team1 --timeout=30s +kubectl exec -it test-client -n team1 -- sh +``` + +### 10b. Get agent credentials + +Inside the test-client pod, get the agent's client credentials (needed to request +user tokens that include the agent's audience): + +```bash +ADMIN_TOKEN=$(curl -s http://keycloak-service.keycloak.svc:8080/realms/kagenti/protocol/openid-connect/token \ + -d "grant_type=password" \ + -d "client_id=admin-cli" \ + -d "username=admin" \ + -d "password=admin" | jq -r ".access_token") + +SPIFFE_ID="spiffe://localtest.me/ns/team1/sa/git-issue-agent" +CLIENTS=$(curl -s -H "Authorization: Bearer $ADMIN_TOKEN" \ + "http://keycloak-service.keycloak.svc:8080/admin/realms/kagenti/clients" \ + --data-urlencode "clientId=$SPIFFE_ID" --get) +INTERNAL_ID=$(echo "$CLIENTS" | jq -r ".[0].id") +CLIENT_ID=$(echo "$CLIENTS" | jq -r ".[0].clientId") +CLIENT_SECRET=$(echo "$CLIENTS" | jq -r ".[0].secret") +echo "Client ID: $CLIENT_ID Secret length: ${#CLIENT_SECRET}" +``` + +### 10c. Test as Alice (public access only) + +Alice authenticates with Keycloak using `password` grant. + +```bash +ALICE_TOKEN=$(curl -s -X POST \ + "http://keycloak-service.keycloak.svc:8080/realms/kagenti/protocol/openid-connect/token" \ + -d "grant_type=password" \ + -d "username=alice" \ + -d "password=alice123" \ + --data-urlencode "client_id=$CLIENT_ID" \ + --data-urlencode "client_secret=$CLIENT_SECRET" | jq -r ".access_token") + +echo "Alice token length: ${#ALICE_TOKEN}" +echo "Alice scopes: $(echo $ALICE_TOKEN | cut -d. -f2 | base64 -d 2>/dev/null | jq -r '.scope')" +``` + +**Alice queries a public repo** (should succeed): + +```bash +curl -s --max-time 300 \ + -H "Authorization: Bearer $ALICE_TOKEN" \ + -H "Content-Type: application/json" \ + -X POST http://git-issue-agent:8080/ \ + -d '{ + "jsonrpc": "2.0", + "id": "alice-public", + "method": "message/send", + "params": { + "message": { + "role": "user", + "messageId": "msg-alice-1", + "parts": [{"type": "text", "text": "List issues in kagenti/kagenti repo"}] + } + } + }' | jq '.result.artifacts[0].parts[0].text' | head -5 +``` + +**Alice queries a private repo** (should fail — PUBLIC_ACCESS_PAT cannot access it): + +```bash +curl -s --max-time 300 \ + -H "Authorization: Bearer $ALICE_TOKEN" \ + -H "Content-Type: application/json" \ + -X POST http://git-issue-agent:8080/ \ + -d '{ + "jsonrpc": "2.0", + "id": "alice-private", + "method": "message/send", + "params": { + "message": { + "role": "user", + "messageId": "msg-alice-2", + "parts": [{"type": "text", "text": "List issues in "}] + } + } + }' | jq '.result.artifacts[0].parts[0].text' | head -5 +``` + +> **Expected:** Alice's request for the private repo fails because the GitHub tool +> uses `PUBLIC_ACCESS_PAT`, which has no access to private repositories. + +### 10d. Test as Bob (privileged access) + +Bob authenticates with Keycloak using `password` grant. + +```bash +BOB_TOKEN=$(curl -s -X POST \ + "http://keycloak-service.keycloak.svc:8080/realms/kagenti/protocol/openid-connect/token" \ + -d "grant_type=password" \ + -d "username=bob" \ + -d "password=bob123" \ + --data-urlencode "client_id=$CLIENT_ID" \ + --data-urlencode "client_secret=$CLIENT_SECRET" | jq -r ".access_token") + +echo "Bob token length: ${#BOB_TOKEN}" +echo "Bob scopes: $(echo $BOB_TOKEN | cut -d. -f2 | base64 -d 2>/dev/null | jq -r '.scope')" +``` + +**Bob queries the same private repo** (should succeed — PRIVILEGED_ACCESS_PAT has access): + +```bash +curl -s --max-time 300 \ + -H "Authorization: Bearer $BOB_TOKEN" \ + -H "Content-Type: application/json" \ + -X POST http://git-issue-agent:8080/ \ + -d '{ + "jsonrpc": "2.0", + "id": "bob-private", + "method": "message/send", + "params": { + "message": { + "role": "user", + "messageId": "msg-bob-1", + "parts": [{"type": "text", "text": "List issues in "}] + } + } + }' | jq '.result.artifacts[0].parts[0].text' | head -5 +``` + +> **Expected:** Bob's request succeeds because the exchanged token contains +> `github-full-access`, so the GitHub tool uses `PRIVILEGED_ACCESS_PAT`. + +### 10e. Verify scope-based PAT selection in tool logs + +Check the GitHub tool logs to confirm that different PATs were selected based on scopes: + +```bash +exit +kubectl logs deployment/github-tool -n team1 | grep -E "REQUIRED_SCOPE|scopes" +``` + +Expected output (two requests, different scope outcomes): + +``` +This OIDC user has scopes "openid email profile" +The REQUIRED_SCOPE "github-full-access" NOT IN scopes [openid email profile] +This OIDC user has scopes "openid email profile github-full-access" +The REQUIRED_SCOPE "github-full-access" in scopes [openid email profile github-full-access] +``` + +### 10f. Clean up + +```bash +kubectl delete pod test-client -n team1 --ignore-not-found +``` + +--- + +## How AuthBridge Changes the Original Demo + +| Aspect | Original Demo | With AuthBridge | +|--------|--------------|-----------------| +| **Agent secrets** | Manual PAT token configuration | Dynamic credentials via SPIFFE + client-registration | +| **Inbound auth** | No validation | AuthBridge validates JWT (signature, issuer, audience) via ext_proc | +| **Token management** | Agent code handles tokens | Transparent sidecar — agent code unchanged | +| **Token for tool** | Same PAT token passed through | OAuth token exchange (RFC 8693) | +| **User attribution** | No user tracking | `sub` claim preserved through exchange | +| **Access control** | Single PAT for all users | Scope-based: public vs. privileged | + +--- + +## Troubleshooting + +### Invalid Client or Invalid Client Credentials + +**Symptom:** `{"error":"invalid_client","error_description":"Invalid client or Invalid client credentials"}` + +**Cause:** The `keycloak-admin-secret` Secret or `authbridge-config` ConfigMap was missing +or incorrect at startup, so the client-registration sidecar couldn't register the client. + +**Fix:** + +```bash +# 1. Verify the keycloak-admin-secret exists +kubectl get secret keycloak-admin-secret -n team1 + +# 2. Verify the authbridge-config ConfigMap has the correct realm +kubectl get configmap authbridge-config -n team1 -o jsonpath='{.data.KEYCLOAK_REALM}' +# Should show: kagenti + +# 3. Re-apply the demo ConfigMap and restart +kubectl apply -f demos/github-issue/k8s/configmaps.yaml +kubectl rollout restart deployment/git-issue-agent -n team1 +``` + +### Client Registration Can't Reach Keycloak + +**Symptom:** `Connection refused` when connecting to Keycloak + +**Fix:** Ensure the proxy-init container excludes the Keycloak port from iptables redirect. +Check that `OUTBOUND_PORTS_EXCLUDE: "8080"` is set in the proxy-init env vars. + +### Token Exchange Fails with "Audience not found" + +**Symptom:** `{"error":"invalid_client","error_description":"Audience not found"}` + +**Fix:** The `github-tool` client must exist in Keycloak. Run `setup_keycloak.py`. + +### Token Exchange Fails with "Client is not within the token audience" + +**Symptom:** Token exchange returns `access_denied` + +**Fix:** The `agent-team1-git-issue-agent-aud` scope must be a realm default. +Run `setup_keycloak.py` to set it up. + +### Agent Pod Not Starting + +**Symptom:** Pod shows 1/2 (or 0/2) containers ready. + +**Fix:** Check the agent and the AuthBridge sidecar; if the issue is +operator-managed registration not finishing, the workload pod waits on +`/shared/client-{id,secret}.txt`. + +```bash +# AuthBridge sidecar — name depends on resolved mode: +# proxy-sidecar (default): authbridge-proxy +# envoy-sidecar: envoy-proxy +kubectl logs deployment/git-issue-agent -n team1 -c authbridge-proxy +kubectl logs deployment/git-issue-agent -n team1 -c agent + +# Operator-managed registration: +kubectl logs -n kagenti-system deployment/kagenti-controller-manager \ + | grep -iE "clientregistration|git-issue-agent" | tail -20 +``` + +### GitHub Tool Returns 401 + +**Symptom:** Tool rejects the exchanged token + +**Fix:** Verify the tool's environment variables match the Keycloak configuration: +- `ISSUER` should be `http://keycloak.localtest.me:8080/realms/kagenti` +- `AUDIENCE` should be `github-tool` + +### Upstream Request Timeout + +**Symptom:** `upstream request timeout` from Envoy + +**Cause:** The LLM inference takes longer than the Envoy route timeout. + +**Fix:** The installer's `envoy-config` ConfigMap sets route and ext_proc +timeouts to 300 seconds (5 min). If you still hit timeouts, verify the +ConfigMap has the correct values: + +```bash +kubectl get configmap envoy-config -n team1 -o jsonpath='{.data.envoy\.yaml}' | grep "timeout:" +``` + +If you see `30s` values instead of `300s`, reinstall Kagenti (the installer +creates the correct defaults) and restart the agent: + +```bash +kubectl rollout restart deployment/git-issue-agent -n team1 +``` + +--- + +## Cleanup + +### Delete Deployments + +```bash +kubectl delete -f demos/github-issue/k8s/git-issue-agent-deployment.yaml +kubectl delete -f demos/github-issue/k8s/github-tool-deployment.yaml +kubectl delete secret github-tool-secrets -n team1 +kubectl delete pod test-client -n team1 --ignore-not-found +``` + +### Delete ConfigMaps + +```bash +kubectl delete -f demos/github-issue/k8s/configmaps.yaml +``` + +### Delete Namespace (removes everything) + +```bash +kubectl delete namespace team1 +``` + +### Remove Webhook (optional) + +```bash +kubectl delete mutatingwebhookconfiguration kagenti-webhook-authbridge-mutating-webhook-configuration +``` + +--- + +## Files Reference + +| File | Description | +|------|-------------| +| `demos/github-issue/demo-manual.md` | This guide | +| `demos/github-issue/demo-ui.md` | UI-driven deployment guide | +| `demos/github-issue/setup_keycloak.py` | Keycloak configuration script | +| `demos/github-issue/k8s/configmaps.yaml` | Demo-specific authbridge-config override | +| `demos/github-issue/k8s/git-issue-agent-deployment.yaml` | Agent deployment with AuthBridge labels | +| `demos/github-issue/k8s/github-tool-deployment.yaml` | GitHub tool deployment (no injection) | + +## Next Steps + +- **UI Deployment**: See [demo-ui.md](demo-ui.md) for deploying via the Kagenti dashboard +- **AuthBridge Binary**: See the [AuthBridge README](../../cmd/authbridge/README.md) for inbound + JWT validation and outbound token exchange internals +- **Token-Exchange Routes**: See the [routes-configuration guide](../token-exchange-routes/README.md) for + route-based token exchange to multiple tool services +- **Access Policies**: See the [access policies proposal](../../PROPOSAL-access-policies.md) + for role-based delegation control +- **AuthBridge Overview**: See the [AuthBridge README](../../README.md) for architecture details diff --git a/authbridge/demos/github-issue/setup_keycloak.py b/authbridge/demos/github-issue/setup_keycloak.py index a0479205f..c083f88d0 100644 --- a/authbridge/demos/github-issue/setup_keycloak.py +++ b/authbridge/demos/github-issue/setup_keycloak.py @@ -1,44 +1,74 @@ """ setup_keycloak.py - Keycloak Setup for GitHub Issue Agent + AuthBridge Demo -This script configures Keycloak for running the GitHub Issue Agent demo with -AuthBridge transparent token exchange. - -Architecture: - UI (user) → gets token (aud: Agent's SPIFFE ID) → sends to Agent - ↓ - Agent Pod (git-issue-agent + AuthBridge sidecars) - | - | Agent calls GitHub Tool with user's token - v - AuthProxy (Envoy) - intercepts, validates inbound, exchanges outbound - | - | Token Exchange → audience "github-tool" - v - GitHub Tool (validates token, uses appropriate GitHub PAT) - -Clients created: -- github-tool: Target audience for token exchange (the MCP GitHub tool) - -Client Scopes created: -- agent---aud: Adds Agent's SPIFFE ID to token audience (realm DEFAULT) -- github-tool-aud: Adds "github-tool" to exchanged tokens (realm OPTIONAL) -- github-full-access: Optional scope for privileged GitHub API access (realm OPTIONAL) - -Demo Users created: -- alice: Regular user — tokens requested without github-full-access scope → public access -- bob: Privileged user — tokens requested with scope=github-full-access → full access - -Note on scope model: - github-full-access is a realm OPTIONAL scope. Optional scopes are NOT automatically - included in tokens — the client must explicitly request them via the "scope" parameter - in the token request. In a production system you would enforce per-user scope access - via role-based policies. In this demo the calling client controls which scope to - request for each user (see demo-manual.md Step 9). - -Usage: - python setup_keycloak.py - python setup_keycloak.py --namespace myns --service-account mysa +This script supports two modes: + + Manual mode (no config file): + Configures Keycloak for running the GitHub Issue Agent demo with + AuthBridge transparent token exchange. + + Architecture: + UI (user) → gets token (aud: Agent's SPIFFE ID) → sends to Agent + ↓ + Agent Pod (git-issue-agent + AuthBridge sidecars) + | + | Agent calls GitHub Tool with user's token + v + AuthProxy (Envoy) - intercepts, validates inbound, exchanges outbound + | + | Token Exchange → audience "github-tool" + v + GitHub Tool (validates token, uses appropriate GitHub PAT) + + Clients created: + - github-tool: Target audience for token exchange (the MCP GitHub tool) + + Client Scopes created: + - agent---aud: Adds Agent's SPIFFE ID to token audience (realm DEFAULT) + - github-tool-aud: Adds "github-tool" to exchanged tokens (realm OPTIONAL) + - github-full-access: Optional scope for privileged GitHub API access (realm OPTIONAL) + + Demo Users created: + - alice: Regular user — tokens requested without github-full-access scope → public access + - bob: Privileged user — tokens requested with scope=github-full-access → full access + + Note on scope model: + github-full-access is a realm OPTIONAL scope. Optional scopes are NOT automatically + included in tokens — the client must explicitly request them via the "scope" parameter + in the token request. In a production system you would enforce per-user scope access + via role-based policies. In this demo the calling client controls which scope to + request for each user (see demo-manual.md Step 9). + + Usage: + python setup_keycloak.py + python setup_keycloak.py --namespace myns --service-account mysa + + RBAC mode (config file supplied via -rbac flag): + Creates a realm with clients, roles, client scopes, audience mappers, + and users to demonstrate role-based access control through OAuth2 token exchange. + + Before provisioning, existing artifacts declared in the config (clients, + client roles, realm roles, and client scopes) are deleted so re-running + the script starts from a clean slate. Operator-registered scopes (e.g. + agent-*-aud created by the kagenti-operator's ClientRegistrationReconciler) + are intentionally left alone. + + Usage: + python setup_keycloak.py -rbac config_file.yaml [--reset-only] + + Arguments: + -rbac config_file.yaml Path to main configuration YAML file + + Flags: + --reset-only Run the cleanup pass (initialize_realm_state) and exit + without provisioning. Useful for tearing the demo + state down between runs. + + Environment variables loaded from .env file + + Configuration files: (assumed to be under 'aiac' directory relative to script dir) + .env - Keycloak connection settings and realm name + config.yaml - Main configuration (clients, roles, users, scope_to_client) Security Note: - This script uses default Keycloak admin credentials (username: "admin", password: "admin") @@ -47,11 +77,20 @@ """ import argparse +import json import os import sys +from pathlib import Path +from typing import Any, Dict, List, Tuple +import yaml +from dotenv import load_dotenv from keycloak import KeycloakAdmin, KeycloakGetError, KeycloakPostError +# =========================================================================== +# Manual setup — module-level configuration +# =========================================================================== + # Default configuration KEYCLOAK_URL = os.environ.get("KEYCLOAK_URL", "http://keycloak.localtest.me:8080") KEYCLOAK_REALM = os.environ.get("KEYCLOAK_REALM", "kagenti") @@ -90,10 +129,30 @@ ] +# =========================================================================== +# Manual helper functions +# =========================================================================== + + def get_spiffe_id(namespace: str, service_account: str) -> str: return f"spiffe://{SPIFFE_TRUST_DOMAIN}/ns/{namespace}/sa/{service_account}" +def ensure_admin_in_realm(keycloak_admin, username, password): + """Ensure the admin user exists in the target realm with a non-temporary password. + + Fixes 'invalid_grant: Invalid user credentials' when requesting tokens directly + from the realm — the admin user lives in master but not in the target realm. + """ + user_id = keycloak_admin.get_user_id(username) + if not user_id: + keycloak_admin.create_user({"username": username, "enabled": True, "emailVerified": True}) + user_id = keycloak_admin.get_user_id(username) + print(f"Created user '{username}' in realm '{KEYCLOAK_REALM}'.") + keycloak_admin.set_user_password(user_id, password, temporary=False) + print(f"Password set as non-temporary for '{username}' in realm '{KEYCLOAK_REALM}'.") + + def get_or_create_realm(keycloak_admin, realm_name): try: realms = keycloak_admin.get_realms() @@ -190,7 +249,7 @@ def get_or_create_user(keycloak_admin, user_config): raise -def main(): +def main_manual(): parser = argparse.ArgumentParser(description="Setup Keycloak for GitHub Issue Agent + AuthBridge demo") parser.add_argument( "--namespace", @@ -235,6 +294,12 @@ def main(): print(" kubectl port-forward service/keycloak-service -n keycloak 8080:8080") sys.exit(1) + # Ensure admin password is non-temporary + admin_user_id = master_admin.get_user_id(KEYCLOAK_ADMIN_USERNAME) + if admin_user_id: + master_admin.set_user_password(admin_user_id, KEYCLOAK_ADMIN_PASSWORD, temporary=False) + print(f"Admin password set as non-temporary for '{KEYCLOAK_ADMIN_USERNAME}'.") + # Create realm print(f"\n--- Setting up realm: {KEYCLOAK_REALM} ---") get_or_create_realm(master_admin, KEYCLOAK_REALM) @@ -248,6 +313,9 @@ def main(): user_realm_name="master", ) + # Ensure admin user exists in the kagenti realm so direct token requests succeed + ensure_admin_in_realm(keycloak_admin, KEYCLOAK_ADMIN_USERNAME, KEYCLOAK_ADMIN_PASSWORD) + # --------------------------------------------------------------- # Create github-tool client (target audience for token exchange) # --------------------------------------------------------------- @@ -487,5 +555,829 @@ def main(): ) +# =========================================================================== +# Config-file-based setup +# =========================================================================== + +# --------------------------------------------------------------------------- +# 1. Configuration loading +# --------------------------------------------------------------------------- + + +def load_main_config(config_file: Path) -> Dict[str, Any]: + """Load main configuration from YAML file.""" + if not config_file.exists(): + raise FileNotFoundError(f"Configuration file not found: {config_file}") + + with open(config_file, "r") as f: + return yaml.safe_load(f) + + +def get_config_value(config: Dict[str, Any], *keys, default=None, env_var=None) -> Any: + """Get configuration value with fallback to environment variable and default.""" + if env_var and os.environ.get(env_var): + return os.environ.get(env_var) + + value = config + for key in keys: + if isinstance(value, dict) and key in value: + value = value[key] + else: + return default + + return value if value != config else default + + +def load_keycloak_config() -> Tuple[str, str]: + """Read and validate non-sensitive Keycloak connection settings. + + Credentials are intentionally not returned to avoid tainting non-sensitive + variables. They are consumed directly by connect_admin. + """ + keycloak_url = os.getenv("KEYCLOAK_URL") + realm = os.getenv("REALM_NAME") + admin_username = os.getenv("KEYCLOAK_ADMIN_USERNAME") + admin_password = os.getenv("KEYCLOAK_ADMIN_PASSWORD") + + if not all([keycloak_url, realm, admin_username, admin_password]): + raise ValueError( + "Missing required environment variables. Please ensure .env file contains " + "KEYCLOAK_URL, KEYCLOAK_ADMIN_USERNAME, KEYCLOAK_ADMIN_PASSWORD, and REALM_NAME" + ) + + assert isinstance(keycloak_url, str) + assert isinstance(realm, str) + return keycloak_url, realm + + +def connect_admin(server_url: str, realm_name: str) -> KeycloakAdmin: + """Build a KeycloakAdmin client authenticating against the master realm.""" + return KeycloakAdmin( + server_url=server_url, + username=os.environ["KEYCLOAK_ADMIN_USERNAME"], + password=os.environ["KEYCLOAK_ADMIN_PASSWORD"], + realm_name=realm_name, + user_realm_name="master", + ) + + +# --------------------------------------------------------------------------- +# 2. Realm creation +# --------------------------------------------------------------------------- + + +def create_realm(admin: KeycloakAdmin, realm: str) -> None: + """Create the demo realm if it does not already exist.""" + print(f"\n=== Creating realm: {realm} ===") + try: + admin.create_realm( + { + "realm": realm, + "enabled": True, + "accessTokenLifespan": 600, + "verifyEmail": False, + "registrationEmailAsUsername": False, + } + ) + print(f" Created realm: {realm}") + except KeycloakPostError: + print(f" Realm {realm} already exists, continuing...") + + +# --------------------------------------------------------------------------- +# 3. Reset realm state +# --------------------------------------------------------------------------- + + +def preserve_client_secrets(admin: KeycloakAdmin, clients_config: List[Dict[str, Any]]) -> Dict[str, str]: + """Preserve secrets from existing clients that don't have explicit secrets in config. + + Returns: + Dict mapping client_id to secret for clients that should preserve their secret + """ + print("\nPreserving client secrets:") + preserved_secrets: Dict[str, str] = {} + + for client_config in clients_config: + client_id = client_config["client_id"] + # Only preserve if no explicit secret in config + if not client_config.get("secret"): + existing_secret = get_existing_client_secret(admin, client_id) + if existing_secret: + preserved_secrets[client_id] = existing_secret + print(f" ✓ Preserved secret for: {client_id}") + else: + print(f" - No existing secret for: {client_id}") + + return preserved_secrets + + +def initialize_realm_state(admin: KeycloakAdmin, main_config: Dict[str, Any]) -> Dict[str, str]: + """Delete client roles, clients, realm roles, and client scopes defined in config so re-provisioning \ +starts clean. + + Returns: + Dict mapping client_id to preserved secret (for clients without explicit secret in config) + """ + clients_config = main_config.get("clients", []) + realm_roles_config = main_config.get("realm_roles", []) + + # Preserve secrets before deleting clients + preserved_secrets = preserve_client_secrets(admin, clients_config) + + delete_client_roles(admin, clients_config) + delete_clients(admin, clients_config) + delete_realm_roles(admin, realm_roles_config) + delete_client_scopes(admin, clients_config) + + return preserved_secrets + + +def delete_client_scopes(admin: KeycloakAdmin, clients_config: List[Dict[str, Any]]) -> None: + """Delete client scopes whose names match roles declared in clients_config. + + Operator-registered scopes (e.g. agent-*-aud) are not in clients_config roles + and are intentionally left alone. + """ + print("\nDeleting client scopes:") + if not clients_config: + print(" No clients in configuration") + return + scope_names = { + role["name"] if isinstance(role, dict) else role for c in clients_config for role in c.get("roles", ["access"]) + } + try: + existing = {s["name"]: s["id"] for s in admin.get_client_scopes()} + except Exception as e: + print(f" ✗ Failed to list client scopes: {e}") + return + for name in scope_names: + if name not in existing: + print(f" - Scope not found: {name}") + continue + try: + admin.delete_client_scope(existing[name]) + print(f" ✓ Deleted client scope: {name}") + except Exception as e: + print(f" ✗ Failed to delete scope {name}: {e}") + + +def delete_client_roles(admin: KeycloakAdmin, clients_config: List[Dict[str, Any]]) -> None: + print("\nDeleting client roles:") + if not clients_config: + print(" No clients in configuration") + return + for client_config in clients_config: + client_id = client_config["client_id"] + client_roles = client_config.get("roles", ["access"]) + try: + internal_id = admin.get_client_id(client_id) + except Exception as e: + print(f" - Skipping roles for {client_id}: {e}") + continue + if not internal_id: + print(f" - Client not found, skipping roles: {client_id}") + continue + for role in client_roles: + role_name = role["name"] if isinstance(role, dict) else role + try: + admin.delete_client_role(internal_id, role_name) + print(f" ✓ Deleted client role: {client_id}.{role_name}") + except Exception as e: + print(f" - Role not found or error: {client_id}.{role_name} ({e})") + + +def delete_clients(admin: KeycloakAdmin, clients_config: List[Dict[str, Any]]) -> None: + print("\nDeleting clients:") + if not clients_config: + print(" No clients in configuration") + return + for client_config in clients_config: + client_id = client_config["client_id"] + try: + internal_id = admin.get_client_id(client_id) + if internal_id: + admin.delete_client(internal_id) + print(f" ✓ Deleted client: {client_id}") + else: + print(f" - Client not found: {client_id}") + except Exception as e: + print(f" ✗ Failed to delete client {client_id}: {e}") + + +def delete_realm_roles(admin: KeycloakAdmin, realm_roles_config: List[str]) -> None: + print("\nDeleting realm roles:") + if not realm_roles_config: + print(" No realm roles in configuration") + return + for role_name in realm_roles_config: + try: + admin.delete_realm_role(role_name) + print(f" ✓ Deleted realm role: {role_name}") + except Exception as e: + print(f" - Realm role not found or error: {role_name} ({e})") + + +# --------------------------------------------------------------------------- +# 4. Create clients +# --------------------------------------------------------------------------- + + +def get_existing_client_secret(admin: KeycloakAdmin, client_id: str) -> str | None: + """Retrieve the secret of an existing client, if it exists. + + Returns: + The client secret if the client exists and has a secret, None otherwise. + """ + try: + internal_id = admin.get_client_id(client_id) + if not internal_id: + return None + + # Get the full client representation which includes the secret + client_repr = admin.get_client(internal_id) + return client_repr.get("secret") + except Exception: + # Client doesn't exist or error retrieving it + return None + + +def create_clients( + admin: KeycloakAdmin, clients_config: List[Dict[str, Any]], preserved_secrets: Dict[str, str] +) -> Dict[str, Dict[str, Any]]: + """Create every client in config and return a {client_id: {id, secret, roles}} map. + + Args: + admin: Keycloak admin client + clients_config: List of client configurations + preserved_secrets: Dict of client_id -> secret for clients to preserve + """ + print("\n=== Creating clients ===") + client_ids: Dict[str, Dict[str, Any]] = {} + for client_config in clients_config: + client_id = client_config["client_id"] + client_secret = client_config.get("secret") + direct_access_enabled = client_config.get("direct_access_grants", False) + + # If no secret is provided in config, use preserved secret if available + if not client_secret and client_id in preserved_secrets: + client_secret = preserved_secrets[client_id] + + payload = build_client_payload(client_id, client_secret, direct_access_enabled) + internal_id = create_client_idempotent(admin, payload) + + if client_secret: + if client_config.get("secret"): + print(" Secret: (configured)") + else: + print(" Secret: (preserved from existing client)") + else: + print(" Secret: (auto-generated by Keycloak)") + if direct_access_enabled: + print(" Direct access grants: enabled") + + client_ids[client_id] = { + "id": internal_id, + "secret": client_secret, + "roles": client_config.get("roles", ["access"]), + } + return client_ids + + +def build_client_payload(client_id: str, client_secret: str | None, direct_access_enabled: bool) -> Dict[str, Any]: + """Build the Keycloak client representation payload.""" + payload: Dict[str, Any] = { + "clientId": client_id, + "publicClient": False, + "serviceAccountsEnabled": True, + "directAccessGrantsEnabled": direct_access_enabled, + "standardFlowEnabled": False, + "fullScopeAllowed": False, + "attributes": {"standard.token.exchange.enabled": "true"}, + } + if client_secret: + payload["secret"] = client_secret + return payload + + +def create_client_idempotent(admin: KeycloakAdmin, payload: dict) -> str: + """Create a client or return existing internal ID.""" + client_id = payload["clientId"] + try: + internal_id = admin.create_client(payload) + print(f" Created client: {client_id}") + return internal_id + except KeycloakPostError: + internal_id = admin.get_client_id(client_id) + if internal_id is None: + raise ValueError(f"Client '{client_id}' not found and could not be created") + print(f" Using existing client: {client_id}") + return internal_id + + +# --------------------------------------------------------------------------- +# 5. Create client roles +# --------------------------------------------------------------------------- + + +def create_client_roles(admin: KeycloakAdmin, client_ids: Dict[str, Dict[str, Any]]) -> None: + print("\n=== Creating client roles ===") + for client_name, client_info in client_ids.items(): + for role in client_info["roles"]: + role_name = role["name"] if isinstance(role, dict) else role + create_client_role_safe(admin, client_info["id"], role_name, client_name) + + # NOTE: We do NOT add target client roles to source client scope mappings. + # This prevents target audiences from appearing in initial login tokens. + # Token exchange will still work because: + # 1. Target client scopes are assigned as DEFAULT to source clients + # 2. Scope-to-role mappings filter which scopes are included based on user's roles + # 3. During token exchange, Keycloak uses the requested audience to determine which scopes to include + print("\n=== Skipping target client role scope mappings (prevents initial token pollution) ===") + print(" Target audiences will only appear during token exchange, not in initial login tokens") + + +def create_client_role_safe( + admin: KeycloakAdmin, + client_id: str, + role_name: str, + client_name: str | None = None, +) -> bool: + """Create a client role with proper error handling.""" + display_name = client_name or client_id + try: + admin.create_client_role( + client_id, + {"name": role_name, "clientRole": True}, + skip_exists=True, + ) + print(f" ✓ Created client role: {role_name} for {display_name}") + return True + except Exception as e: + print(f" ℹ Client role {role_name} for {display_name} already exists or error: {e}") + return True + + +# --------------------------------------------------------------------------- +# 6. Create realm roles +# --------------------------------------------------------------------------- + + +def create_realm_roles(admin: KeycloakAdmin, realm_roles: List[str]) -> None: + print("\n=== Creating realm roles ===") + for role in realm_roles: + # Extract role name and description - support both dict and string formats + if isinstance(role, dict): + role_payload = {"name": role["name"]} + if "description" in role: + role_payload["description"] = role["description"] + role_name = role["name"] + else: + role_payload = {"name": role} + role_name = role + + try: + admin.create_realm_role(role_payload, skip_exists=True) + print(f" Created role: {role_name}") + except Exception: + print(f" Role {role_name} already exists") + + +# --------------------------------------------------------------------------- +# 7. Create client scopes (one per role per client) +# --------------------------------------------------------------------------- + +DEFAULT_SCOPE_ATTRIBUTES = { + "include_in_token_scope": "true", + "display_on_consent_screen": "false", + "consent_screen_text": "", +} + +DEFAULT_MAPPER_CONFIG = { + "introspection_token_claim": "true", + "userinfo_token_claim": "false", + "id_token_claim": "false", + "lightweight_claim": "false", + "access_token_claim": "true", + "lightweight_access_token_claim": "false", +} + + +def create_client_scopes( + admin: KeycloakAdmin, + client_ids: Dict[str, Dict[str, Any]], +) -> Dict[str, str]: + """Create one scope per role per client. Returns {scope_name: scope_id}.""" + print("\n=== Creating client scopes ===") + scope_ids: Dict[str, str] = {} + for client_name, client_info in client_ids.items(): + for role in client_info["roles"]: + # Extract role name - support both dict and string formats + scope_name = role["name"] if isinstance(role, dict) else role + scope_id = create_single_client_scope( + admin, + scope_name, + client_name, + DEFAULT_SCOPE_ATTRIBUTES, + DEFAULT_MAPPER_CONFIG, + ) + scope_ids[scope_name] = scope_id + return scope_ids + + +def create_single_client_scope( + admin: KeycloakAdmin, + scope_name: str, + target_client: str, + default_attributes: Dict[str, str], + default_mapper_config: Dict[str, str], +) -> str: + """Create client scope with audience mapper for a specific role. + + The scope will be assigned as optional and conditionally included based on client role. + """ + keycloak_attributes = {key.replace("_", "."): str(value) for key, value in default_attributes.items()} + + scope_payload = { + "name": scope_name, + "protocol": "openid-connect", + "attributes": keycloak_attributes, + } + + scope_id = admin.create_client_scope( + scope_payload, + skip_exists=True, + ) + + _disable_full_scope_allowed(admin, scope_id, scope_name) + _add_audience_mapper(admin, scope_id, target_client, default_mapper_config) + return scope_id + + +def _disable_full_scope_allowed(admin: KeycloakAdmin, scope_id: str, scope_name: str) -> None: + # Disable Full Scope Allowed so the scope is only included if the user has the mapped role. + try: + scope_representation = admin.get_client_scope(scope_id) + if scope_representation.get("fullScopeAllowed", True): + scope_representation["fullScopeAllowed"] = False + admin.update_client_scope(scope_id, scope_representation) + print(f" Created client scope: {scope_name} (Full Scope Allowed: OFF)") + else: + print(f" Created client scope: {scope_name}") + except Exception as e: + print(f" Created client scope: {scope_name} (could not disable Full Scope Allowed: {e})") + + +def _add_audience_mapper( + admin: KeycloakAdmin, + scope_id: str, + target_client: str, + default_mapper_config: Dict[str, str], +) -> None: + try: + mapper_config = {key.replace("_", "."): value for key, value in default_mapper_config.items()} + mapper_config["included.client.audience"] = target_client + + admin.add_mapper_to_client_scope( + scope_id, + { + "name": f"{target_client}-audience", + "protocol": "openid-connect", + "protocolMapper": "oidc-audience-mapper", + "consentRequired": False, + "config": mapper_config, + }, + ) + print(f" Added audience mapper -> {target_client}") + except Exception as e: + print(f" Audience mapper already exists for {target_client}: {e}") + + +# --------------------------------------------------------------------------- +# 8. Self scopes as DEFAULT +# --------------------------------------------------------------------------- + + +def assign_self_scopes_as_default( + admin: KeycloakAdmin, + client_ids: Dict[str, Dict[str, Any]], + scope_ids: Dict[str, str], +) -> None: + """Assign each client's own role-scopes as default so login-issued tokens carry aud=.""" + print("\n=== Assigning self audience scopes to clients as DEFAULT ===") + for client_name, client_info in client_ids.items(): + assigned: List[str] = [] + for role in client_info["roles"]: + # Extract role name - support both dict and string formats + role_name = role["name"] if isinstance(role, dict) else role + scope_id = scope_ids.get(role_name) + if not scope_id: + continue + try: + admin.add_client_default_client_scope(client_info["id"], scope_id, {}) + assigned.append(role_name) + except Exception: + pass # Already added + + if assigned: + print(f" {client_name} <- {', '.join(assigned)} (self default)") + else: + print(f" {client_name} <- (no self scopes)") + + +# --------------------------------------------------------------------------- +# 9. Target scopes as OPTIONAL +# --------------------------------------------------------------------------- + + +def assign_target_scopes_as_optional( + admin: KeycloakAdmin, + main_config: Dict[str, Any], + client_ids: Dict[str, Dict[str, Any]], + scope_ids: Dict[str, str], +) -> None: + """Assign target client scopes as OPTIONAL (driven by client_audience_targets). + + Optional (not default) so target audiences only appear during token exchange, + never in initial login tokens. + """ + print("\n=== Assigning target client scopes to clients as OPTIONAL ===") + client_audience_targets = main_config.get("client_audience_targets", {}) + + for client_id, target_client_names in client_audience_targets.items(): + if client_id not in client_ids: + continue + if not target_client_names: + print(f" {client_id} <- (no target clients)") + continue + + assigned = _assign_target_scopes_for_client( + admin, + client_ids, + scope_ids, + client_id, + target_client_names, + ) + if assigned: + print(f" {client_id} <- {', '.join(assigned)} (optional)") + else: + print(f" {client_id} <- (no scopes)") + + +def _assign_target_scopes_for_client( + admin: KeycloakAdmin, + client_ids: Dict[str, Dict[str, Any]], + scope_ids: Dict[str, str], + client_id: str, + target_client_names: List[str], +) -> List[str]: + assigned: List[str] = [] + for target_client_name in target_client_names: + if target_client_name not in client_ids: + continue + for role in client_ids[target_client_name]["roles"]: + # Extract role name - support both dict and string formats + role_name = role["name"] if isinstance(role, dict) else role + scope_id = scope_ids.get(role_name) + if not scope_id: + continue + try: + admin.add_client_optional_client_scope(client_ids[client_id]["id"], scope_id, {}) + assigned.append(role_name) + except Exception: + pass # Already added + return assigned + + +# --------------------------------------------------------------------------- +# 10. Scope -> role gating +# --------------------------------------------------------------------------- + + +def map_scopes_to_roles( + admin: KeycloakAdmin, + realm: str, + client_ids: Dict[str, Dict[str, Any]], + scope_ids: Dict[str, str], +) -> None: + """Restrict each scope to users holding the matching client role.""" + print("\n=== Mapping client scopes to client roles for filtering ===") + for client_name, client_info in client_ids.items(): + client_id = client_info["id"] + print(f" {client_name}:") + for role in client_info["roles"]: + # Extract role name - support both dict and string formats + role_name = role["name"] if isinstance(role, dict) else role + scope_id = scope_ids.get(role_name) + if not scope_id: + continue + try: + assign_client_role_to_client_scope(admin, realm, scope_id, client_id, role_name) + print(f" ✓ Restricted {role_name} to role {client_name}.{role_name}") + except Exception as e: + print(f" ℹ Scope {role_name} already restricted or error: {e}") + + +def assign_client_role_to_client_scope( + admin: KeycloakAdmin, realm: str, scope_id: str, client_id: str, role_name: str +) -> None: + """Assign a client role to a client scope's scope-mappings for role-gating.""" + role = admin.get_client_role(client_id, role_name) + url = ( + f"{admin.connection.base_url}/admin/realms/{realm}/client-scopes/{scope_id}/scope-mappings/clients/{client_id}" + ) + admin.connection.raw_post(url, data=json.dumps([role])) + + +# --------------------------------------------------------------------------- +# 11. Create users +# --------------------------------------------------------------------------- + + +def create_users(admin: KeycloakAdmin, users_config: List[Dict[str, Any]]) -> None: + print("\n=== Creating users ===") + for user_config in users_config: + create_single_user(admin, user_config) + + +def create_single_user(admin: KeycloakAdmin, user_config: Dict[str, Any]) -> None: + username = user_config["username"] + user_roles = user_config.get("roles", []) + + admin.create_user( + { + "username": username, + "email": f"{username}@example.com", + "firstName": username.capitalize(), + "lastName": "Demo", + "enabled": True, + "emailVerified": True, + "credentials": [ + { + "type": "password", + "value": f"{username}123", + "temporary": False, + } + ], + }, + exist_ok=True, + ) + user_id = admin.get_user_id(username) + if not user_id: + raise RuntimeError(f"User '{username}' not found after creation") + admin.set_user_password(user_id, f"{username}123", temporary=False) + print(f" Created user: {username}") + + if not user_roles: + print(" No roles assigned") + return + + role_representations = [admin.get_realm_role(r) for r in user_roles] + try: + admin.assign_realm_roles(user_id, role_representations) + print(f" Assigned roles: {', '.join(user_roles)}") + except Exception as e: + print(f" Roles may already be assigned: {e}") + + +# --------------------------------------------------------------------------- +# 12. Summary +# --------------------------------------------------------------------------- + + +def print_summary( + keycloak_url: str, + realm: str, + main_config: Dict[str, Any], + users_config: List[Dict[str, Any]], +) -> None: + print("\n" + "=" * 60) + print("Demo realm setup complete!") + print("=" * 60) + print(f"\nKeycloak URL: {keycloak_url}") + print(f"Realm: {realm}") + print(f"Admin console: {keycloak_url}/admin/master/console/#/{realm}") + print("\nUsers (password=123 for each user):") + + composite_mappings = main_config.get("composite_role_mappings", {}) + for user_config in users_config: + print(_format_user_summary(user_config, composite_mappings)) + + +def _format_user_summary( + user_config: Dict[str, Any], + composite_mappings: Dict[str, Any], +) -> str: + username = user_config["username"] + user_roles = user_config.get("roles", []) + if not user_roles: + return f" {username:8} - roles: (none)" + + role_details: List[str] = [] + for realm_role in user_roles: + if realm_role in composite_mappings: + client_roles = [ + f"{s['client']}-{s['role'].replace(s['client'] + '-', '')}" for s in composite_mappings[realm_role] + ] + role_details.append(f"{realm_role} ({', '.join(client_roles)})") + else: + role_details.append(realm_role) + return f" {username:8} ({', '.join(user_roles):15}) - roles: {', '.join(role_details)}" + + +# --------------------------------------------------------------------------- +# Orchestrator +# --------------------------------------------------------------------------- + + +def main_rbac(config_file: str, reset_only: bool = False): + """Main setup function.""" + script_dir = Path(__file__).parent + + # 1. Load configuration (env + YAML) + load_dotenv(script_dir / "aiac" / "aiac.env") + main_config_path = script_dir / "aiac" / config_file + print(f"Loading main configuration from {main_config_path} ...") + main_config = load_main_config(main_config_path) + + keycloak_url, realm = load_keycloak_config() + + print(f"\nConnecting to Keycloak at {keycloak_url} ...") + admin = connect_admin(keycloak_url, realm_name="master") + + # Ensure admin password is non-temporary + admin_username = os.getenv("KEYCLOAK_ADMIN_USERNAME", "admin") + admin_password = os.getenv("KEYCLOAK_ADMIN_PASSWORD", "admin") + admin_user_id = admin.get_user_id(admin_username) + if admin_user_id: + admin.set_user_password(admin_user_id, admin_password, temporary=False) + print(f"Admin password set as non-temporary for '{admin_username}'.") + + # 2. Create realm + create_realm(admin, realm) + + # Switch to the new realm for the remaining provisioning steps + admin = connect_admin(keycloak_url, realm_name=realm) + + # Ensure admin user exists in the target realm so direct token requests succeed + ensure_admin_in_realm(admin, admin_username, admin_password) + + # 3. Reset prior provisioning artifacts and preserve secrets + print(f"\n=== Initializing realm state for re-provisioning: {realm} ===") + preserved_secrets = initialize_realm_state(admin, main_config) + + if reset_only: + print("\nReset-only mode: skipping provisioning. Done.") + return + + # 4. Create clients with preserved secrets + client_ids = create_clients(admin, main_config["clients"], preserved_secrets) + + # 5. Create client roles + create_client_roles(admin, client_ids) + + # 6. Create realm roles + create_realm_roles(admin, main_config.get("realm_roles", [])) + + # 7. Create client scopes (one per role per client) with audience mappers + scope_ids = create_client_scopes(admin, client_ids) + + # 8. Self scopes as DEFAULT + assign_self_scopes_as_default(admin, client_ids, scope_ids) + + # 9. Target scopes as OPTIONAL + assign_target_scopes_as_optional(admin, main_config, client_ids, scope_ids) + + # 10. Scope -> role gating + map_scopes_to_roles(admin, realm, client_ids, scope_ids) + + # 11. Create users + users_config = main_config["users"] + create_users(admin, users_config) + + # 12. Summary + print_summary(keycloak_url, realm, main_config, users_config) + + +# =========================================================================== +# Entry point +# =========================================================================== + if __name__ == "__main__": - main() + if "-rbac" in sys.argv: + try: + idx = sys.argv.index("-rbac") + if idx + 1 >= len(sys.argv): + print("Usage: python setup_keycloak.py -rbac config.yaml [--reset-only]", file=sys.stderr) + sys.exit(1) + config_file = sys.argv[idx + 1] + reset_only = "--reset-only" in sys.argv + main_rbac(config_file, reset_only=reset_only) + except Exception as e: + import traceback + + print(f"\nERROR: {e}", file=sys.stderr) + traceback.print_exc() + sys.exit(1) + else: + main_manual() diff --git a/authbridge/demos/github-issue/setup_keycloak_rbac.py b/authbridge/demos/github-issue/setup_keycloak_rbac.py new file mode 100644 index 000000000..4a8228e34 --- /dev/null +++ b/authbridge/demos/github-issue/setup_keycloak_rbac.py @@ -0,0 +1,648 @@ +""" +setup_keycloak.py - Keycloak Setup for GitHub Issue Agent + AuthBridge Demo + +This script configures Keycloak for running the GitHub Issue Agent demo with +AuthBridge transparent token exchange. + +Architecture: + UI (user) → gets token (aud: Agent's SPIFFE ID) → sends to Agent + ↓ + Agent Pod (git-issue-agent + AuthBridge sidecars) + | + | Agent calls GitHub Tool with user's token + v + AuthProxy (Envoy) - intercepts, validates inbound, exchanges outbound + | + | Token Exchange → audience "github-tool" + v + GitHub Tool (validates token, uses appropriate GitHub PAT) + +Clients created (from config.yaml): +- spiffe://localtest.me/ns/team1/sa/git-issue-agent: Agent client with github-agent role +- github-tool: Target audience for token exchange with github-tool-aud and github-full-access roles + +Client Scopes created: +- Per-role scopes for each client (e.g., github-full-access, github-tool-aud) +- Scopes are assigned as DEFAULT (self) or OPTIONAL (targets) + +Realm Roles created: +- regular: Standard user access level +- privileged: Elevated user access level + +Demo Users created: +- alice: User with 'regular' realm role +- bob: User with 'privileged' realm role + +Usage: + python setup_keycloak.py + +Arguments: + config.yaml Path to main configuration YAML file + policy.yaml Path to access control policy YAML file + +Environment variables (optional, defaults provided): + KEYCLOAK_URL Default: http://keycloak.localtest.me:8080 + KEYCLOAK_ADMIN_USERNAME Default: admin + KEYCLOAK_ADMIN_PASSWORD Default: admin + REALM_NAME Default: kagenti + +Security Note: +- This script uses default Keycloak admin credentials (username: "admin", password: "admin") + for demo and local development only. These credentials are insecure and MUST NOT be used + in any production or internet-exposed environment. +""" + +import json +import os +import sys +from pathlib import Path +from typing import Any, Dict, List, Optional + +import yaml +from keycloak import KeycloakAdmin, KeycloakPostError + +# Default configuration - can be overridden by environment variables +KEYCLOAK_URL = os.environ.get("KEYCLOAK_URL", "http://keycloak.localtest.me:8080") +KEYCLOAK_REALM = os.environ.get("REALM_NAME", "kagenti") +KEYCLOAK_ADMIN_USERNAME = os.environ.get("KEYCLOAK_ADMIN_USERNAME", "admin") +KEYCLOAK_ADMIN_PASSWORD = os.environ.get("KEYCLOAK_ADMIN_PASSWORD", "admin") + +if KEYCLOAK_ADMIN_USERNAME == "admin" and KEYCLOAK_ADMIN_PASSWORD == "admin": + print( + "WARNING: Using default Keycloak admin credentials 'admin'/'admin'. " + "These credentials are INSECURE and must NOT be used in production.", + file=sys.stderr, + ) + + +def load_main_config(config_file: Path) -> Dict[str, Any]: + """Load main configuration from YAML file.""" + if not config_file.exists(): + raise FileNotFoundError(f"Configuration file not found: {config_file}") + + with open(config_file, "r") as f: + return yaml.safe_load(f) + + +def load_access_control_policy(access_control_policy_file: Path) -> Dict[str, List[Dict[str, str]]]: + """Load access control policy (user role -> client roles). + + Returns a dictionary where each user role (realm role) maps to a list of client role mappings. + Each mapping contains 'client' (client name) and 'role' (role name). + """ + if not access_control_policy_file.exists(): + raise FileNotFoundError(f"Access control policy file not found: {access_control_policy_file}") + + with open(access_control_policy_file, "r") as f: + policy_config = yaml.safe_load(f) + + policy = policy_config.get("policy", {}) + + # Handle empty policy (when policy: is present but has no value) + if policy is None: + policy = {} + + # Validate policy structure + for user_role, client_roles in policy.items(): + if not isinstance(client_roles, list): + raise ValueError(f"Invalid policy for user role '{user_role}': " + "must be a list of client role mappings") + for client_role in client_roles: + if not isinstance(client_role, dict): + raise ValueError( + f"Invalid client role mapping for user role '{user_role}':" + + "must be a dict with 'client' and 'role' keys" + ) + if "client" not in client_role or "role" not in client_role: + raise ValueError( + f"Invalid client role mapping for user role '{user_role}':" + + "must contain 'client' and 'role' keys" + ) + if not isinstance(client_role["client"], str) or not isinstance(client_role["role"], str): + raise ValueError( + f"Invalid client role mapping for user role '{user_role}':" + "'client' and 'role' must be strings" + ) + + return policy + + +def add_client_role_to_realm_role_composite( + admin: KeycloakAdmin, realm: str, realm_role_name: str, client_id: str, client_role_name: str +): + """Add a client role to a realm role's composite roles.""" + # Get the client role + client_role = admin.get_client_role(client_id, client_role_name) + + # Get the realm role + realm_role = admin.get_realm_role(realm_role_name) + + # Add client role to realm role's composites + url = f"{admin.connection.base_url}/admin/realms/{realm}/roles-by-id/{realm_role['id']}/composites" + admin.connection.raw_post(url, data=json.dumps([client_role])) + + +def apply_access_control_policy( + admin: KeycloakAdmin, + realm: str, + access_control_policy_file: Path, + client_ids: Dict[str, str], + scope_ids: Optional[Dict[str, str]] = None, +) -> None: + """Load and apply access control policy to realm roles. + + Makes realm roles composites of client roles. This ensures users with a realm role + automatically get all the client roles mapped to that realm role in the policy. + This implements role-based access control by controlling which client roles users receive. + + Args: + admin: Keycloak admin instance + realm: Realm name + access_control_policy_file: Path to policy YAML file + client_ids: Mapping of client names to client IDs + scope_ids: Optional mapping of scope names to scope IDs (unused, kept for compatibility) + """ + user_role_to_client_roles = load_access_control_policy(access_control_policy_file) + + # Make realm roles composites of client roles + # This ensures users with realm roles automatically get the mapped client roles + print("\n=== Making realm roles composites of client roles ===") + for user_role, client_role_mappings in user_role_to_client_roles.items(): + print(f"\nProcessing realm role '{user_role}':") + for mapping in client_role_mappings: + client_name = mapping["client"] + role_name = mapping["role"] + + if client_name not in client_ids: + print(f" Warning: Client '{client_name}' not found") + continue + + client_id = client_ids[client_name] + + try: + add_client_role_to_realm_role_composite(admin, realm, user_role, client_id, role_name) + print(f" ✓ Added client role '{client_name}.{role_name}' to realm role '{user_role}'") + except Exception as e: + print(f" ℹ Client role '{client_name}.{role_name}' already in composite or error: {e}") + + +def create_client_role_safe( + admin: KeycloakAdmin, client_id: str, role_name: str, client_name: str | None = None, description: str | None = None +) -> bool: + """ + Create a client role with proper error handling. + + Args: + admin: Keycloak admin instance + client_id: The client ID where the role should be created + role_name: Name of the role to create + client_name: Optional display name for logging purposes + description: Optional description for the role + + Returns: + bool: True if role was created or already exists, False on error + """ + display_name = client_name or client_id + try: + role_payload = {"name": role_name, "clientRole": True} + if description: + role_payload["description"] = description + + admin.create_client_role(client_id, role_payload, skip_exists=True) + desc_info = f" ({description})" if description else "" + print(f" ✓ Created client role: {role_name} for {display_name}{desc_info}") + return True + except Exception as e: + # Log the error but don't fail - role might already exist + print(f" ℹ Client role {role_name} for {display_name} already exists or error: {e}") + return True # Consider existing role as success + + +def assign_client_role_to_client_scope(admin: KeycloakAdmin, realm: str, scope_id: str, client_id: str, role_name: str): + """Assign a client role to a client scope's scope-mappings for role-gating.""" + role = admin.get_client_role(client_id, role_name) + url = ( + f"{admin.connection.base_url}/admin/realms/{realm}/client-scopes/{scope_id}/scope-mappings/clients/{client_id}" + ) + admin.connection.raw_post(url, data=json.dumps([role])) + + +def create_client_idempotent(admin: KeycloakAdmin, payload: dict) -> str: + """Create a client or return existing internal ID.""" + client_id = payload["clientId"] + try: + internal_id = admin.create_client(payload) + print(f" Created client: {client_id}") + return internal_id + except KeycloakPostError: + internal_id = admin.get_client_id(client_id) + if internal_id is None: + raise ValueError(f"Client '{client_id}' not found and could not be created") + print(f" Using existing client: {client_id}") + return internal_id + + +def create_single_client_scope( + admin: KeycloakAdmin, + realm: str, + scope_name: str, + target_client: str, + target_client_id: str, + role_name: str, + default_attributes: Dict[str, str], + default_mapper_config: Dict[str, str], +) -> str: + """Create client scope with audience mapper for a specific role. + + The scope will be assigned as optional and conditionally included based on client role. + """ + # Convert snake_case to dot.notation for Keycloak + keycloak_attributes = {key.replace("_", "."): value for key, value in default_attributes.items()} + + scope_id = admin.create_client_scope( + { + "name": scope_name, + "protocol": "openid-connect", + "attributes": keycloak_attributes, + }, + skip_exists=True, + ) + + # Disable Full Scope Allowed on the client scope to enable role-based filtering + # This ensures the scope is only included if the user has the mapped role + try: + scope_representation = admin.get_client_scope(scope_id) + if scope_representation.get("fullScopeAllowed", True): + scope_representation["fullScopeAllowed"] = False + admin.update_client_scope(scope_id, scope_representation) + print(f" Created client scope: {scope_name} (Full Scope Allowed: OFF)") + else: + print(f" Created client scope: {scope_name}") + except Exception as e: + print(f" Created client scope: {scope_name} (could not disable Full Scope Allowed: {e})") + + # Add audience mapper - will add the audience to tokens + try: + # Convert snake_case to dot.notation for Keycloak + keycloak_mapper_config = {key.replace("_", "."): value for key, value in default_mapper_config.items()} + keycloak_mapper_config["included.client.audience"] = target_client + + admin.add_mapper_to_client_scope( + scope_id, + { + "name": f"{target_client}-audience", + "protocol": "openid-connect", + "protocolMapper": "oidc-audience-mapper", + "consentRequired": False, + "config": keycloak_mapper_config, + }, + ) + print(f" Added audience mapper -> {target_client}") + except Exception as e: + print(f" Audience mapper already exists for {target_client}: {e}") + + return scope_id + + +def main(config_file: str, access_control_policy_file: str): + """Main setup function.""" + script_dir = Path(__file__).parent + + # Load main configuration + main_config_path = script_dir / config_file + print(f"Loading main configuration from {main_config_path} ...") + main_config = load_main_config(main_config_path) + + access_control_policy_path = script_dir / access_control_policy_file + + # Use global configuration variables (set from environment or defaults) + REALM = KEYCLOAK_REALM + + print("=" * 70) + print("GitHub Issue Agent + AuthBridge - Keycloak Setup") + print("=" * 70) + print(f"\nKeycloak URL: {KEYCLOAK_URL}") + print(f"Realm: {REALM}") + + print(f"\nConnecting to Keycloak at {KEYCLOAK_URL} ...") + admin = KeycloakAdmin( + server_url=KEYCLOAK_URL, + username=KEYCLOAK_ADMIN_USERNAME, + password=KEYCLOAK_ADMIN_PASSWORD, + realm_name="master", + user_realm_name="master", + ) + + # Create realm + print(f"\n=== Creating realm: {REALM} ===") + try: + admin.create_realm( + { + "realm": REALM, + "enabled": True, + "accessTokenLifespan": 600, + "verifyEmail": False, + "registrationEmailAsUsername": False, + } + ) + print(f" Created realm: {REALM}") + except KeycloakPostError: + print(f" Realm {REALM} already exists, continuing...") + + # Switch to realm + admin = KeycloakAdmin( + server_url=KEYCLOAK_URL, + username=KEYCLOAK_ADMIN_USERNAME, + password=KEYCLOAK_ADMIN_PASSWORD, + realm_name=REALM, + user_realm_name="master", + ) + + # Create clients + print("\n=== Creating clients ===") + clients_config = main_config["clients"] + + client_ids = {} + for client_config in clients_config: + client_id = client_config["client_id"] + + # Get optional secret from config, or let Keycloak auto-generate + client_secret = client_config.get("secret") + + # Get direct access grants setting from config, default to False + direct_access_enabled = client_config.get("direct_access_grants", False) + + client_payload = { + "clientId": client_id, + "publicClient": False, + "serviceAccountsEnabled": True, + "directAccessGrantsEnabled": direct_access_enabled, + "standardFlowEnabled": False, + "fullScopeAllowed": False, + "attributes": {"standard.token.exchange.enabled": "true"}, + } + + # Only set secret if provided in config + if client_secret: + client_payload["secret"] = client_secret + + internal_id = create_client_idempotent(admin, client_payload) + if client_secret: + print(" Secret: client_secret") + else: + print(" Secret: (auto-generated by Keycloak)") + if direct_access_enabled: + print(" Direct access grants: enabled") + + # Get roles from config - support both old format (list of strings) and new format (list of dicts) + roles_config = client_config.get("roles", ["access"]) + client_roles = [] + for role in roles_config: + if isinstance(role, dict): + # New format with name and description + client_roles.append({"name": role["name"], "description": role.get("description")}) + else: + # Old format - just role name as string + client_roles.append({"name": role, "description": None}) + + client_ids[client_id] = {"id": internal_id, "secret": client_secret, "roles": client_roles} + + # Create client roles - create multiple roles per client based on config + print("\n=== Creating client roles ===") + for client_name, client_info in client_ids.items(): + client_id = client_info["id"] + client_roles = client_info["roles"] + for role in client_roles: + role_name = role["name"] + role_description = role.get("description") + create_client_role_safe(admin, client_id, role_name, client_name, role_description) + + # NOTE: We do NOT add target client roles to source client scope mappings + # This prevents target audiences from appearing in initial login tokens + # Token exchange will still work because: + # 1. Target client scopes are assigned as DEFAULT to source clients + # 2. Scope-to-role mappings filter which scopes are included based on user's roles + # 3. During token exchange, Keycloak uses the requested audience to determine which scopes to include + print("\n=== Skipping target client role scope mappings (prevents initial token pollution) ===") + print(" Target audiences will only appear during token exchange, not in initial login tokens") + + # Create realm roles + print("\n=== Creating realm roles ===") + roles = main_config.get("realm_roles", []) + for role in roles: + # Support both old format (string) and new format (dict with name and description) + if isinstance(role, dict): + role_name = role["name"] + role_description = role.get("description") + else: + role_name = role + role_description = None + + try: + role_payload = {"name": role_name} + if role_description: + role_payload["description"] = role_description + + admin.create_realm_role(role_payload, skip_exists=True) + desc_info = f" ({role_description})" if role_description else "" + print(f" Created role: {role_name}{desc_info}") + except Exception: + print(f" Role {role_name} already exists") + + # Create client scopes with audience mappers - create one scope per role per client + print("\n=== Creating client scopes ===") + default_attributes = { + "include_in_token_scope": "true", + "display_on_consent_screen": "false", + "consent_screen_text": "", + } + default_mapper_config = { + "introspection_token_claim": "true", + "userinfo_token_claim": "false", + "id_token_claim": "false", + "lightweight_claim": "false", + "access_token_claim": "true", + "lightweight_access_token_claim": "false", + } + + scope_ids = {} + for client_name, client_info in client_ids.items(): + client_internal_id = client_info["id"] + client_roles = client_info["roles"] + + # Create one scope per role (scope name = role name) + for role in client_roles: + role_name = role["name"] + scope_name = role_name # No -audience suffix + scope_id = create_single_client_scope( + admin, + REALM, + scope_name, + client_name, + client_internal_id, + role_name, + default_attributes, + default_mapper_config, + ) + scope_ids[scope_name] = scope_id + + # Ensure login-issued tokens also include the authenticating client as audience. + # This adds each client's own role scopes as defaults, so a token obtained for that + # client (for example via password grant) contains aud= in addition + # to any currently configured audience content. Scope-to-role filtering still applies. + print("\n=== Assigning self audience scopes to clients as DEFAULT ===") + for client_name, client_info in client_ids.items(): + assigned_self_scopes = [] + for role in client_info["roles"]: + role_name = role["name"] + scope_id = scope_ids.get(role_name) + if not scope_id: + continue + try: + admin.add_client_default_client_scope(client_info["id"], scope_id, {}) + assigned_self_scopes.append(role_name) + except Exception: + pass # Already added + + if assigned_self_scopes: + print(f" {client_name} <- {', '.join(assigned_self_scopes)} (self default)") + else: + print(f" {client_name} <- (no self scopes)") + + # Build client_ids mapping for apply_access_control_policy (client_name -> internal_id) + client_id_mapping = {name: info["id"] for name, info in client_ids.items()} + apply_access_control_policy(admin, REALM, access_control_policy_path, client_id_mapping, scope_ids) + + # Assign target scopes as OPTIONAL (not DEFAULT) + # This prevents target audiences from appearing in initial login tokens + # During token exchange, the requested audience will trigger inclusion of the appropriate scopes + print("\n=== Assigning target client scopes to clients as OPTIONAL ===") + client_audience_targets = main_config.get("client_audience_targets", {}) + + for client_id, target_client_names in client_audience_targets.items(): + if client_id in client_ids and target_client_names: + assigned_scopes = [] + for target_client_name in target_client_names: + if target_client_name in client_ids: + target_roles = client_ids[target_client_name]["roles"] + for role in target_roles: + role_name = role["name"] + scope_name = role_name # No -audience suffix + if scope_name in scope_ids: + scope_id = scope_ids[scope_name] + try: + admin.add_client_optional_client_scope(client_ids[client_id]["id"], scope_id, {}) + assigned_scopes.append(scope_name) + except Exception: + pass # Already added + if assigned_scopes: + print(f" {client_id} <- {', '.join(assigned_scopes)} (optional)") + else: + print(f" {client_id} <- (no scopes)") + elif client_id in client_ids: + print(f" {client_id} <- (no target clients)") + + # Map each client scope to its corresponding client role + # This filters DEFAULT scopes: only included if user has the specific client role + print("\n=== Mapping client scopes to client roles for filtering ===") + for client_name, client_info in client_ids.items(): + client_id = client_info["id"] + client_roles = client_info["roles"] + print(f" {client_name}:") + for role in client_roles: + role_name = role["name"] + scope_name = role_name # No -audience suffix + if scope_name in scope_ids: + scope_id = scope_ids[scope_name] + try: + # Add the client role to the scope's scope mappings + # This restricts the scope to users who have this client role + client_role = admin.get_client_role(client_id, role_name) + url = ( + f"{admin.connection.base_url}/admin/realms/{REALM}" + f"/client-scopes/{scope_id}/scope-mappings/clients/{client_id}" + ) + admin.connection.raw_post(url, data=json.dumps([client_role])) + print(f" ✓ Restricted {scope_name} to role {client_name}.{role_name}") + except Exception as e: + print(f" ℹ Scope {scope_name} already restricted or error: {e}") + + # Create users + print("\n=== Creating users ===") + users_config = main_config["users"] + + for user_config in users_config: + username = user_config["username"] + user_roles = user_config.get("roles", []) + password = f"{username}123" + user_id = admin.create_user( + { + "username": username, + "email": f"{username}@example.com", + "firstName": username.capitalize(), + "lastName": "Demo", + "enabled": True, + "emailVerified": True, + "credentials": [ + { + "type": "password", + "value": password, + "temporary": False, + } + ], + }, + exist_ok=True, + ) + print(f" Created user: {username} ") + + if user_roles: + print(f" Assigning roles to {username}: {', '.join(user_roles)}") + role_representations = [admin.get_realm_role(r) for r in user_roles] + try: + admin.assign_realm_roles(user_id, role_representations) + print(f" Assigned roles: {', '.join(user_roles)}") + except Exception as e: + print(f" Roles may already be assigned: {e}") + else: + print(" No roles assigned") + + # Summary + print("\n" + "=" * 60) + print("Demo realm setup complete!") + print("=" * 60) + print(f"\nKeycloak URL: {KEYCLOAK_URL}") + print(f"Realm: {REALM}") + print(f"Admin console: {KEYCLOAK_URL}/admin/master/console/#/{REALM}") + print("\nUsers (password format: username123):") + + # Build a mapping of realm roles to their composite client roles for display + composite_mappings = main_config.get("composite_role_mappings", {}) + for user_config in users_config: + username = user_config["username"] + user_roles = user_config.get("roles", []) + if user_roles: + # Show realm roles and their composite client roles + role_details = [] + for realm_role in user_roles: + if realm_role in composite_mappings: + client_roles = [ + f"{s['client']}-{s['role'].replace(s['client'] + '-', '')}" + for s in composite_mappings[realm_role] + ] + role_details.append(f"{realm_role} ({', '.join(client_roles)})") + else: + role_details.append(realm_role) + print(f" {username:8} ({', '.join(user_roles):15}) - roles: {', '.join(role_details)}") + else: + print(f" {username:8} - roles: (none)") + + +if __name__ == "__main__": + try: + config_file = "config.yaml" + policy_file = "policy.yaml" + main(config_file, policy_file) + except Exception as e: + import traceback + + print(f"\nERROR: {e}", file=sys.stderr) + traceback.print_exc() + sys.exit(1) From ca469bcb9066d732d2746d9e8f21fc7665ab77e3 Mon Sep 17 00:00:00 2001 From: Omer Boehm Date: Thu, 18 Jun 2026 20:45:37 +0300 Subject: [PATCH 2/7] removing confing files Signed-off-by: Omer Boehm --- authbridge/demos/github-issue/aiac/aiac.env | 5 -- .../demos/github-issue/aiac/config.yaml | 54 ------------------- 2 files changed, 59 deletions(-) delete mode 100644 authbridge/demos/github-issue/aiac/aiac.env delete mode 100644 authbridge/demos/github-issue/aiac/config.yaml diff --git a/authbridge/demos/github-issue/aiac/aiac.env b/authbridge/demos/github-issue/aiac/aiac.env deleted file mode 100644 index f72b4fb4e..000000000 --- a/authbridge/demos/github-issue/aiac/aiac.env +++ /dev/null @@ -1,5 +0,0 @@ -# Keycloak connection settings -KEYCLOAK_URL=http://keycloak.localtest.me:8080 -KEYCLOAK_ADMIN_USERNAME=admin -KEYCLOAK_ADMIN_PASSWORD=admin -REALM_NAME=kagenti diff --git a/authbridge/demos/github-issue/aiac/config.yaml b/authbridge/demos/github-issue/aiac/config.yaml deleted file mode 100644 index b48044118..000000000 --- a/authbridge/demos/github-issue/aiac/config.yaml +++ /dev/null @@ -1,54 +0,0 @@ -# Client configurations with roles -# Each client can have multiple roles, each role gets its own audience scope -# Optional 'secret' parameter: if not provided, Keycloak will auto-generate the client secret -clients: - - client_id: "kagenti" - secret: "demo-ui-secret" - direct_access_grants: true - roles: - - name: "demo-ui" - description: "Access to the demo UI interface" - - client_id: "spiffe://localtest.me/ns/team1/sa/git-issue-agent" - #secret: "github-agent-secret-for-testing-do-not-use" - direct_access_grants: true - roles: - - name: "github-agent" - description: "Access to the GitHub agent service" - - client_id: "github-tool" - #secret: "github-tool-secret-for-testing-do-not-use" - roles: - - name: "github-tool-aud" - description: "Provides access to public GitHub repositories" - - name: "github-full-access" - description: "Provides access to private GitHub repositories" - -# Realm roles (can be composites of client roles) -realm_roles: - - name: "developer" - description: "R&D team members" - - name: "tech-support" - description: "Technical support staff providing customer assistance" - - name: "sales" - description: "Sales team members managing customer relationships" - -# Client audience targets -client_audience_targets: - kagenti: - - spiffe://localtest.me/ns/team1/sa/git-issue-agent - spiffe://localtest.me/ns/team1/sa/git-issue-agent: - - github-tool - github-tool: [] - -# User configurations -users: - - username: "alice" - roles: - - "developer" - - - username: "bob" - roles: - - "tech-support" - - - username: "charlie" - roles: - - "sales" From 3896cde31ca7911bdc1f4c254f085bb54715ff42 Mon Sep 17 00:00:00 2001 From: Omer Boehm Date: Sat, 20 Jun 2026 21:58:58 +0300 Subject: [PATCH 3/7] config and policy Signed-off-by: Omer Boehm --- authbridge/demos/github-issue/aiac/.gitignore | 14 +++++ .../demos/github-issue/aiac/config.yaml | 54 +++++++++++++++++++ .../demos/github-issue/aiac/policy.yaml | 37 +++++++++++++ 3 files changed, 105 insertions(+) create mode 100644 authbridge/demos/github-issue/aiac/.gitignore create mode 100644 authbridge/demos/github-issue/aiac/config.yaml create mode 100644 authbridge/demos/github-issue/aiac/policy.yaml diff --git a/authbridge/demos/github-issue/aiac/.gitignore b/authbridge/demos/github-issue/aiac/.gitignore new file mode 100644 index 000000000..0757a2962 --- /dev/null +++ b/authbridge/demos/github-issue/aiac/.gitignore @@ -0,0 +1,14 @@ +# Runtime credentials — copy aiac.env.TEMPLATE to aiac.env and fill in your values. +aiac.env + +# LLM config with real endpoints/keys — copy llm_conf.yaml.TEMPLATE and fill in values. +aiac_agent/config/llm_conf.yaml + +# Generated policy files — created at runtime by aiac_cli.py. +generated_configs/ + +# Python virtual environment +venv/ +__pycache__/ +*.pyc +.pytest_cache/ diff --git a/authbridge/demos/github-issue/aiac/config.yaml b/authbridge/demos/github-issue/aiac/config.yaml new file mode 100644 index 000000000..8c25cee50 --- /dev/null +++ b/authbridge/demos/github-issue/aiac/config.yaml @@ -0,0 +1,54 @@ +# Client configurations with roles +# Each client can have multiple roles, each role gets its own audience scope +# Optional 'secret' parameter: if not provided, Keycloak will auto-generate the client secret +clients: + - client_id: "kagenti" + #secret: "demo-ui-secret-for-testing-do-not-use" + direct_access_grants: true + roles: + - name: "demo-ui" + description: "Access to the demo UI interface" + - client_id: "spiffe://localtest.me/ns/team1/sa/git-issue-agent" + #secret: "github-agent-secret-for-testing-do-not-use" + direct_access_grants: true + roles: + - name: "github-agent" + description: "Access to the GitHub agent service" + - client_id: "github-tool" + #secret: "github-tool-secret-for-testing-do-not-use" + roles: + - name: "github-tool-aud" + description: "Provides access to public GitHub repositories" + - name: "github-full-access" + description: "Provides access to private GitHub repositories" + +# Realm roles (can be composites of client roles) +realm_roles: + - name: "developer" + description: "R&D team members" + - name: "tech-support" + description: "Technical support staff providing customer assistance" + - name: "sales" + description: "Sales team members managing customer relationships" + +# Client audience targets +client_audience_targets: + kagenti: + - spiffe://localtest.me/ns/team1/sa/git-issue-agent + spiffe://localtest.me/ns/team1/sa/git-issue-agent: + - github-tool + github-tool: [] + +# User configurations +users: + - username: "alice" + roles: + - "developer" + + - username: "bob" + roles: + - "tech-support" + + - username: "charlie" + roles: + - "sales" diff --git a/authbridge/demos/github-issue/aiac/policy.yaml b/authbridge/demos/github-issue/aiac/policy.yaml new file mode 100644 index 000000000..44c262539 --- /dev/null +++ b/authbridge/demos/github-issue/aiac/policy.yaml @@ -0,0 +1,37 @@ +# Access Control Policy +# Maps user roles (realm roles) to specific client roles +# Format: user_role_name -> list of client role mappings +# Each entry specifies: client (client name) and role (role name from that client) + +# Original Policy Description: +# * Members of the R&D can access both private and public github repositories +# * Other technical personnel can access public github repositories only +# +# LLM Mapping Explanation: +# Explanation +# - “Members of the R&D” are mapped to the realm role developer. +# - “Other technical personnel” are mapped to the realm role tech‑support. +# - Access to private and public GitHub repositories requires both client roles on the `github-tool` client: `github-full-access` (covers private) and `github-tool-aud` (covers public). +# - Access to public repositories only requires the `github-tool-aud` role. +# - The call chain to reach the GitHub tool is: `kagenti` → `git‑issue‑agent` → `github-tool`. Therefore every user who needs any GitHub access must also receive the entry‑point client role `demo-ui` on `kagenti` and the intermediate role `github-agent` on `git‑issue‑agent`. +# - All required client‑role pairs are listed as separate objects, respecting the “both” rule. + +policy: + developer: + - client: kagenti + role: demo-ui + - client: spiffe://localtest.me/ns/team1/sa/git-issue-agent + role: github-agent + - client: github-tool + role: github-full-access + - client: github-tool + role: github-tool-aud + tech-support: + - client: kagenti + role: demo-ui + - client: spiffe://localtest.me/ns/team1/sa/git-issue-agent + role: github-agent + - client: github-tool + role: github-tool-aud + +# Generated by PolicyBuilder using LangGraph From b2f3611e741500140dee870e78a3a685ca3645f7 Mon Sep 17 00:00:00 2001 From: Omer Boehm Date: Sat, 20 Jun 2026 22:31:04 +0300 Subject: [PATCH 4/7] merged load policy into setup keycloak Signed-off-by: Omer Boehm --- .../demos/github-issue/setup_keycloak.py | 127 +++- .../demos/github-issue/setup_keycloak_rbac.py | 648 ------------------ 2 files changed, 122 insertions(+), 653 deletions(-) delete mode 100644 authbridge/demos/github-issue/setup_keycloak_rbac.py diff --git a/authbridge/demos/github-issue/setup_keycloak.py b/authbridge/demos/github-issue/setup_keycloak.py index b66a7a36e..c0067a759 100644 --- a/authbridge/demos/github-issue/setup_keycloak.py +++ b/authbridge/demos/github-issue/setup_keycloak.py @@ -54,10 +54,14 @@ are intentionally left alone. Usage: - python setup_keycloak.py -rbac config_file.yaml [--reset-only] + python setup_keycloak.py -rbac config_file.yaml [-policy policy.yaml] [--reset-only] Arguments: -rbac config_file.yaml Path to main configuration YAML file + -policy policy.yaml Path to access control policy YAML file (optional). + Makes realm roles composites of the client roles + declared in the policy, implementing RBAC through + OAuth2 token scopes. Flags: --reset-only Run the cleanup pass (initialize_realm_state) and exit @@ -69,6 +73,7 @@ Configuration files: (assumed to be under 'aiac' directory relative to script dir) .env - Keycloak connection settings and realm name config.yaml - Main configuration (clients, roles, users, scope_to_client) + policy.yaml - Access control policy (realm role -> client role mappings) Security Note: - This script uses default Keycloak admin credentials (username: "admin", password: "admin") @@ -81,7 +86,7 @@ import os import sys from pathlib import Path -from typing import Any, Dict, List, Tuple +from typing import Any, Dict, List, Optional, Tuple import yaml from dotenv import load_dotenv @@ -1285,12 +1290,107 @@ def _format_user_summary( return f" {username:8} ({', '.join(user_roles):15}) - roles: {', '.join(role_details)}" +# --------------------------------------------------------------------------- +# 13. Access control policy +# --------------------------------------------------------------------------- + + +def load_access_control_policy(access_control_policy_file: Path) -> Dict[str, List[Dict[str, str]]]: + """Load access control policy (user role -> client roles). + + Returns a dictionary where each user role (realm role) maps to a list of client role mappings. + Each mapping contains 'client' (client name) and 'role' (role name). + """ + if not access_control_policy_file.exists(): + raise FileNotFoundError(f"Access control policy file not found: {access_control_policy_file}") + + with open(access_control_policy_file, "r") as f: + policy_config = yaml.safe_load(f) + + policy = policy_config.get("policy", {}) + + if policy is None: + policy = {} + + for user_role, client_roles in policy.items(): + if not isinstance(client_roles, list): + raise ValueError(f"Invalid policy for user role '{user_role}': must be a list of client role mappings") + for client_role in client_roles: + if not isinstance(client_role, dict): + raise ValueError( + f"Invalid client role mapping for user role '{user_role}':" + + "must be a dict with 'client' and 'role' keys" + ) + if "client" not in client_role or "role" not in client_role: + raise ValueError( + f"Invalid client role mapping for user role '{user_role}':" + + "must contain 'client' and 'role' keys" + ) + if not isinstance(client_role["client"], str) or not isinstance(client_role["role"], str): + raise ValueError( + f"Invalid client role mapping for user role '{user_role}':" + "'client' and 'role' must be strings" + ) + + return policy + + +def _add_client_role_to_realm_role_composite( + admin: KeycloakAdmin, realm: str, realm_role_name: str, client_id: str, client_role_name: str +) -> None: + """Add a client role to a realm role's composite roles.""" + client_role = admin.get_client_role(client_id, client_role_name) + realm_role = admin.get_realm_role(realm_role_name) + url = f"{admin.connection.base_url}/admin/realms/{realm}/roles-by-id/{realm_role['id']}/composites" + admin.connection.raw_post(url, data=json.dumps([client_role])) + + +def apply_access_control_policy( + admin: KeycloakAdmin, + realm: str, + access_control_policy_file: Path, + client_ids: Dict[str, str], + scope_ids: Optional[Dict[str, str]] = None, +) -> None: + """Load and apply access control policy to realm roles. + + Makes realm roles composites of client roles so users with a realm role + automatically get all the client roles mapped to that realm role in the policy. + + Args: + admin: Keycloak admin instance + realm: Realm name + access_control_policy_file: Path to policy YAML file + client_ids: Mapping of client names to internal Keycloak IDs + scope_ids: Unused; kept for API compatibility + """ + user_role_to_client_roles = load_access_control_policy(access_control_policy_file) + + print("\n=== Making realm roles composites of client roles ===") + for user_role, client_role_mappings in user_role_to_client_roles.items(): + print(f"\nProcessing realm role '{user_role}':") + for mapping in client_role_mappings: + client_name = mapping["client"] + role_name = mapping["role"] + + if client_name not in client_ids: + print(f" Warning: Client '{client_name}' not found") + continue + + client_id = client_ids[client_name] + + try: + _add_client_role_to_realm_role_composite(admin, realm, user_role, client_id, role_name) + print(f" ✓ Added client role '{client_name}.{role_name}' to realm role '{user_role}'") + except Exception as e: + print(f" ℹ Client role '{client_name}.{role_name}' already in composite or error: {e}") + + # --------------------------------------------------------------------------- # Orchestrator # --------------------------------------------------------------------------- -def main_rbac(config_file: str, reset_only: bool = False): +def main_rbac(config_file: str, policy_file: Optional[str] = None, reset_only: bool = False): """Main setup function.""" script_dir = Path(__file__).parent @@ -1345,6 +1445,13 @@ def main_rbac(config_file: str, reset_only: bool = False): # 8. Self scopes as DEFAULT assign_self_scopes_as_default(admin, client_ids, scope_ids) + # 8.5. Apply access control policy (if provided) + if policy_file: + policy_path = script_dir / "aiac" / policy_file + print(f"\nApplying access control policy from {policy_path} ...") + client_id_mapping = {name: info["id"] for name, info in client_ids.items()} + apply_access_control_policy(admin, realm, policy_path, client_id_mapping, scope_ids) + # 9. Target scopes as OPTIONAL assign_target_scopes_as_optional(admin, main_config, client_ids, scope_ids) @@ -1368,11 +1475,21 @@ def main_rbac(config_file: str, reset_only: bool = False): try: idx = sys.argv.index("-rbac") if idx + 1 >= len(sys.argv): - print("Usage: python setup_keycloak.py -rbac config.yaml [--reset-only]", file=sys.stderr) + print( + "Usage: python setup_keycloak.py -rbac config.yaml [-policy policy.yaml] [--reset-only]", + file=sys.stderr, + ) sys.exit(1) config_file = sys.argv[idx + 1] + policy_file = None + if "-policy" in sys.argv: + pidx = sys.argv.index("-policy") + if pidx + 1 >= len(sys.argv): + print("Error: -policy requires a file argument", file=sys.stderr) + sys.exit(1) + policy_file = sys.argv[pidx + 1] reset_only = "--reset-only" in sys.argv - main_rbac(config_file, reset_only=reset_only) + main_rbac(config_file, policy_file=policy_file, reset_only=reset_only) except Exception as e: import traceback diff --git a/authbridge/demos/github-issue/setup_keycloak_rbac.py b/authbridge/demos/github-issue/setup_keycloak_rbac.py deleted file mode 100644 index 4a8228e34..000000000 --- a/authbridge/demos/github-issue/setup_keycloak_rbac.py +++ /dev/null @@ -1,648 +0,0 @@ -""" -setup_keycloak.py - Keycloak Setup for GitHub Issue Agent + AuthBridge Demo - -This script configures Keycloak for running the GitHub Issue Agent demo with -AuthBridge transparent token exchange. - -Architecture: - UI (user) → gets token (aud: Agent's SPIFFE ID) → sends to Agent - ↓ - Agent Pod (git-issue-agent + AuthBridge sidecars) - | - | Agent calls GitHub Tool with user's token - v - AuthProxy (Envoy) - intercepts, validates inbound, exchanges outbound - | - | Token Exchange → audience "github-tool" - v - GitHub Tool (validates token, uses appropriate GitHub PAT) - -Clients created (from config.yaml): -- spiffe://localtest.me/ns/team1/sa/git-issue-agent: Agent client with github-agent role -- github-tool: Target audience for token exchange with github-tool-aud and github-full-access roles - -Client Scopes created: -- Per-role scopes for each client (e.g., github-full-access, github-tool-aud) -- Scopes are assigned as DEFAULT (self) or OPTIONAL (targets) - -Realm Roles created: -- regular: Standard user access level -- privileged: Elevated user access level - -Demo Users created: -- alice: User with 'regular' realm role -- bob: User with 'privileged' realm role - -Usage: - python setup_keycloak.py - -Arguments: - config.yaml Path to main configuration YAML file - policy.yaml Path to access control policy YAML file - -Environment variables (optional, defaults provided): - KEYCLOAK_URL Default: http://keycloak.localtest.me:8080 - KEYCLOAK_ADMIN_USERNAME Default: admin - KEYCLOAK_ADMIN_PASSWORD Default: admin - REALM_NAME Default: kagenti - -Security Note: -- This script uses default Keycloak admin credentials (username: "admin", password: "admin") - for demo and local development only. These credentials are insecure and MUST NOT be used - in any production or internet-exposed environment. -""" - -import json -import os -import sys -from pathlib import Path -from typing import Any, Dict, List, Optional - -import yaml -from keycloak import KeycloakAdmin, KeycloakPostError - -# Default configuration - can be overridden by environment variables -KEYCLOAK_URL = os.environ.get("KEYCLOAK_URL", "http://keycloak.localtest.me:8080") -KEYCLOAK_REALM = os.environ.get("REALM_NAME", "kagenti") -KEYCLOAK_ADMIN_USERNAME = os.environ.get("KEYCLOAK_ADMIN_USERNAME", "admin") -KEYCLOAK_ADMIN_PASSWORD = os.environ.get("KEYCLOAK_ADMIN_PASSWORD", "admin") - -if KEYCLOAK_ADMIN_USERNAME == "admin" and KEYCLOAK_ADMIN_PASSWORD == "admin": - print( - "WARNING: Using default Keycloak admin credentials 'admin'/'admin'. " - "These credentials are INSECURE and must NOT be used in production.", - file=sys.stderr, - ) - - -def load_main_config(config_file: Path) -> Dict[str, Any]: - """Load main configuration from YAML file.""" - if not config_file.exists(): - raise FileNotFoundError(f"Configuration file not found: {config_file}") - - with open(config_file, "r") as f: - return yaml.safe_load(f) - - -def load_access_control_policy(access_control_policy_file: Path) -> Dict[str, List[Dict[str, str]]]: - """Load access control policy (user role -> client roles). - - Returns a dictionary where each user role (realm role) maps to a list of client role mappings. - Each mapping contains 'client' (client name) and 'role' (role name). - """ - if not access_control_policy_file.exists(): - raise FileNotFoundError(f"Access control policy file not found: {access_control_policy_file}") - - with open(access_control_policy_file, "r") as f: - policy_config = yaml.safe_load(f) - - policy = policy_config.get("policy", {}) - - # Handle empty policy (when policy: is present but has no value) - if policy is None: - policy = {} - - # Validate policy structure - for user_role, client_roles in policy.items(): - if not isinstance(client_roles, list): - raise ValueError(f"Invalid policy for user role '{user_role}': " + "must be a list of client role mappings") - for client_role in client_roles: - if not isinstance(client_role, dict): - raise ValueError( - f"Invalid client role mapping for user role '{user_role}':" - + "must be a dict with 'client' and 'role' keys" - ) - if "client" not in client_role or "role" not in client_role: - raise ValueError( - f"Invalid client role mapping for user role '{user_role}':" - + "must contain 'client' and 'role' keys" - ) - if not isinstance(client_role["client"], str) or not isinstance(client_role["role"], str): - raise ValueError( - f"Invalid client role mapping for user role '{user_role}':" + "'client' and 'role' must be strings" - ) - - return policy - - -def add_client_role_to_realm_role_composite( - admin: KeycloakAdmin, realm: str, realm_role_name: str, client_id: str, client_role_name: str -): - """Add a client role to a realm role's composite roles.""" - # Get the client role - client_role = admin.get_client_role(client_id, client_role_name) - - # Get the realm role - realm_role = admin.get_realm_role(realm_role_name) - - # Add client role to realm role's composites - url = f"{admin.connection.base_url}/admin/realms/{realm}/roles-by-id/{realm_role['id']}/composites" - admin.connection.raw_post(url, data=json.dumps([client_role])) - - -def apply_access_control_policy( - admin: KeycloakAdmin, - realm: str, - access_control_policy_file: Path, - client_ids: Dict[str, str], - scope_ids: Optional[Dict[str, str]] = None, -) -> None: - """Load and apply access control policy to realm roles. - - Makes realm roles composites of client roles. This ensures users with a realm role - automatically get all the client roles mapped to that realm role in the policy. - This implements role-based access control by controlling which client roles users receive. - - Args: - admin: Keycloak admin instance - realm: Realm name - access_control_policy_file: Path to policy YAML file - client_ids: Mapping of client names to client IDs - scope_ids: Optional mapping of scope names to scope IDs (unused, kept for compatibility) - """ - user_role_to_client_roles = load_access_control_policy(access_control_policy_file) - - # Make realm roles composites of client roles - # This ensures users with realm roles automatically get the mapped client roles - print("\n=== Making realm roles composites of client roles ===") - for user_role, client_role_mappings in user_role_to_client_roles.items(): - print(f"\nProcessing realm role '{user_role}':") - for mapping in client_role_mappings: - client_name = mapping["client"] - role_name = mapping["role"] - - if client_name not in client_ids: - print(f" Warning: Client '{client_name}' not found") - continue - - client_id = client_ids[client_name] - - try: - add_client_role_to_realm_role_composite(admin, realm, user_role, client_id, role_name) - print(f" ✓ Added client role '{client_name}.{role_name}' to realm role '{user_role}'") - except Exception as e: - print(f" ℹ Client role '{client_name}.{role_name}' already in composite or error: {e}") - - -def create_client_role_safe( - admin: KeycloakAdmin, client_id: str, role_name: str, client_name: str | None = None, description: str | None = None -) -> bool: - """ - Create a client role with proper error handling. - - Args: - admin: Keycloak admin instance - client_id: The client ID where the role should be created - role_name: Name of the role to create - client_name: Optional display name for logging purposes - description: Optional description for the role - - Returns: - bool: True if role was created or already exists, False on error - """ - display_name = client_name or client_id - try: - role_payload = {"name": role_name, "clientRole": True} - if description: - role_payload["description"] = description - - admin.create_client_role(client_id, role_payload, skip_exists=True) - desc_info = f" ({description})" if description else "" - print(f" ✓ Created client role: {role_name} for {display_name}{desc_info}") - return True - except Exception as e: - # Log the error but don't fail - role might already exist - print(f" ℹ Client role {role_name} for {display_name} already exists or error: {e}") - return True # Consider existing role as success - - -def assign_client_role_to_client_scope(admin: KeycloakAdmin, realm: str, scope_id: str, client_id: str, role_name: str): - """Assign a client role to a client scope's scope-mappings for role-gating.""" - role = admin.get_client_role(client_id, role_name) - url = ( - f"{admin.connection.base_url}/admin/realms/{realm}/client-scopes/{scope_id}/scope-mappings/clients/{client_id}" - ) - admin.connection.raw_post(url, data=json.dumps([role])) - - -def create_client_idempotent(admin: KeycloakAdmin, payload: dict) -> str: - """Create a client or return existing internal ID.""" - client_id = payload["clientId"] - try: - internal_id = admin.create_client(payload) - print(f" Created client: {client_id}") - return internal_id - except KeycloakPostError: - internal_id = admin.get_client_id(client_id) - if internal_id is None: - raise ValueError(f"Client '{client_id}' not found and could not be created") - print(f" Using existing client: {client_id}") - return internal_id - - -def create_single_client_scope( - admin: KeycloakAdmin, - realm: str, - scope_name: str, - target_client: str, - target_client_id: str, - role_name: str, - default_attributes: Dict[str, str], - default_mapper_config: Dict[str, str], -) -> str: - """Create client scope with audience mapper for a specific role. - - The scope will be assigned as optional and conditionally included based on client role. - """ - # Convert snake_case to dot.notation for Keycloak - keycloak_attributes = {key.replace("_", "."): value for key, value in default_attributes.items()} - - scope_id = admin.create_client_scope( - { - "name": scope_name, - "protocol": "openid-connect", - "attributes": keycloak_attributes, - }, - skip_exists=True, - ) - - # Disable Full Scope Allowed on the client scope to enable role-based filtering - # This ensures the scope is only included if the user has the mapped role - try: - scope_representation = admin.get_client_scope(scope_id) - if scope_representation.get("fullScopeAllowed", True): - scope_representation["fullScopeAllowed"] = False - admin.update_client_scope(scope_id, scope_representation) - print(f" Created client scope: {scope_name} (Full Scope Allowed: OFF)") - else: - print(f" Created client scope: {scope_name}") - except Exception as e: - print(f" Created client scope: {scope_name} (could not disable Full Scope Allowed: {e})") - - # Add audience mapper - will add the audience to tokens - try: - # Convert snake_case to dot.notation for Keycloak - keycloak_mapper_config = {key.replace("_", "."): value for key, value in default_mapper_config.items()} - keycloak_mapper_config["included.client.audience"] = target_client - - admin.add_mapper_to_client_scope( - scope_id, - { - "name": f"{target_client}-audience", - "protocol": "openid-connect", - "protocolMapper": "oidc-audience-mapper", - "consentRequired": False, - "config": keycloak_mapper_config, - }, - ) - print(f" Added audience mapper -> {target_client}") - except Exception as e: - print(f" Audience mapper already exists for {target_client}: {e}") - - return scope_id - - -def main(config_file: str, access_control_policy_file: str): - """Main setup function.""" - script_dir = Path(__file__).parent - - # Load main configuration - main_config_path = script_dir / config_file - print(f"Loading main configuration from {main_config_path} ...") - main_config = load_main_config(main_config_path) - - access_control_policy_path = script_dir / access_control_policy_file - - # Use global configuration variables (set from environment or defaults) - REALM = KEYCLOAK_REALM - - print("=" * 70) - print("GitHub Issue Agent + AuthBridge - Keycloak Setup") - print("=" * 70) - print(f"\nKeycloak URL: {KEYCLOAK_URL}") - print(f"Realm: {REALM}") - - print(f"\nConnecting to Keycloak at {KEYCLOAK_URL} ...") - admin = KeycloakAdmin( - server_url=KEYCLOAK_URL, - username=KEYCLOAK_ADMIN_USERNAME, - password=KEYCLOAK_ADMIN_PASSWORD, - realm_name="master", - user_realm_name="master", - ) - - # Create realm - print(f"\n=== Creating realm: {REALM} ===") - try: - admin.create_realm( - { - "realm": REALM, - "enabled": True, - "accessTokenLifespan": 600, - "verifyEmail": False, - "registrationEmailAsUsername": False, - } - ) - print(f" Created realm: {REALM}") - except KeycloakPostError: - print(f" Realm {REALM} already exists, continuing...") - - # Switch to realm - admin = KeycloakAdmin( - server_url=KEYCLOAK_URL, - username=KEYCLOAK_ADMIN_USERNAME, - password=KEYCLOAK_ADMIN_PASSWORD, - realm_name=REALM, - user_realm_name="master", - ) - - # Create clients - print("\n=== Creating clients ===") - clients_config = main_config["clients"] - - client_ids = {} - for client_config in clients_config: - client_id = client_config["client_id"] - - # Get optional secret from config, or let Keycloak auto-generate - client_secret = client_config.get("secret") - - # Get direct access grants setting from config, default to False - direct_access_enabled = client_config.get("direct_access_grants", False) - - client_payload = { - "clientId": client_id, - "publicClient": False, - "serviceAccountsEnabled": True, - "directAccessGrantsEnabled": direct_access_enabled, - "standardFlowEnabled": False, - "fullScopeAllowed": False, - "attributes": {"standard.token.exchange.enabled": "true"}, - } - - # Only set secret if provided in config - if client_secret: - client_payload["secret"] = client_secret - - internal_id = create_client_idempotent(admin, client_payload) - if client_secret: - print(" Secret: client_secret") - else: - print(" Secret: (auto-generated by Keycloak)") - if direct_access_enabled: - print(" Direct access grants: enabled") - - # Get roles from config - support both old format (list of strings) and new format (list of dicts) - roles_config = client_config.get("roles", ["access"]) - client_roles = [] - for role in roles_config: - if isinstance(role, dict): - # New format with name and description - client_roles.append({"name": role["name"], "description": role.get("description")}) - else: - # Old format - just role name as string - client_roles.append({"name": role, "description": None}) - - client_ids[client_id] = {"id": internal_id, "secret": client_secret, "roles": client_roles} - - # Create client roles - create multiple roles per client based on config - print("\n=== Creating client roles ===") - for client_name, client_info in client_ids.items(): - client_id = client_info["id"] - client_roles = client_info["roles"] - for role in client_roles: - role_name = role["name"] - role_description = role.get("description") - create_client_role_safe(admin, client_id, role_name, client_name, role_description) - - # NOTE: We do NOT add target client roles to source client scope mappings - # This prevents target audiences from appearing in initial login tokens - # Token exchange will still work because: - # 1. Target client scopes are assigned as DEFAULT to source clients - # 2. Scope-to-role mappings filter which scopes are included based on user's roles - # 3. During token exchange, Keycloak uses the requested audience to determine which scopes to include - print("\n=== Skipping target client role scope mappings (prevents initial token pollution) ===") - print(" Target audiences will only appear during token exchange, not in initial login tokens") - - # Create realm roles - print("\n=== Creating realm roles ===") - roles = main_config.get("realm_roles", []) - for role in roles: - # Support both old format (string) and new format (dict with name and description) - if isinstance(role, dict): - role_name = role["name"] - role_description = role.get("description") - else: - role_name = role - role_description = None - - try: - role_payload = {"name": role_name} - if role_description: - role_payload["description"] = role_description - - admin.create_realm_role(role_payload, skip_exists=True) - desc_info = f" ({role_description})" if role_description else "" - print(f" Created role: {role_name}{desc_info}") - except Exception: - print(f" Role {role_name} already exists") - - # Create client scopes with audience mappers - create one scope per role per client - print("\n=== Creating client scopes ===") - default_attributes = { - "include_in_token_scope": "true", - "display_on_consent_screen": "false", - "consent_screen_text": "", - } - default_mapper_config = { - "introspection_token_claim": "true", - "userinfo_token_claim": "false", - "id_token_claim": "false", - "lightweight_claim": "false", - "access_token_claim": "true", - "lightweight_access_token_claim": "false", - } - - scope_ids = {} - for client_name, client_info in client_ids.items(): - client_internal_id = client_info["id"] - client_roles = client_info["roles"] - - # Create one scope per role (scope name = role name) - for role in client_roles: - role_name = role["name"] - scope_name = role_name # No -audience suffix - scope_id = create_single_client_scope( - admin, - REALM, - scope_name, - client_name, - client_internal_id, - role_name, - default_attributes, - default_mapper_config, - ) - scope_ids[scope_name] = scope_id - - # Ensure login-issued tokens also include the authenticating client as audience. - # This adds each client's own role scopes as defaults, so a token obtained for that - # client (for example via password grant) contains aud= in addition - # to any currently configured audience content. Scope-to-role filtering still applies. - print("\n=== Assigning self audience scopes to clients as DEFAULT ===") - for client_name, client_info in client_ids.items(): - assigned_self_scopes = [] - for role in client_info["roles"]: - role_name = role["name"] - scope_id = scope_ids.get(role_name) - if not scope_id: - continue - try: - admin.add_client_default_client_scope(client_info["id"], scope_id, {}) - assigned_self_scopes.append(role_name) - except Exception: - pass # Already added - - if assigned_self_scopes: - print(f" {client_name} <- {', '.join(assigned_self_scopes)} (self default)") - else: - print(f" {client_name} <- (no self scopes)") - - # Build client_ids mapping for apply_access_control_policy (client_name -> internal_id) - client_id_mapping = {name: info["id"] for name, info in client_ids.items()} - apply_access_control_policy(admin, REALM, access_control_policy_path, client_id_mapping, scope_ids) - - # Assign target scopes as OPTIONAL (not DEFAULT) - # This prevents target audiences from appearing in initial login tokens - # During token exchange, the requested audience will trigger inclusion of the appropriate scopes - print("\n=== Assigning target client scopes to clients as OPTIONAL ===") - client_audience_targets = main_config.get("client_audience_targets", {}) - - for client_id, target_client_names in client_audience_targets.items(): - if client_id in client_ids and target_client_names: - assigned_scopes = [] - for target_client_name in target_client_names: - if target_client_name in client_ids: - target_roles = client_ids[target_client_name]["roles"] - for role in target_roles: - role_name = role["name"] - scope_name = role_name # No -audience suffix - if scope_name in scope_ids: - scope_id = scope_ids[scope_name] - try: - admin.add_client_optional_client_scope(client_ids[client_id]["id"], scope_id, {}) - assigned_scopes.append(scope_name) - except Exception: - pass # Already added - if assigned_scopes: - print(f" {client_id} <- {', '.join(assigned_scopes)} (optional)") - else: - print(f" {client_id} <- (no scopes)") - elif client_id in client_ids: - print(f" {client_id} <- (no target clients)") - - # Map each client scope to its corresponding client role - # This filters DEFAULT scopes: only included if user has the specific client role - print("\n=== Mapping client scopes to client roles for filtering ===") - for client_name, client_info in client_ids.items(): - client_id = client_info["id"] - client_roles = client_info["roles"] - print(f" {client_name}:") - for role in client_roles: - role_name = role["name"] - scope_name = role_name # No -audience suffix - if scope_name in scope_ids: - scope_id = scope_ids[scope_name] - try: - # Add the client role to the scope's scope mappings - # This restricts the scope to users who have this client role - client_role = admin.get_client_role(client_id, role_name) - url = ( - f"{admin.connection.base_url}/admin/realms/{REALM}" - f"/client-scopes/{scope_id}/scope-mappings/clients/{client_id}" - ) - admin.connection.raw_post(url, data=json.dumps([client_role])) - print(f" ✓ Restricted {scope_name} to role {client_name}.{role_name}") - except Exception as e: - print(f" ℹ Scope {scope_name} already restricted or error: {e}") - - # Create users - print("\n=== Creating users ===") - users_config = main_config["users"] - - for user_config in users_config: - username = user_config["username"] - user_roles = user_config.get("roles", []) - password = f"{username}123" - user_id = admin.create_user( - { - "username": username, - "email": f"{username}@example.com", - "firstName": username.capitalize(), - "lastName": "Demo", - "enabled": True, - "emailVerified": True, - "credentials": [ - { - "type": "password", - "value": password, - "temporary": False, - } - ], - }, - exist_ok=True, - ) - print(f" Created user: {username} ") - - if user_roles: - print(f" Assigning roles to {username}: {', '.join(user_roles)}") - role_representations = [admin.get_realm_role(r) for r in user_roles] - try: - admin.assign_realm_roles(user_id, role_representations) - print(f" Assigned roles: {', '.join(user_roles)}") - except Exception as e: - print(f" Roles may already be assigned: {e}") - else: - print(" No roles assigned") - - # Summary - print("\n" + "=" * 60) - print("Demo realm setup complete!") - print("=" * 60) - print(f"\nKeycloak URL: {KEYCLOAK_URL}") - print(f"Realm: {REALM}") - print(f"Admin console: {KEYCLOAK_URL}/admin/master/console/#/{REALM}") - print("\nUsers (password format: username123):") - - # Build a mapping of realm roles to their composite client roles for display - composite_mappings = main_config.get("composite_role_mappings", {}) - for user_config in users_config: - username = user_config["username"] - user_roles = user_config.get("roles", []) - if user_roles: - # Show realm roles and their composite client roles - role_details = [] - for realm_role in user_roles: - if realm_role in composite_mappings: - client_roles = [ - f"{s['client']}-{s['role'].replace(s['client'] + '-', '')}" - for s in composite_mappings[realm_role] - ] - role_details.append(f"{realm_role} ({', '.join(client_roles)})") - else: - role_details.append(realm_role) - print(f" {username:8} ({', '.join(user_roles):15}) - roles: {', '.join(role_details)}") - else: - print(f" {username:8} - roles: (none)") - - -if __name__ == "__main__": - try: - config_file = "config.yaml" - policy_file = "policy.yaml" - main(config_file, policy_file) - except Exception as e: - import traceback - - print(f"\nERROR: {e}", file=sys.stderr) - traceback.print_exc() - sys.exit(1) From 9e2d0f5ebb1708e7acbc7d09cd64e14902fe5704 Mon Sep 17 00:00:00 2001 From: Omer Boehm Date: Wed, 24 Jun 2026 13:54:58 +0300 Subject: [PATCH 5/7] Feat: Role-based scope gating for github-issue demo Signed-off-by: Omer Boehm --- authbridge/demos/github-issue/aiac/.gitignore | 2 +- authbridge/demos/github-issue/aiac/Makefile | 12 +- .../demos/github-issue/aiac/aiac_cli.py | 9 +- .../github-issue/aiac/scripts/show-result.py | 17 +- authbridge/demos/github-issue/demo-aiac.md | 39 +-- authbridge/demos/github-issue/demo-rbac.md | 195 ++++++------- authbridge/demos/github-issue/rbac/Makefile | 259 ++++++++++++++++++ .../github-issue/{aiac => rbac}/config.yaml | 0 .../github-issue/{aiac => rbac}/policy.yaml | 2 - .../demos/github-issue/setup_keycloak.py | 94 ++++--- 10 files changed, 448 insertions(+), 181 deletions(-) create mode 100644 authbridge/demos/github-issue/rbac/Makefile rename authbridge/demos/github-issue/{aiac => rbac}/config.yaml (100%) rename authbridge/demos/github-issue/{aiac => rbac}/policy.yaml (97%) diff --git a/authbridge/demos/github-issue/aiac/.gitignore b/authbridge/demos/github-issue/aiac/.gitignore index 0757a2962..cd543b7e0 100644 --- a/authbridge/demos/github-issue/aiac/.gitignore +++ b/authbridge/demos/github-issue/aiac/.gitignore @@ -5,7 +5,7 @@ aiac.env aiac_agent/config/llm_conf.yaml # Generated policy files — created at runtime by aiac_cli.py. -generated_configs/ +config/ # Python virtual environment venv/ diff --git a/authbridge/demos/github-issue/aiac/Makefile b/authbridge/demos/github-issue/aiac/Makefile index 5691f5c24..faa9ff3a3 100644 --- a/authbridge/demos/github-issue/aiac/Makefile +++ b/authbridge/demos/github-issue/aiac/Makefile @@ -31,10 +31,12 @@ help: ## Show this menu (default) $(MAKEFILE_LIST) @printf "\nVariables:\n" @printf " \033[36m%-20s\033[0m %s\n" "POLICY" "policy file to apply (default: policies/regular_policy.txt)" + @printf " \033[36m%-20s\033[0m %s\n" "RBAC_CONFIG" "RBAC config file (default: config/rbac/rbac_config.yaml)" @printf " \033[36m%-20s\033[0m %s\n" "PYTHON" "Python interpreter (default: kagenti-extensions/.venv/bin/python)" @printf "\nPrerequisites: uv, Keycloak running, aiac.env and llm_conf.yaml configured.\n\n" POLICY ?= policies/regular_policy.txt +RBAC_CONFIG ?= ../rbac/config.yaml # Resolve the venv relative to this Makefile, regardless of where make is invoked. MAKEFILE_DIR := $(dir $(abspath $(lastword $(MAKEFILE_LIST)))) @@ -81,14 +83,14 @@ preflight: ## Verify prerequisites (uv, venv, aiac.env, llm_conf.yaml, Keycloak setup: preflight ## Provision Keycloak realm with clients, roles, and users @echo "[*] Provisioning Keycloak realm ..." - $(PYTHON) ../setup_keycloak.py -rbac config.yaml + $(PYTHON) ../setup_keycloak.py -rbac aiac/$(RBAC_CONFIG) @echo "[✓] Keycloak realm provisioned." # ---------- Apply policy ---------- apply-policy: preflight ## Generate + apply POLICY (default: policies/regular_policy.txt) to Keycloak @echo "[*] Running AIAC full pipeline: $(POLICY)" - $(PYTHON) aiac_cli.py --yes $(POLICY) + $(PYTHON) aiac_cli.py $(POLICY) apply-permissive: POLICY = policies/permissive_policy.txt apply-permissive: apply-policy ## Generate + apply the permissive policy (grants Sales access) @@ -96,13 +98,13 @@ apply-permissive: apply-policy ## Generate + apply the permissive policy (grant # ---------- Show result ---------- show-result: preflight ## Show active composite-role mappings in Keycloak (verify applied policy) - $(PYTHON) scripts/show-result.py + $(PYTHON) scripts/show-result.py --config-path $(RBAC_CONFIG) # ---------- Reset ---------- reset: preflight ## Wipe generated configs and re-provision the realm from scratch @echo "[*] Removing generated configs ..." - @rm -f generated_configs/*.yaml + @rm -f config/*.yaml @echo "[*] Re-provisioning Keycloak realm ..." - $(PYTHON) ../setup_keycloak.py -rbac config.yaml + $(PYTHON) ../setup_keycloak.py -rbac aiac/$(RBAC_CONFIG) @echo "[✓] Reset complete." diff --git a/authbridge/demos/github-issue/aiac/aiac_cli.py b/authbridge/demos/github-issue/aiac/aiac_cli.py index 923e47f2c..4c3603b71 100644 --- a/authbridge/demos/github-issue/aiac/aiac_cli.py +++ b/authbridge/demos/github-issue/aiac/aiac_cli.py @@ -149,12 +149,11 @@ def run_full_pipeline(policy_text_file: str, policy_name: str | None, yes: bool print_info(f"Policy name: {policy_name}") print() - generated_configs_dir = script_dir / "generated_configs" + generated_configs_dir = script_dir / "config" generated_configs_dir.mkdir(exist_ok=True) config_file = generated_configs_dir / f"{policy_name}_config.yaml" policy_file = generated_configs_dir / f"{policy_name}_policy.yaml" - main_config = "config.yaml" realm_name = os.getenv("REALM_NAME", "demo") keycloak_url = os.getenv("KEYCLOAK_URL") @@ -212,7 +211,7 @@ def run_full_pipeline(policy_text_file: str, policy_name: str | None, yes: bool realm_name=realm_name, user_realm_name="master", ) - delete_access_control_policy(admin, realm_name, script_dir / main_config) + delete_access_control_policy(admin, realm_name, config_file=config_file) print_success("Old rules removed successfully") print() @@ -226,8 +225,8 @@ def run_full_pipeline(policy_text_file: str, policy_name: str | None, yes: bool print_success(f"Policy '{policy_name}' has been successfully updated in Keycloak") print() print_info("Generated files:") - print(f" - Configuration: generated_configs/{config_file.name}") - print(f" - Rules: generated_configs/{policy_file.name}") + print(f" - Configuration: {config_file}") + print(f" - Rules: {policy_file}") except Exception as e: print_error(f"An error occurred: {e}") diff --git a/authbridge/demos/github-issue/aiac/scripts/show-result.py b/authbridge/demos/github-issue/aiac/scripts/show-result.py index 504fb0535..461af727c 100644 --- a/authbridge/demos/github-issue/aiac/scripts/show-result.py +++ b/authbridge/demos/github-issue/aiac/scripts/show-result.py @@ -9,6 +9,7 @@ Run via: make show-result """ +import argparse import os import sys from pathlib import Path @@ -46,6 +47,14 @@ def section(title: str) -> None: def main() -> None: + parser = argparse.ArgumentParser(description="Display composite-role mappings currently active in Keycloak") + parser.add_argument( + "--config-path", + type=Path, + help="Path to the RBAC configuration YAML file", + ) + args = parser.parse_args() + try: admin = KeycloakAdmin( server_url=KEYCLOAK_URL, @@ -58,7 +67,11 @@ def main() -> None: print(f"{RED}ERROR: Could not connect to Keycloak at {KEYCLOAK_URL}: {e}{RESET}") sys.exit(1) - config_path = AIAC_DIR / "config.yaml" + config_path = args.config_path + if not config_path.exists(): + print(f"{RED}ERROR: Config file not found: {config_path}{RESET}") + sys.exit(1) + with open(config_path) as f: config = yaml.safe_load(f) @@ -85,7 +98,7 @@ def main() -> None: name = c.get("name", "?") print(f" {GREEN}✓{RESET} {container}.{name}") - gen_dir = AIAC_DIR / "generated_configs" + gen_dir = AIAC_DIR / "config" / "generated" policy_files = list(gen_dir.glob("*_policy.yaml")) if gen_dir.exists() else [] if policy_files: latest = max(policy_files, key=lambda p: p.stat().st_mtime_ns) diff --git a/authbridge/demos/github-issue/demo-aiac.md b/authbridge/demos/github-issue/demo-aiac.md index 23cf05a9c..33efbd89f 100644 --- a/authbridge/demos/github-issue/demo-aiac.md +++ b/authbridge/demos/github-issue/demo-aiac.md @@ -378,7 +378,7 @@ REALM_NAME=kagenti Run the setup script to create the demo realm with clients, roles, and users: ```bash -python setup_keycloak.py -rbac config.yaml +python setup_keycloak.py -rbac rbac/config.yaml ``` Open bash inside the test client pod @@ -560,26 +560,26 @@ python aiac_cli.py policies/regular_policy.txt ``` Review generated files: - - Configuration: generated_configs/regular_policy_config.yaml - - Rules: generated_configs/regular_policy_policy.yaml + - Configuration: config/regular_policy_config.yaml + - Rules: config/regular_policy_policy.yaml Verify results ```bash echo "1. Developer has github-full-access:" -yq '.policy.developer[] | select(.client == "github-tool" and .role == "github-full-access")' generated_configs/regular_policy_policy.yaml +yq '.policy.developer[] | select(.client == "github-tool" and .role == "github-full-access")' config/regular_policy_policy.yaml echo -e "\n2. Developer has github-tool-aud:" -yq '.policy.developer[] | select(.client == "github-tool" and .role == "github-tool-aud")' generated_configs/regular_policy_policy.yaml +yq '.policy.developer[] | select(.client == "github-tool" and .role == "github-tool-aud")' config/regular_policy_policy.yaml echo -e "\n3. Tech-support has github-tool-aud:" -yq '.policy.tech-support[] | select(.client == "github-tool" and .role == "github-tool-aud")' generated_configs/regular_policy_policy.yaml +yq '.policy.tech-support[] | select(.client == "github-tool" and .role == "github-tool-aud")' config/regular_policy_policy.yaml echo -e "\n4. Tech-support does NOT have github-full-access (should be empty):" -yq '.policy.tech-support[] | select(.client == "github-tool" and .role == "github-full-access")' generated_configs/regular_policy_policy.yaml +yq '.policy.tech-support[] | select(.client == "github-tool" and .role == "github-full-access")' config/regular_policy_policy.yaml echo -e "\n5. Sales does NOT exist in policy (should be null):" -yq '.policy.sales' generated_configs/regular_policy_policy.yaml +yq '.policy.sales' config/regular_policy_policy.yaml ``` Expected output : ```bash @@ -641,7 +641,7 @@ echo "Client ID: $CLIENT_ID Secret length: ${#CLIENT_SECRET}" # step 2 - run AIAC using regualr policy -# python AIAC.py policy/regular_policy.txt +# python aiac_cli.py policies/regular_policy.txt # users will be configured acording to the 'regular' policy #ALICE (Developer) can list issues in kagenti/kagenti repo #ALICE can also list issues in omerboehm/intro2c repo (because she is a DEVELOPER and has full access) @@ -791,26 +791,26 @@ Apply the updated policy python aiac_cli.py policies/permissive_policy.txt ``` Review generated files: - - Configuration: generated_configs/permissive_policy_config.yaml - - Rules: generated_configs/permissive_policy_policy.yaml + - Configuration: config/permissive_policy_config.yaml + - Rules: config/permissive_policy_policy.yaml Verify results ```bash echo "1. Developer has github-full-access:" -yq '.policy.developer[] | select(.client == "github-tool" and .role == "github-full-access")' generated_configs/permissive_policy_policy.yaml +yq '.policy.developer[] | select(.client == "github-tool" and .role == "github-full-access")' config/permissive_policy_policy.yaml echo -e "\n2. Developer has github-tool-aud:" -yq '.policy.developer[] | select(.client == "github-tool" and .role == "github-tool-aud")' generated_configs/permissive_policy_policy.yaml +yq '.policy.developer[] | select(.client == "github-tool" and .role == "github-tool-aud")' config/permissive_policy_policy.yaml echo -e "\n3. Tech-support has github-tool-aud:" -yq '.policy.tech-support[] | select(.client == "github-tool" and .role == "github-tool-aud")' generated_configs/permissive_policy_policy.yaml +yq '.policy.tech-support[] | select(.client == "github-tool" and .role == "github-tool-aud")' config/permissive_policy_policy.yaml echo -e "\n4. Tech-support does NOT have github-full-access (should be empty):" -yq '.policy.tech-support[] | select(.client == "github-tool" and .role == "github-full-access")' generated_configs/permissive_policy_policy.yaml +yq '.policy.tech-support[] | select(.client == "github-tool" and .role == "github-full-access")' config/permissive_policy_policy.yaml echo -e "\n5. Sales is now just like Tech-support (\"Other personnel\"):" -yq '.policy.sales[] | select(.client == "github-tool" and .role == "github-tool-aud")' generated_configs/permissive_policy_policy.yaml +yq '.policy.sales[] | select(.client == "github-tool" and .role == "github-tool-aud")' config/permissive_policy_policy.yaml ``` Expected output : ```txt @@ -831,7 +831,7 @@ role: github-tool-aud 5. Sales is now just like Tech-support ("Other personnel"): client: github-tool role: github-tool-aud - +``` ### Step 17: Reset Realm (Optional) @@ -839,9 +839,10 @@ role: github-tool-aud To clean up and start fresh: ```bash +cd demos/github-issue/ # delete generated policies -rm -f generated_configs/*.yaml +rm -f aiac/config/*.yaml # re-provision the realm -python setup_keycloak.py -rbac config.yaml +python setup_keycloak.py -rbac rbac/config.yaml ``` diff --git a/authbridge/demos/github-issue/demo-rbac.md b/authbridge/demos/github-issue/demo-rbac.md index a9b821dda..f5615e659 100644 --- a/authbridge/demos/github-issue/demo-rbac.md +++ b/authbridge/demos/github-issue/demo-rbac.md @@ -32,27 +32,27 @@ providing end-to-end security: │ ┌───────────────────────────────────────────────────────────────────────────┐ │ │ │ GIT-ISSUE-AGENT POD (namespace: team1) │ │ │ │ │ │ -│ │ ┌──────────────────┐ ┌────────────────────────────────────────────┐ │ │ -│ │ │ git-issue-agent │ │ AuthBridge sidecar (combined image) │ │ │ -│ │ │ (A2A agent, │ │ Container name depends on resolved mode: │ │ │ -│ │ │ port 8000) │ │ proxy-sidecar (default): authbridge-proxy│ │ │ -│ │ └──────────────────┘ │ envoy-sidecar: envoy-proxy │ │ │ -│ │ │ │ │ │ -│ │ │ Inbound: │ │ │ -│ │ │ - Validates JWT (signature + issuer + │ │ │ -│ │ │ audience via JWKS) │ │ │ -│ │ │ - Returns 401 for invalid/missing tokens │ │ │ -│ │ │ Outbound: │ │ │ -│ │ │ - HTTP: Exchanges token via Keycloak │ │ │ -│ │ │ → aud: github-tool │ │ │ -│ │ │ - HTTPS: TLS passthrough │ │ │ -│ │ │ │ │ │ -│ │ │ spiffe-helper bundled inside the image │ │ │ -│ │ │ (gated by SPIRE_ENABLED). │ │ │ -│ │ │ Keycloak client registration is │ │ │ -│ │ │ operator-managed; the resulting Secret │ │ │ -│ │ │ is mounted at /shared/client-{id,secret}.txt│ │ │ -│ │ └────────────────────────────────────────────┘ │ │ +│ │ ┌──────────────────┐ ┌───────────────────────────────────────────--─┐ │ │ +│ │ │ git-issue-agent │ │ AuthBridge sidecar (combined image) │ │ │ +│ │ │ (A2A agent, │ │ Container name depends on resolved mode: │ │ │ +│ │ │ port 8000) │ │ proxy-sidecar (default): authbridge-proxy │ │ │ +│ │ └──────────────────┘ │ envoy-sidecar: envoy-proxy │ │ │ +│ │ │ │ │ │ +│ │ │ Inbound: │ │ │ +│ │ │ - Validates JWT (signature + issuer + │ │ │ +│ │ │ audience via JWKS) │ │ │ +│ │ │ - Returns 401 for invalid/missing tokens │ │ │ +│ │ │ Outbound: │ │ │ +│ │ │ - HTTP: Exchanges token via Keycloak │ │ │ +│ │ │ → aud: github-tool │ │ │ +│ │ │ - HTTPS: TLS passthrough │ │ │ +│ │ │ │ │ │ +│ │ │ spiffe-helper bundled inside the image │ │ │ +│ │ │ (gated by SPIRE_ENABLED). │ │ │ +│ │ │ Keycloak client registration is │ │ │ +│ │ │ operator-managed; the resulting Secret │ │ │ +│ │ │ is mounted at /shared/client-{id,secret}.txt│ │ │ +│ │ └────────────────────────────────────────────--┘ │ │ │ └───────────────────────────────────────────────────────────────────────────┘ │ │ │ │ │ Exchanged token │(aud: github-tool) │ @@ -236,7 +236,57 @@ kubectl apply -f authbridge/demos/github-issue/k8s/configmaps-webhook.yaml -n te --- -## Step 2: Apply Demo ConfigMaps +## Step 2: Configure Keycloak + +Keycloak needs to be configured with the correct clients, scopes, and users for the +token exchange flow between the agent and the GitHub tool. + +### Port-forward Keycloak (if needed) + +The setup script connects to Keycloak at `http://keycloak.localtest.me:8080`. +If Keycloak is not already reachable at that address (e.g., via an ingress), +start a port-forward in a separate terminal: + +```bash +kubectl port-forward service/keycloak-service -n keycloak 8080:8080 +``` + +### Run the setup script + +```bash +cd authbridge + +# Create virtual environment (if not already done) +uv sync +source venv/bin/activate +uv pip install --upgrade pip +uv pip install -r requirements.txt + +cd demos/github-issue +# Run the Keycloak setup for this demo +python setup_keycloak.py -rbac aiac/config/rbac/config.yaml -policy aiac/config/rbac/policy.yaml +``` + + +This creates: + +| Resource | Name | Purpose | +|----------|------|---------| +| **Realm** | `kagenti` | Keycloak realm for the demo | +| **Client** | `spiffe://localtest.me/ns/team1/sa/git-issue-agent` | Agent client (already existed) with github-agent role | +| **Client** | `github-tool` | Target audience for token exchange | +| **Client Role** | `github-agent` | Access role for the GitHub issue agent | +| **Client Role** | `github-tool-aud` | Audience role for GitHub tool access | +| **Client Role** | `github-full-access` | Full access role for GitHub tool operations | +| **Scope** | `github-agent` | Client scope for agent access (DEFAULT for agent client) | +| **Scope** | `github-tool-aud` | Client scope for tool audience (OPTIONAL for agent) | +| **Scope** | `github-full-access` | Client scope for privileged access (OPTIONAL for agent) | +| **User** | `alice` (password: `alice123`) | User with 'developer' role - has privileged access | +| **User** | `bob` (password: `bob123`) | User with 'tech-support' role - has public access | +| **User** | `charlie` (password: `charlie123`) | User with 'Sales' role - has no access udner regular policy or public access under permissive policy | +--- + +## Step 3: Apply Demo ConfigMaps The Kagenti installer creates default ConfigMaps (`authbridge-config`, `spiffe-helper-config`, `envoy-config`) and the `keycloak-admin-secret` Secret @@ -269,7 +319,7 @@ kubectl apply -f demos/github-issue/k8s/configmaps.yaml --- -## Step 3: Create GitHub PAT Secret +## Step 4: Create GitHub PAT Secret The GitHub tool needs PAT tokens to access the GitHub API. Create a Kubernetes secret with your tokens: @@ -290,7 +340,7 @@ kubectl create secret generic github-tool-secrets -n team1 \ --- -## Step 4: Deploy the GitHub Tool +## Step 5: Deploy the GitHub Tool Deploy the GitHub MCP tool as a target service. This deployment does **not** get AuthBridge injection (it is the target, not the caller): @@ -303,7 +353,7 @@ kubectl wait --for=condition=available --timeout=120s deployment/github-tool -n --- -## Step 5: Deploy the GitHub Issue Agent +## Step 6: Deploy the GitHub Issue Agent Deploy the agent with AuthBridge labels. The webhook will automatically inject one combined AuthBridge sidecar (post-#411). In envoy-sidecar mode @@ -343,7 +393,7 @@ agent envoy-proxy --- -## Step 6: Validate the Deployment +## Step 7: Validate the Deployment ### Check pod status @@ -447,56 +497,6 @@ ollama serve --- -## Step 7: Configure Keycloak - -Keycloak needs to be configured with the correct clients, scopes, and users for the -token exchange flow between the agent and the GitHub tool. - -### Port-forward Keycloak (if needed) - -The setup script connects to Keycloak at `http://keycloak.localtest.me:8080`. -If Keycloak is not already reachable at that address (e.g., via an ingress), -start a port-forward in a separate terminal: - -```bash -kubectl port-forward service/keycloak-service -n keycloak 8080:8080 -``` - -### Run the setup script - -```bash -cd authbridge - -# Create virtual environment (if not already done) -uv sync -source venv/bin/activate -uv pip install --upgrade pip -uv pip install -r requirements.txt - -# Run the Keycloak setup for this demo -python demos/github-issue/setup_keycloak.py -``` - -This creates: - -| Resource | Name | Purpose | -|----------|------|---------| -| **Realm** | `kagenti` | Keycloak realm for the demo | -| **Client** | `spiffe://localtest.me/ns/team1/sa/git-issue-agent` | Agent client with github-agent role | -| **Client** | `github-tool` | Target audience for token exchange | -| **Client Role** | `github-agent` | Access role for the GitHub issue agent | -| **Client Role** | `github-tool-aud` | Audience role for GitHub tool access | -| **Client Role** | `github-full-access` | Full access role for GitHub tool operations | -| **Scope** | `github-agent` | Client scope for agent access (DEFAULT for agent client) | -| **Scope** | `github-tool-aud` | Client scope for tool audience (OPTIONAL for agent) | -| **Scope** | `github-full-access` | Client scope for privileged access (OPTIONAL for agent) | -| **Realm Role** | `regular` | Standard user access level | -| **Realm Role** | `privileged` | Elevated user access level | -| **User** | `alice` (password: `alice123`) | User with 'regular' realm role | -| **User** | `bob` (password: `bob123`) | User with 'privileged' realm role | - ---- - ## Step 8: Test the AuthBridge Flow These tests verify both **inbound** JWT validation and **outbound** token exchange @@ -850,14 +850,14 @@ privilege levels get different GitHub API access through the same agent. | User | Token Scope | Tool PAT Used | Public Repos | Private Repos | |------|-------------|---------------|:------------:|:-------------:| -| **Alice** | `openid` (no `github-full-access`) | `PUBLIC_ACCESS_PAT` | Yes | No | -| **Bob** | `openid github-full-access` | `PRIVILEGED_ACCESS_PAT` | Yes | Yes | +| **Alice** | `openid github-full-access` | `PRIVILEGED_ACCESS_PAT` | Yes | Yes | +| **Bob** | `openid` (no `github-full-access`) | `PUBLIC_ACCESS_PAT` | Yes | No | The flow: 1. User authenticates with Keycloak using `password` grant -2. Request is sent to Agent (on behalf of User) +2. Request is sent to Agent (on behalf of User). 3. Agent invokes Tool to perform its github task -4. AuthBridge exchanges the token +4. AuthBridge exchanges the token. Alice's (developer) token will include `github-full-access`, Bob's (tech-support) will not. 5. The GitHub tool checks for `REQUIRED_SCOPE` (`github-full-access`) in the exchanged token 6. Tokens with the scope get the privileged PAT; tokens without get the public-only PAT @@ -933,7 +933,7 @@ curl -s --max-time 300 \ }' | jq '.result.artifacts[0].parts[0].text' | head -5 ``` -**Alice queries a private repo** (should fail — PUBLIC_ACCESS_PAT cannot access it): +**Alice queries a private repo** (should succeed — PRIVILEGED_ACCESS_PAT has access): ```bash curl -s --max-time 300 \ @@ -954,10 +954,10 @@ curl -s --max-time 300 \ }' | jq '.result.artifacts[0].parts[0].text' | head -5 ``` -> **Expected:** Alice's request for the private repo fails because the GitHub tool -> uses `PUBLIC_ACCESS_PAT`, which has no access to private repositories. +> **Expected:** Alice's request for the private repo succeeds because the GitHub tool +> uses `PRIVILEGED_ACCESS_PAT`, which has access to private repositories. -### 10d. Test as Bob (privileged access) +### 10d. Test as Bob Bob authenticates with Keycloak using `password` grant. @@ -974,7 +974,7 @@ echo "Bob token length: ${#BOB_TOKEN}" echo "Bob scopes: $(echo $BOB_TOKEN | cut -d. -f2 | base64 -d 2>/dev/null | jq -r '.scope')" ``` -**Bob queries the same private repo** (should succeed — PRIVILEGED_ACCESS_PAT has access): +**Bob queries the same private repo** (should fail — PUBLIC_ACCESS_PAT cannot access it): ```bash curl -s --max-time 300 \ @@ -995,8 +995,8 @@ curl -s --max-time 300 \ }' | jq '.result.artifacts[0].parts[0].text' | head -5 ``` -> **Expected:** Bob's request succeeds because the exchanged token contains -> `github-full-access`, so the GitHub tool uses `PRIVILEGED_ACCESS_PAT`. +> **Expected:** Bob's request fails because the exchanged token dosn't contain +> `github-full-access`, so the GitHub tool uses `PUBLIC_ACCESS_PAT`. ### 10e. Verify scope-based PAT selection in tool logs @@ -1007,13 +1007,10 @@ exit kubectl logs deployment/github-tool -n team1 | grep -E "REQUIRED_SCOPE|scopes" ``` -Expected output (two requests, different scope outcomes): +Expected output (Bob doesnt have required scope): ``` -This OIDC user has scopes "openid email profile" -The REQUIRED_SCOPE "github-full-access" NOT IN scopes [openid email profile] -This OIDC user has scopes "openid email profile github-full-access" -The REQUIRED_SCOPE "github-full-access" in scopes [openid email profile github-full-access] +"The REQUIRED_SCOPE NOT IN scopes" requiredScope=github-full-access scopes="[agent-team1-git-issue-agent-aud profile github-tool-aud email openid]" ``` ### 10f. Clean up @@ -1167,20 +1164,10 @@ kubectl delete mutatingwebhookconfiguration kagenti-webhook-authbridge-mutating- | File | Description | |------|-------------| -| `demos/github-issue/demo-manual.md` | This guide | +| `demos/github-issue/demo-rbac.md` | This guide (variant or demo-manual.md) | +| `demos/github-issue/demo-manual.md` | This guide's baseline | | `demos/github-issue/demo-ui.md` | UI-driven deployment guide | | `demos/github-issue/setup_keycloak.py` | Keycloak configuration script | | `demos/github-issue/k8s/configmaps.yaml` | Demo-specific authbridge-config override | | `demos/github-issue/k8s/git-issue-agent-deployment.yaml` | Agent deployment with AuthBridge labels | | `demos/github-issue/k8s/github-tool-deployment.yaml` | GitHub tool deployment (no injection) | - -## Next Steps - -- **UI Deployment**: See [demo-ui.md](demo-ui.md) for deploying via the Kagenti dashboard -- **AuthBridge Binary**: See the [AuthBridge README](../../cmd/authbridge/README.md) for inbound - JWT validation and outbound token exchange internals -- **Token-Exchange Routes**: See the [routes-configuration guide](../token-exchange-routes/README.md) for - route-based token exchange to multiple tool services -- **Access Policies**: See the [access policies proposal](../../PROPOSAL-access-policies.md) - for role-based delegation control -- **AuthBridge Overview**: See the [AuthBridge README](../../README.md) for architecture details diff --git a/authbridge/demos/github-issue/rbac/Makefile b/authbridge/demos/github-issue/rbac/Makefile new file mode 100644 index 000000000..cadf44a04 --- /dev/null +++ b/authbridge/demos/github-issue/rbac/Makefile @@ -0,0 +1,259 @@ +# demo-rbac Makefile — RBAC-aware GitHub Issue Agent demo with AuthBridge. +# +# End-to-end: +# +# make build-images # build + load agent/tool images into the Kind cluster +# make setup-keycloak # configure Keycloak (realm, clients, RBAC roles, users) +# make deploy +# # namespace + ConfigMaps + PAT secret + tool + agent +# make validate # verify pod readiness and operator client registration +# make test-ab # authbridge inbound validation tests (Steps 8a–8f from demo-rbac.md) +# make test-rbac # show scope-based PAT selection logs (Alice vs Bob) +# make clean # delete all deployed resources +# +# Prerequisites: +# - uv (https://docs.astral.sh/uv/) +# - kubectl pointing at the Kind cluster +# - Kind cluster accessible (default name: kagenti) +# - Keycloak running at http://keycloak.localtest.me:8080 +# - AGENT_EXAMPLES_DIR set to the agent-examples repo clone (for build-images) +# - PRIVILEGED_ACCESS_PAT and PUBLIC_ACCESS_PAT set for the deploy target + +.PHONY: help preflight build-images setup-namespace setup-keycloak apply-configmaps \ + check-pats create-secret deploy-tool deploy-agent deploy validate test test-rbac clean + +# `make` with no target prints help. +.DEFAULT_GOAL := help + +help: ## Show this menu (default) + @printf "\ndemo-rbac — RBAC-aware GitHub Issue Agent demo with AuthBridge.\n\n" + @printf "Typical run:\n" + @printf " \033[1mmake build-images AGENT_EXAMPLES_DIR=\033[0m # build + load images\n" + @printf " \033[1mmake setup-keycloak\033[0m # configure Keycloak\n" + @printf " \033[1mmake deploy PRIVILEGED_ACCESS_PAT= PUBLIC_ACCESS_PAT=\033[0m\n" + @printf " \033[1mmake validate\033[0m # verify pods ready\n" + @printf " \033[1mmake test\033[0m # inbound tests\n" + @printf " \033[1mmake test-rbac\033[0m # Alice vs Bob\n" + @printf " \033[1mmake clean\033[0m # tear everything down\n\n" + @printf "Targets:\n" + @awk 'BEGIN { FS = ":.*## " } \ + /^[a-zA-Z_-]+:.*## / { printf " \033[36m%-22s\033[0m %s\n", $$1, $$2 }' \ + $(MAKEFILE_LIST) + @printf "\nVariables:\n" + @printf " \033[36m%-22s\033[0m %s\n" "NAMESPACE" "target namespace (default: team1)" + @printf " \033[36m%-22s\033[0m %s\n" "KIND_CLUSTER_NAME" "Kind cluster name (default: kagenti)" + @printf " \033[36m%-22s\033[0m %s\n" "AGENT_EXAMPLES_DIR" "path to agent-examples repo clone (required for build-images)" + @printf " \033[36m%-22s\033[0m %s\n" "PRIVILEGED_ACCESS_PAT" "GitHub PAT with all-repo access (required for deploy)" + @printf " \033[36m%-22s\033[0m %s\n" "PUBLIC_ACCESS_PAT" "GitHub PAT with public-repo access (required for deploy)" + @printf " \033[36m%-22s\033[0m %s\n" "RBAC_CONFIG" "RBAC config file (default: rbac/config.yaml)" + @printf " \033[36m%-22s\033[0m %s\n" "RBAC_POLICY" "RBAC policy file (default: rbac/policy.yaml)" + @printf " \033[36m%-22s\033[0m %s\n" "PYTHON" "Python interpreter (default: kagenti-extensions/.venv/bin/python)" + @printf "\nPrerequisites: uv, kubectl, kind, docker, Keycloak running.\n\n" + +MAKEFILE_DIR := $(dir $(abspath $(lastword $(MAKEFILE_LIST)))) + +NAMESPACE ?= team1 +KIND_CLUSTER_NAME ?= kagenti +RBAC_CONFIG ?= rbac/config.yaml +RBAC_POLICY ?= rbac/policy.yaml +AGENT_EXAMPLES_DIR ?= $(MAKEFILE_DIR)../../../../../agent-examples + + +# Resolve the venv relative to this Makefile, regardless of where make is invoked. +VENV := $(MAKEFILE_DIR)../../../../.venv +PYTHON ?= $(VENV)/bin/python + +# ---------- Pre-flight ---------- + +preflight: ## Verify prerequisites (kubectl, kind, docker, python deps, Keycloak) + @command -v kubectl >/dev/null 2>&1 || { \ + echo "ERROR: kubectl not found."; exit 1; \ + } + @command -v kind >/dev/null 2>&1 || { \ + echo "ERROR: kind not found."; exit 1; \ + } + @command -v docker >/dev/null 2>&1 || command -v podman >/dev/null 2>&1 || { \ + echo "ERROR: docker or podman not found."; exit 1; \ + } + @test -x "$(VENV)/bin/python" || { \ + echo "ERROR: venv not found at $(VENV)"; \ + echo " Run: uv sync (from authbridge/)"; \ + exit 1; \ + } + @$(PYTHON) -c 'import yaml, keycloak' 2>/dev/null || { \ + echo "ERROR: Python dependencies missing."; \ + echo " Run: uv pip install --python $(VENV)/bin/python -r ../../../requirements.txt"; \ + exit 1; \ + } + @curl -sf "http://keycloak.localtest.me:8080/realms/master" -o /dev/null || { \ + echo "ERROR: Keycloak not reachable at http://keycloak.localtest.me:8080"; \ + echo " Start: kubectl port-forward service/keycloak-service -n keycloak 8080:8080"; \ + exit 1; \ + } + @echo "[✓] All prerequisites met." + +# ---------- Build images ---------- + +build-images: ## Build + load agent and tool container images into Kind (requires AGENT_EXAMPLES_DIR) + @test -n "$(AGENT_EXAMPLES_DIR)" || { \ + echo "ERROR: AGENT_EXAMPLES_DIR is not set."; \ + echo " Set it to the path of your agent-examples repo clone."; \ + exit 1; \ + } + @echo "[*] Building github-tool image ..." + docker build -t ghcr.io/kagenti/agent-examples/github-tool:latest \ + -f $(AGENT_EXAMPLES_DIR)/mcp/github_tool/Dockerfile \ + $(AGENT_EXAMPLES_DIR)/mcp/github_tool/ + @echo "[*] Building git-issue-agent image ..." + docker build -t ghcr.io/kagenti/agent-examples/git-issue-agent:latest \ + -f $(AGENT_EXAMPLES_DIR)/a2a/git_issue_agent/Dockerfile \ + $(AGENT_EXAMPLES_DIR)/a2a/git_issue_agent/ + @echo "[*] Loading images into Kind cluster '$(KIND_CLUSTER_NAME)' ..." + kind load docker-image --name $(KIND_CLUSTER_NAME) \ + ghcr.io/kagenti/agent-examples/github-tool:latest + kind load docker-image --name $(KIND_CLUSTER_NAME) \ + ghcr.io/kagenti/agent-examples/git-issue-agent:latest + @echo "[✓] Images built and loaded." + +# ---------- Setup ---------- + +setup-namespace: ## Create namespace and apply webhook ConfigMaps + @echo "[*] Creating namespace '$(NAMESPACE)' ..." + kubectl create namespace $(NAMESPACE) --dry-run=client -o yaml | kubectl apply -f - + @test -f k8s/configmaps-webhook.yaml && { \ + echo "[*] Applying webhook ConfigMaps ..."; \ + kubectl apply -f k8s/configmaps-webhook.yaml -n $(NAMESPACE); \ + } || echo "[!] k8s/configmaps-webhook.yaml not found — skipping (Kagenti installer creates these)." + @echo "[✓] Namespace ready." + +setup-keycloak: preflight ## Configure Keycloak: realm, clients, RBAC roles, and users + @test -f ../$(RBAC_CONFIG) || { \ + echo "ERROR: RBAC config not found at $(RBAC_CONFIG)"; \ + echo " Expected: $(MAKEFILE_DIR)$(RBAC_CONFIG)"; \ + exit 1; \ + } + @test -f ../$(RBAC_POLICY) || { \ + echo "ERROR: RBAC policy not found at $(RBAC_POLICY)"; \ + echo " Expected: $(MAKEFILE_DIR)$(RBAC_POLICY)"; \ + exit 1; \ + } + @echo "[*] Configuring Keycloak realm ..." + $(PYTHON) ../setup_keycloak.py -rbac $(RBAC_CONFIG) -policy $(RBAC_POLICY) + @echo "[✓] Keycloak configured." + +apply-configmaps: ## Apply demo-specific ConfigMaps (authbridge-config, authproxy-routes) + @echo "[*] Applying demo ConfigMaps ..." + kubectl apply -f ../k8s/configmaps.yaml -n $(NAMESPACE) + @echo "[✓] ConfigMaps applied." + +# ---------- PAT secret ---------- + +check-pats: + @test -n "$(PRIVILEGED_ACCESS_PAT)" || { \ + echo "ERROR: PRIVILEGED_ACCESS_PAT is not set."; \ + echo " make deploy PRIVILEGED_ACCESS_PAT=ghp_xxx PUBLIC_ACCESS_PAT=ghp_yyy"; \ + exit 1; \ + } + @test -n "$(PUBLIC_ACCESS_PAT)" || { \ + echo "ERROR: PUBLIC_ACCESS_PAT is not set."; \ + echo " make deploy PRIVILEGED_ACCESS_PAT=ghp_xxx PUBLIC_ACCESS_PAT=ghp_yyy"; \ + exit 1; \ + } + +create-secret: check-pats ## Create github-tool-secrets with PAT tokens (requires PRIVILEGED_ACCESS_PAT, PUBLIC_ACCESS_PAT) + @echo "[*] Creating github-tool-secrets ..." + kubectl create secret generic github-tool-secrets -n $(NAMESPACE) \ + --from-literal=INIT_AUTH_HEADER="Bearer $(PRIVILEGED_ACCESS_PAT)" \ + --from-literal=UPSTREAM_HEADER_TO_USE_IF_IN_AUDIENCE="Bearer $(PRIVILEGED_ACCESS_PAT)" \ + --from-literal=UPSTREAM_HEADER_TO_USE_IF_NOT_IN_AUDIENCE="Bearer $(PUBLIC_ACCESS_PAT)" \ + --dry-run=client -o yaml | kubectl apply -f - + @echo "[✓] github-tool-secrets created." + +# ---------- Deploy ---------- + +deploy-tool: ## Deploy GitHub MCP tool and wait for readiness + @echo "[*] Deploying GitHub tool ..." + kubectl apply -f ../k8s/github-tool-deployment.yaml -n $(NAMESPACE) + kubectl wait --for=condition=available --timeout=120s deployment/github-tool -n $(NAMESPACE) + @echo "[✓] GitHub tool ready." + +deploy-agent: ## Deploy GitHub Issue Agent (AuthBridge injected by webhook) and wait for readiness + @echo "[*] Deploying GitHub Issue Agent ..." + kubectl apply -f ../k8s/git-issue-agent-deployment.yaml -n $(NAMESPACE) + kubectl wait --for=condition=available --timeout=180s deployment/git-issue-agent -n $(NAMESPACE) + @echo "[✓] GitHub Issue Agent ready." + +deploy: preflight setup-namespace apply-configmaps create-secret deploy-tool deploy-agent ## Full deploy: namespace + ConfigMaps + PAT secret + tool + agent + +# ---------- Validate ---------- + +validate: ## Verify pod readiness and operator-managed client registration + @echo "[*] Pod status in namespace '$(NAMESPACE)':" + @kubectl get pods -n $(NAMESPACE) + @echo "" + @echo "[*] Injected sidecar containers on git-issue-agent:" + @kubectl get pod -n $(NAMESPACE) -l app.kubernetes.io/name=git-issue-agent \ + -o jsonpath='{.items[0].spec.containers[*].name}' 2>/dev/null \ + && echo "" || echo "(pod not found)" + @echo "" + @echo "[*] Operator-managed credential Secret mounted into agent:" + @kubectl get pod -n $(NAMESPACE) -l app.kubernetes.io/name=git-issue-agent \ + -o jsonpath='{.items[0].spec.volumes[?(@.secret)].secret.secretName}' 2>/dev/null \ + && echo "" || echo "(pod not found)" + @echo "" + @echo "[*] Operator client-registration logs (last 10 relevant lines):" + @kubectl logs -n kagenti-system deployment/kagenti-controller-manager 2>/dev/null \ + | grep -iE "clientregistration|git-issue-agent" | tail -10 || echo "(operator not reachable)" + @echo "" + @echo "[✓] Validation complete — check output above." + +# ---------- Test ---------- + +test-ab: ## Run inbound validation tests (Steps 8a-8f): public endpoint, no-token, invalid-token, sidecar logs + @echo "[*] Starting test-client pod ..." + -kubectl run test-client --image=nicolaka/netshoot -n $(NAMESPACE) --restart=Never \ + -- sleep 3600 2>/dev/null + kubectl wait --for=condition=ready pod/test-client -n $(NAMESPACE) --timeout=30s + @echo "" + @echo "[*] 8a. Agent card (public endpoint — no token required):" + kubectl exec test-client -n $(NAMESPACE) -- curl -s \ + http://git-issue-agent:8080/.well-known/agent.json | grep -o '"name":"[^"]*"' + @echo "" + @echo "[*] 8b. Inbound rejection — no token:" + kubectl exec test-client -n $(NAMESPACE) -- curl -s http://git-issue-agent:8080/ + @echo "" + @echo "[*] 8c. Inbound rejection — invalid token:" + kubectl exec test-client -n $(NAMESPACE) -- curl -s \ + -H "Authorization: Bearer invalid-token" http://git-issue-agent:8080/ + @echo "" + @echo "[*] 8f. AuthBridge inbound validation logs:" + $(eval SIDECAR := $(shell kubectl get pod -n $(NAMESPACE) -l app.kubernetes.io/name=git-issue-agent \ + -o jsonpath='{.items[0].spec.containers[*].name}' 2>/dev/null \ + | tr ' ' '\n' | grep -E '^(authbridge-proxy|envoy-proxy)$$' | head -1)) + kubectl logs deployment/git-issue-agent -n $(NAMESPACE) -c $(SIDECAR) 2>&1 | grep "\[Inbound\]" || true + @echo "" + @echo "[✓] Test run complete." + +# ---------- RBAC test ---------- + +test-rbac: ## Show scope-based PAT selection logs (Alice vs Bob) — see demo-rbac.md §10 for full interactive steps + @echo "[*] GitHub tool scope-selection logs (last 20 lines):" + @kubectl logs deployment/github-tool -n $(NAMESPACE) 2>/dev/null \ + | grep -E "REQUIRED_SCOPE|scopes" | tail -20 || echo "(no relevant log lines yet)" + @echo "" + @printf "To run the full Alice vs Bob test interactively:\n" + @printf " \033[1mkubectl run test-client --image=nicolaka/netshoot -n $(NAMESPACE) --restart=Never -- sleep 3600\033[0m\n" + @printf " \033[1mkubectl exec -it test-client -n $(NAMESPACE) -- sh\033[0m\n" + @printf "Then follow Steps 10b–10d from demo-rbac.md.\n" + +# ---------- Clean ---------- + +clean: ## Delete all deployed resources and the namespace + @echo "[*] Removing deployments ..." + -kubectl delete -f ../k8s/git-issue-agent-deployment.yaml -n $(NAMESPACE) 2>/dev/null + -kubectl delete -f ../k8s/github-tool-deployment.yaml -n $(NAMESPACE) 2>/dev/null + @echo "[*] Removing secrets and ConfigMaps ..." + -kubectl delete -f ../k8s/configmaps.yaml -n $(NAMESPACE) 2>/dev/null + -kubectl delete secret github-tool-secrets -n $(NAMESPACE) 2>/dev/null + -kubectl delete pod test-client -n $(NAMESPACE) --ignore-not-found 2>/dev/null + @echo "[✓] Cleanup complete." diff --git a/authbridge/demos/github-issue/aiac/config.yaml b/authbridge/demos/github-issue/rbac/config.yaml similarity index 100% rename from authbridge/demos/github-issue/aiac/config.yaml rename to authbridge/demos/github-issue/rbac/config.yaml diff --git a/authbridge/demos/github-issue/aiac/policy.yaml b/authbridge/demos/github-issue/rbac/policy.yaml similarity index 97% rename from authbridge/demos/github-issue/aiac/policy.yaml rename to authbridge/demos/github-issue/rbac/policy.yaml index 44c262539..10037c1b5 100644 --- a/authbridge/demos/github-issue/aiac/policy.yaml +++ b/authbridge/demos/github-issue/rbac/policy.yaml @@ -33,5 +33,3 @@ policy: role: github-agent - client: github-tool role: github-tool-aud - -# Generated by PolicyBuilder using LangGraph diff --git a/authbridge/demos/github-issue/setup_keycloak.py b/authbridge/demos/github-issue/setup_keycloak.py index c0067a759..42a855598 100644 --- a/authbridge/demos/github-issue/setup_keycloak.py +++ b/authbridge/demos/github-issue/setup_keycloak.py @@ -54,11 +54,11 @@ are intentionally left alone. Usage: - python setup_keycloak.py -rbac config_file.yaml [-policy policy.yaml] [--reset-only] + python setup_keycloak.py -rbac /config.yaml [-policy /policy.yaml] [--reset-only] Arguments: - -rbac config_file.yaml Path to main configuration YAML file - -policy policy.yaml Path to access control policy YAML file (optional). + -rbac /config_file.yaml Path to main configuration YAML file + -policy /policy.yaml Path to access control policy YAML file (optional). Makes realm roles composites of the client roles declared in the policy, implementing RBAC through OAuth2 token scopes. @@ -81,7 +81,6 @@ in any production or internet-exposed environment. """ -import argparse import json import os import sys @@ -254,24 +253,7 @@ def get_or_create_user(keycloak_admin, user_config): raise -def main_manual(): - parser = argparse.ArgumentParser(description="Setup Keycloak for GitHub Issue Agent + AuthBridge demo") - parser.add_argument( - "--namespace", - "-n", - default=DEFAULT_NAMESPACE, - help=f"Kubernetes namespace (default: {DEFAULT_NAMESPACE})", - ) - parser.add_argument( - "--service-account", - "-s", - default=DEFAULT_SERVICE_ACCOUNT, - help=f"Service account name (default: {DEFAULT_SERVICE_ACCOUNT})", - ) - args = parser.parse_args() - - namespace = args.namespace - service_account = args.service_account +def main_manual(namespace: str = DEFAULT_NAMESPACE, service_account: str = DEFAULT_SERVICE_ACCOUNT): agent_spiffe_id = get_spiffe_id(namespace, service_account) print("=" * 70) @@ -1396,7 +1378,7 @@ def main_rbac(config_file: str, policy_file: Optional[str] = None, reset_only: b # 1. Load configuration (env + YAML) load_dotenv(script_dir / "aiac" / "aiac.env") - main_config_path = script_dir / "aiac" / config_file + main_config_path = script_dir / config_file print(f"Loading main configuration from {main_config_path} ...") main_config = load_main_config(main_config_path) @@ -1447,7 +1429,7 @@ def main_rbac(config_file: str, policy_file: Optional[str] = None, reset_only: b # 8.5. Apply access control policy (if provided) if policy_file: - policy_path = script_dir / "aiac" / policy_file + policy_path = script_dir / policy_file print(f"\nApplying access control policy from {policy_path} ...") client_id_mapping = {name: info["id"] for name, info in client_ids.items()} apply_access_control_policy(admin, realm, policy_path, client_id_mapping, scope_ids) @@ -1471,25 +1453,51 @@ def main_rbac(config_file: str, policy_file: Optional[str] = None, reset_only: b # =========================================================================== if __name__ == "__main__": - if "-rbac" in sys.argv: + import argparse + + parser = argparse.ArgumentParser( + description="Setup Keycloak for GitHub Issue Agent + AuthBridge demo", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog="""modes: + Manual mode (default, no -rbac): + python setup_keycloak.py [--namespace NS] [--service-account SA] + + RBAC mode (-rbac): + python setup_keycloak.py -rbac /config.yaml [-policy /policy.yaml] [--reset-only] +""", + ) + parser.add_argument( + "--namespace", + "-n", + default=DEFAULT_NAMESPACE, + help=f"Kubernetes namespace (default: {DEFAULT_NAMESPACE})", + ) + parser.add_argument( + "--service-account", + "-s", + default=DEFAULT_SERVICE_ACCOUNT, + help=f"Service account name (default: {DEFAULT_SERVICE_ACCOUNT})", + ) + parser.add_argument( + "-rbac", + metavar="CONFIG_FILE", + help="Path to main YAML config file — enables RBAC mode", + ) + parser.add_argument( + "-policy", + metavar="POLICY_FILE", + help="Path to access control policy YAML (RBAC mode, optional)", + ) + parser.add_argument( + "--reset-only", + action="store_true", + help="Run cleanup pass only, skip provisioning (RBAC mode)", + ) + args = parser.parse_args() + + if args.rbac: try: - idx = sys.argv.index("-rbac") - if idx + 1 >= len(sys.argv): - print( - "Usage: python setup_keycloak.py -rbac config.yaml [-policy policy.yaml] [--reset-only]", - file=sys.stderr, - ) - sys.exit(1) - config_file = sys.argv[idx + 1] - policy_file = None - if "-policy" in sys.argv: - pidx = sys.argv.index("-policy") - if pidx + 1 >= len(sys.argv): - print("Error: -policy requires a file argument", file=sys.stderr) - sys.exit(1) - policy_file = sys.argv[pidx + 1] - reset_only = "--reset-only" in sys.argv - main_rbac(config_file, policy_file=policy_file, reset_only=reset_only) + main_rbac(args.rbac, policy_file=args.policy, reset_only=args.reset_only) except Exception as e: import traceback @@ -1497,4 +1505,4 @@ def main_rbac(config_file: str, policy_file: Optional[str] = None, reset_only: b traceback.print_exc() sys.exit(1) else: - main_manual() + main_manual(namespace=args.namespace, service_account=args.service_account) From 40f2e5819cbf2481c5e96651574835074176571f Mon Sep 17 00:00:00 2001 From: Omer Boehm Date: Wed, 24 Jun 2026 16:05:01 +0300 Subject: [PATCH 6/7] Feat: Role-based scope gating for github-issue demo - addressing coderabbitai review Signed-off-by: Omer Boehm --- .../github-issue/aiac/scripts/show-result.py | 2 +- authbridge/demos/github-issue/demo-rbac.md | 2 +- authbridge/demos/github-issue/rbac/Makefile | 2 +- authbridge/demos/github-issue/setup_keycloak.py | 17 +++++++++++++++-- 4 files changed, 18 insertions(+), 5 deletions(-) diff --git a/authbridge/demos/github-issue/aiac/scripts/show-result.py b/authbridge/demos/github-issue/aiac/scripts/show-result.py index 461af727c..e2a40211e 100644 --- a/authbridge/demos/github-issue/aiac/scripts/show-result.py +++ b/authbridge/demos/github-issue/aiac/scripts/show-result.py @@ -98,7 +98,7 @@ def main() -> None: name = c.get("name", "?") print(f" {GREEN}✓{RESET} {container}.{name}") - gen_dir = AIAC_DIR / "config" / "generated" + gen_dir = AIAC_DIR / "config" policy_files = list(gen_dir.glob("*_policy.yaml")) if gen_dir.exists() else [] if policy_files: latest = max(policy_files, key=lambda p: p.stat().st_mtime_ns) diff --git a/authbridge/demos/github-issue/demo-rbac.md b/authbridge/demos/github-issue/demo-rbac.md index f5615e659..bdc3f76e1 100644 --- a/authbridge/demos/github-issue/demo-rbac.md +++ b/authbridge/demos/github-issue/demo-rbac.md @@ -264,7 +264,7 @@ uv pip install -r requirements.txt cd demos/github-issue # Run the Keycloak setup for this demo -python setup_keycloak.py -rbac aiac/config/rbac/config.yaml -policy aiac/config/rbac/policy.yaml +python setup_keycloak.py -rbac rbac/config.yaml -policy rbac/policy.yaml ``` diff --git a/authbridge/demos/github-issue/rbac/Makefile b/authbridge/demos/github-issue/rbac/Makefile index cadf44a04..4dd070c63 100644 --- a/authbridge/demos/github-issue/rbac/Makefile +++ b/authbridge/demos/github-issue/rbac/Makefile @@ -20,7 +20,7 @@ # - PRIVILEGED_ACCESS_PAT and PUBLIC_ACCESS_PAT set for the deploy target .PHONY: help preflight build-images setup-namespace setup-keycloak apply-configmaps \ - check-pats create-secret deploy-tool deploy-agent deploy validate test test-rbac clean + check-pats create-secret deploy-tool deploy-agent deploy validate test-ab test-rbac clean # `make` with no target prints help. .DEFAULT_GOAL := help diff --git a/authbridge/demos/github-issue/setup_keycloak.py b/authbridge/demos/github-issue/setup_keycloak.py index 42a855598..7c3c98ae7 100644 --- a/authbridge/demos/github-issue/setup_keycloak.py +++ b/authbridge/demos/github-issue/setup_keycloak.py @@ -1363,8 +1363,21 @@ def apply_access_control_policy( try: _add_client_role_to_realm_role_composite(admin, realm, user_role, client_id, role_name) print(f" ✓ Added client role '{client_name}.{role_name}' to realm role '{user_role}'") - except Exception as e: - print(f" ℹ Client role '{client_name}.{role_name}' already in composite or error: {e}") + except KeycloakPostError as e: + # HTTP 409 Conflict indicates the composite relationship already exists (idempotent) + if e.response_code == 409: + print( + f" ℹ Client role '{client_name}.{role_name}' already in composite for realm role '{user_role}'" + ) + else: + # Re-raise other Keycloak API errors (auth failures, malformed requests, etc.) + raise + except KeycloakGetError as e: + # Role lookup failures indicate missing roles - these should not be suppressed + raise RuntimeError( + f"Failed to add client role '{client_name}.{role_name}' to realm role '{user_role}': " + f"Role not found (client_id={client_id})" + ) from e # --------------------------------------------------------------------------- From a20a645c06d9903f6ba3102702ab85274c85fda1 Mon Sep 17 00:00:00 2001 From: Omer Boehm Date: Wed, 24 Jun 2026 20:22:15 +0300 Subject: [PATCH 7/7] Feat: Role-based scope gating for github-issue demo - addressing mrsabath review comments Signed-off-by: Omer Boehm --- authbridge/demos/github-issue/aiac/Makefile | 10 +++++----- .../demos/github-issue/aiac/scripts/show-result.py | 1 + 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/authbridge/demos/github-issue/aiac/Makefile b/authbridge/demos/github-issue/aiac/Makefile index faa9ff3a3..c8010b52b 100644 --- a/authbridge/demos/github-issue/aiac/Makefile +++ b/authbridge/demos/github-issue/aiac/Makefile @@ -31,12 +31,12 @@ help: ## Show this menu (default) $(MAKEFILE_LIST) @printf "\nVariables:\n" @printf " \033[36m%-20s\033[0m %s\n" "POLICY" "policy file to apply (default: policies/regular_policy.txt)" - @printf " \033[36m%-20s\033[0m %s\n" "RBAC_CONFIG" "RBAC config file (default: config/rbac/rbac_config.yaml)" + @printf " \033[36m%-20s\033[0m %s\n" "RBAC_CONFIG" "RBAC config file (default: rbac/config.yaml)" @printf " \033[36m%-20s\033[0m %s\n" "PYTHON" "Python interpreter (default: kagenti-extensions/.venv/bin/python)" @printf "\nPrerequisites: uv, Keycloak running, aiac.env and llm_conf.yaml configured.\n\n" POLICY ?= policies/regular_policy.txt -RBAC_CONFIG ?= ../rbac/config.yaml +RBAC_CONFIG ?= rbac/config.yaml # Resolve the venv relative to this Makefile, regardless of where make is invoked. MAKEFILE_DIR := $(dir $(abspath $(lastword $(MAKEFILE_LIST)))) @@ -83,7 +83,7 @@ preflight: ## Verify prerequisites (uv, venv, aiac.env, llm_conf.yaml, Keycloak setup: preflight ## Provision Keycloak realm with clients, roles, and users @echo "[*] Provisioning Keycloak realm ..." - $(PYTHON) ../setup_keycloak.py -rbac aiac/$(RBAC_CONFIG) + $(PYTHON) ../setup_keycloak.py -rbac $(RBAC_CONFIG) @echo "[✓] Keycloak realm provisioned." # ---------- Apply policy ---------- @@ -98,7 +98,7 @@ apply-permissive: apply-policy ## Generate + apply the permissive policy (grant # ---------- Show result ---------- show-result: preflight ## Show active composite-role mappings in Keycloak (verify applied policy) - $(PYTHON) scripts/show-result.py --config-path $(RBAC_CONFIG) + $(PYTHON) scripts/show-result.py --config-path ../$(RBAC_CONFIG) # ---------- Reset ---------- @@ -106,5 +106,5 @@ reset: preflight ## Wipe generated configs and re-provision the realm from scra @echo "[*] Removing generated configs ..." @rm -f config/*.yaml @echo "[*] Re-provisioning Keycloak realm ..." - $(PYTHON) ../setup_keycloak.py -rbac aiac/$(RBAC_CONFIG) + $(PYTHON) ../setup_keycloak.py -rbac $(RBAC_CONFIG) @echo "[✓] Reset complete." diff --git a/authbridge/demos/github-issue/aiac/scripts/show-result.py b/authbridge/demos/github-issue/aiac/scripts/show-result.py index e2a40211e..1c46d029d 100644 --- a/authbridge/demos/github-issue/aiac/scripts/show-result.py +++ b/authbridge/demos/github-issue/aiac/scripts/show-result.py @@ -51,6 +51,7 @@ def main() -> None: parser.add_argument( "--config-path", type=Path, + required=True, help="Path to the RBAC configuration YAML file", ) args = parser.parse_args()