This repository has been archived on 2026-04-16. You can view files and clone it, but cannot push or open issues or pull requests.
llm-principal-broker/llm_broker/keycloak.py
Tyler King 944b3fde19 feat: LLM Principal Broker MVP
FastAPI companion service to the GSAP broker for AI agent
identity delegation in governed shell sessions.

Implements GCAP-SPEC-LLM-PRINCIPAL-BROKER-0001:
  POST /delegate — request delegation (human → AI agent)
  POST /delegate/{id}/revoke — revoke delegation
  GET  /delegate/{id} — delegation status
  GET  /agents — list active delegations
  GET  /health — health check

Delegation lifecycle:
  REQUESTED → ACTIVE → EXPIRED | REVOKED
  Cascading revocation on delegator AC revocation
  Background cleanup of expired delegations (30s interval)

Keycloak integration:
  Registers ephemeral agent clients per delegation
  Deletes clients on revocation/expiry
  Dev mode: stubs when no client_secret configured

GSAP broker integration:
  Requests delegated ACs via on_behalf_of pattern
  Scope narrowing: agent ceiling ≤ delegator ceiling
  Dev mode: stubs when no bearer_token configured

Chronicle integration:
  DELEGATION_CREATED (0x3001)
  DELEGATION_REVOKED (0x3003)
  DELEGATION_EXPIRED (0x3004)

All 7 smoke tests pass (health, create, list, query, revoke, verify, empty).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 16:28:37 -04:00

138 lines
4.8 KiB
Python

"""Keycloak Admin API client — ephemeral agent client registration.
Registers and deletes confidential Keycloak clients for AI agent
delegations per GCAP-SPEC-LLM-PRINCIPAL-BROKER-0001 §4.1.
"""
import logging
from typing import Optional
import httpx
logger = logging.getLogger(__name__)
class KeycloakAdmin:
def __init__(self, base_url: str, realm: str, client_id: str, client_secret: str):
self.base_url = base_url.rstrip("/")
self.realm = realm
self.client_id = client_id
self.client_secret = client_secret
self._token: Optional[str] = None
async def _get_admin_token(self) -> str:
async with httpx.AsyncClient(timeout=10.0) as client:
resp = await client.post(
f"{self.base_url}/realms/{self.realm}/protocol/openid-connect/token",
data={
"grant_type": "client_credentials",
"client_id": self.client_id,
"client_secret": self.client_secret,
},
)
resp.raise_for_status()
self._token = resp.json()["access_token"]
return self._token
async def _headers(self) -> dict:
if not self._token:
await self._get_admin_token()
return {"Authorization": f"Bearer {self._token}"}
async def register_agent_client(
self,
client_id: str,
display_name: str,
delegator_did: str,
delegation_id: str,
agent_type: str,
) -> dict:
"""Register ephemeral Keycloak client for an AI agent."""
if not self.client_secret:
logger.info("Keycloak not configured — dev mode stub for %s", client_id)
return {"client_id": client_id, "client_secret": f"dev-secret-{delegation_id}", "client_uuid": None}
headers = await self._headers()
client_rep = {
"clientId": client_id,
"name": display_name,
"enabled": True,
"serviceAccountsEnabled": True,
"directAccessGrantsEnabled": False,
"publicClient": False,
"protocol": "openid-connect",
"attributes": {
"agent_type": agent_type,
"delegator_did": delegator_did,
"delegation_id": delegation_id,
},
}
async with httpx.AsyncClient(timeout=10.0) as http:
resp = await http.post(
f"{self.base_url}/admin/realms/{self.realm}/clients",
json=client_rep,
headers=headers,
)
if resp.status_code == 401:
headers = {"Authorization": f"Bearer {await self._get_admin_token()}"}
resp = await http.post(
f"{self.base_url}/admin/realms/{self.realm}/clients",
json=client_rep,
headers=headers,
)
resp.raise_for_status()
# Retrieve the generated client secret
location = resp.headers.get("Location", "")
client_uuid = location.rstrip("/").split("/")[-1] if location else None
client_secret = ""
if client_uuid:
secret_resp = await http.get(
f"{self.base_url}/admin/realms/{self.realm}/clients/{client_uuid}/client-secret",
headers=headers,
)
if secret_resp.status_code == 200:
client_secret = secret_resp.json().get("value", "")
logger.info("Registered agent client: %s (uuid=%s)", client_id, client_uuid)
return {
"client_id": client_id,
"client_secret": client_secret,
"client_uuid": client_uuid,
}
async def delete_agent_client(self, client_id: str) -> bool:
"""Delete ephemeral agent client on revocation/expiry."""
if not self.client_secret:
logger.info("Keycloak not configured — dev mode stub delete for %s", client_id)
return True
headers = await self._headers()
async with httpx.AsyncClient(timeout=10.0) as http:
resp = await http.get(
f"{self.base_url}/admin/realms/{self.realm}/clients",
params={"clientId": client_id},
headers=headers,
)
if resp.status_code != 200:
return False
clients = resp.json()
if not clients:
return False
client_uuid = clients[0]["id"]
del_resp = await http.delete(
f"{self.base_url}/admin/realms/{self.realm}/clients/{client_uuid}",
headers=headers,
)
deleted = del_resp.status_code in (200, 204)
if deleted:
logger.info("Deleted agent client: %s", client_id)
return deleted