Created
February 12, 2026 15:46
-
-
Save rileyseaburg/f6b9b1db94b9049710a8d3cfcd7b84c5 to your computer and use it in GitHub Desktop.
OPA + Keycloak Authorization — One Sheet
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # OPA + Keycloak Authorization — One Sheet | |
| > **What:** Centralized RBAC authorization using Open Policy Agent (OPA) with Keycloak as the identity provider. | |
| > **Why:** Declarative, auditable access control without scattering `if user.role == "admin"` checks across your codebase. | |
| --- | |
| ## Architecture | |
| ``` | |
| ┌──────────┐ JWT (OIDC) ┌───────────────┐ HTTP POST ┌─────────┐ | |
| │ Keycloak │ ──────────────────▶│ Your Service │ ──────────────────▶│ OPA │ | |
| │ (IdP) │ access_token │ (FastAPI) │ /v1/data/authz/ │ sidecar │ | |
| └──────────┘ with roles │ │◀───────────────── │ │ | |
| │ Middleware │ { "result": T/F } │ Rego │ | |
| └───────────────┘ │ policies│ | |
| └─────────┘ | |
| ``` | |
| 1. **Keycloak** authenticates users and issues JWTs containing realm roles (`admin`, `editor`, `viewer`, etc.) | |
| 2. **Your service** validates the JWT, extracts `user_id`, `roles`, `tenant_id` | |
| 3. **OPA sidecar** (or local mode) evaluates Rego policies against the request and returns allow/deny | |
| --- | |
| ## How It Works — 3 Layers | |
| ### Layer 1: Rego Policies (the rules) | |
| Policies live in `policies/` and are loaded into OPA at startup. | |
| | File | Purpose | | |
| |------|---------| | |
| | `authz.rego` | Core RBAC — maps roles → permissions, evaluates `allow` | | |
| | `api_keys.rego` | Scope enforcement for API key auth (wildcards supported) | | |
| | `tenants.rego` | Tenant isolation — users can only access their own tenant's resources | | |
| | `data.json` | Role definitions and permission mappings (loaded as OPA data) | | |
| **Core decision logic** (`authz.rego`): | |
| ```rego | |
| package authz | |
| default allow := false | |
| # Public endpoints are always allowed | |
| allow if { input.action in data.public_endpoints } | |
| # Role-based: user's roles grant the requested permission | |
| allow if { input.action in role_permissions } | |
| ``` | |
| ### Layer 2: Middleware (automatic enforcement) | |
| A single Starlette middleware intercepts every HTTP request, matches it against a route table, and enforces the corresponding permission — no per-endpoint code changes needed. | |
| ```python | |
| # policy_middleware.py — route → permission mapping (excerpt) | |
| _RULES = [ | |
| (r"^/health$", None, ""), # public, skip auth | |
| (r"^/v1/agent/tasks$", {"POST"}, "tasks:write"), # requires tasks:write | |
| (r"^/v1/agent/tasks", {"GET"}, "tasks:read"), # requires tasks:read | |
| (r"^/v1/admin/", None, ""), # already has its own auth | |
| ... | |
| ] | |
| ``` | |
| Rules are evaluated top-to-bottom, first match wins. Empty permission (`""`) = skip auth. | |
| ### Layer 3: Python Client (programmatic checks) | |
| For resource-level checks beyond what middleware catches: | |
| ```python | |
| from a2a_server.policy import require_permission, enforce_policy | |
| # Option A: FastAPI dependency (decorative) | |
| @router.get("/tasks") | |
| async def list_tasks(user=Depends(require_permission("tasks:read"))): | |
| ... | |
| # Option B: Inline enforcement (imperative) | |
| async def update_task(task_id: str, user: dict): | |
| await enforce_policy(user, "tasks:write", resource={ | |
| "type": "task", | |
| "id": task_id, | |
| "owner_id": task.owner_id, | |
| "tenant_id": task.tenant_id, | |
| }) | |
| ``` | |
| --- | |
| ## OPA Input Schema | |
| Every policy decision receives this JSON: | |
| ```json | |
| { | |
| "input": { | |
| "user": { | |
| "user_id": "uuid-here", | |
| "roles": ["editor"], | |
| "tenant_id": "tenant-123", | |
| "scopes": [], | |
| "auth_source": "keycloak" | |
| }, | |
| "action": "tasks:write", | |
| "resource": { | |
| "type": "task", | |
| "id": "task-456", | |
| "owner_id": "uuid-owner", | |
| "tenant_id": "tenant-123" | |
| } | |
| } | |
| } | |
| ``` | |
| | Field | Source | Notes | | |
| |-------|--------|-------| | |
| | `user.roles` | Keycloak JWT `realm_access.roles` | Or `["editor"]` default for self-service users | | |
| | `user.auth_source` | Detected automatically | `"keycloak"`, `"api_key"`, or `"self-service"` | | |
| | `user.scopes` | API key record | Only populated for API key auth | | |
| | `resource.*` | Your code | Optional — only needed for resource-level checks | | |
| --- | |
| ## RBAC Roles | |
| Hierarchy: **admin** > **operator** > **editor** > **viewer** | |
| | Role | Description | Example Permissions | | |
| |------|-------------|---------------------| | |
| | `admin` | Full system access | `admin:access`, `tasks:*`, `codebases:*`, everything | | |
| | `a2a-admin` | Keycloak alias | Inherits all `admin` permissions | | |
| | `operator` | Ops management | `tasks:read/write`, `workers:read/write`, `monitor:*` | | |
| | `editor` | Standard user | `tasks:read/write`, `codebases:read/write`, `sessions:*` | | |
| | `viewer` | Read-only | `tasks:read`, `codebases:read`, `monitor:read` | | |
| | `a2a-user` | Keycloak alias | Inherits all `editor` permissions | | |
| | `a2a-agent` | Service accounts | `tasks:read/write/execute`, `sessions:*`, `voice:*` | | |
| Permissions follow `resource:action` format: `tasks:read`, `codebases:write`, `admin:access`. | |
| --- | |
| ## Deployment Modes | |
| ### Mode 1: OPA Sidecar (Production) | |
| OPA runs as a sidecar container alongside your app. Policies are mounted via ConfigMap. | |
| ```yaml | |
| # Helm values | |
| opa: | |
| enabled: true # deploys OPA sidecar + ConfigMap | |
| # Environment | |
| OPA_URL=http://localhost:8181 # sidecar is on localhost | |
| OPA_ENABLED=true | |
| ``` | |
| **Kubernetes ConfigMap** auto-bundles all `.rego` files and `data.json`: | |
| ```yaml | |
| # chart/templates/opa-configmap.yaml | |
| data: | |
| data.json: |- ... | |
| authz.rego: |- ... | |
| api_keys.rego: |- ... | |
| tenants.rego: |- ... | |
| ``` | |
| ### Mode 2: Local / Dev (No Sidecar) | |
| Evaluates the same policy logic in-process — no OPA container needed. | |
| ```bash | |
| OPA_LOCAL_MODE=true # evaluate policies in Python | |
| OPA_ENABLED=true | |
| ``` | |
| ### Master Kill Switch | |
| ```bash | |
| OPA_ENABLED=false # disables ALL policy enforcement (emergency use only) | |
| ``` | |
| --- | |
| ## Environment Variables | |
| | Variable | Default | Description | | |
| |----------|---------|-------------| | |
| | `OPA_ENABLED` | `true` | Master toggle for all policy enforcement | | |
| | `OPA_URL` | `http://localhost:8181` | OPA sidecar address | | |
| | `OPA_LOCAL_MODE` | `false` | Evaluate policies in-process (no sidecar needed) | | |
| | `OPA_FAIL_OPEN` | `false` | If OPA is unreachable: `false` = deny, `true` = allow | | |
| | `OPA_CACHE_TTL` | `5.0` | Decision cache TTL in seconds (0 = disabled) | | |
| | `OPA_TIMEOUT` | `2.0` | HTTP request timeout to OPA (seconds) | | |
| | `DEFAULT_USER_ROLE` | `editor` | Fallback role for users with no explicit assignment | | |
| | `KEYCLOAK_URL` | — | Keycloak base URL (e.g. `https://auth.example.com`) | | |
| | `KEYCLOAK_REALM` | — | Keycloak realm name | | |
| | `KEYCLOAK_CLIENT_ID` | — | OIDC client ID | | |
| | `KEYCLOAK_CLIENT_SECRET` | — | OIDC client secret | | |
| --- | |
| ## Keycloak Setup | |
| 1. **Create a realm** (e.g. `my-service`) | |
| 2. **Create realm roles**: `admin`, `operator`, `editor`, `viewer` (or use `a2a-admin`, `a2a-user` aliases) | |
| 3. **Create an OIDC client** with: | |
| - Access Type: `confidential` | |
| - Valid Redirect URIs: your app's callback URL | |
| - Enable "Service Accounts" if you need machine-to-machine auth | |
| 4. **Assign roles** to users in Keycloak — they appear in the JWT under `realm_access.roles` | |
| 5. **Set env vars**: `KEYCLOAK_URL`, `KEYCLOAK_REALM`, `KEYCLOAK_CLIENT_ID`, `KEYCLOAK_CLIENT_SECRET` | |
| --- | |
| ## Adapting for Your Service | |
| ### Step 1: Define your permissions | |
| Edit `policies/data.json` — add/remove permissions for each role: | |
| ```json | |
| { | |
| "roles": { | |
| "editor": { | |
| "permissions": ["invoices:read", "invoices:write", "reports:read"] | |
| } | |
| } | |
| } | |
| ``` | |
| ### Step 2: Map your routes | |
| Add entries to the route table in your middleware: | |
| ```python | |
| _RULES = [ | |
| (r"^/api/invoices$", {"GET"}, "invoices:read"), | |
| (r"^/api/invoices$", {"POST"}, "invoices:write"), | |
| (r"^/api/invoices/", {"DELETE"}, "invoices:delete"), | |
| (r"^/api/reports", {"GET"}, "reports:read"), | |
| ] | |
| ``` | |
| ### Step 3: Add the middleware | |
| ```python | |
| from policy_middleware import PolicyAuthorizationMiddleware | |
| app = FastAPI() | |
| app.add_middleware(PolicyAuthorizationMiddleware) | |
| ``` | |
| ### Step 4: Deploy OPA | |
| **Docker Compose:** | |
| ```yaml | |
| services: | |
| opa: | |
| image: openpolicyagent/opa:latest | |
| command: ["run", "--server", "--addr", "0.0.0.0:8181", "/policies"] | |
| volumes: | |
| - ./policies:/policies | |
| ``` | |
| **Kubernetes (Helm):** | |
| ```yaml | |
| opa: | |
| enabled: true | |
| # Policies are mounted from ConfigMap automatically | |
| ``` | |
| --- | |
| ## Testing Policies | |
| ```bash | |
| # Run all OPA policy tests | |
| opa test policies/ -v | |
| # Test a specific decision manually | |
| curl -X POST http://localhost:8181/v1/data/authz/allow \ | |
| -H 'Content-Type: application/json' \ | |
| -d '{ | |
| "input": { | |
| "user": {"user_id": "u1", "roles": ["editor"], "tenant_id": "t1", "scopes": [], "auth_source": "keycloak"}, | |
| "action": "tasks:read", | |
| "resource": {} | |
| } | |
| }' | |
| # → {"result": true} | |
| # Python integration tests | |
| python -m pytest tests/test_policy.py tests/test_policy_middleware.py -v | |
| ``` | |
| --- | |
| ## Decision Flow Summary | |
| ``` | |
| Request arrives | |
| │ | |
| ▼ | |
| Middleware matches path → permission string | |
| │ | |
| ├─ "" (empty) → SKIP (public or already protected) | |
| │ | |
| ├─ No match → PASS THROUGH | |
| │ | |
| └─ "tasks:write" → Resolve user from JWT | |
| │ | |
| ├─ No user → 401 | |
| │ | |
| └─ OPA check(user, "tasks:write") | |
| │ | |
| ├─ allowed → proceed to handler | |
| │ | |
| └─ denied → 403 | |
| ``` |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment